Community project
Touchscreen Pomodoro Timer

Build a compact Pomodoro timer with a vibrant 1.28-inch round touchscreen display powered by an ESP32-S3 microcontroller. This project combines the Waveshare ESP32-S3-Touch-LCD module with capacitive touch controls to create an interactive focus timer that guides users through work and break cycles with visual feedback and celebratory confetti animations.
This guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions to get the timer running in minutes. Flash the included firmware to enable intuitive touch gestures—tap to pause/resume, swipe to adjust session length, and long-press to reset. An optional LiPo battery connection makes the timer portable for use anywhere.
Wiring diagram
Interactive · read-onlyPan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| Waveshare ESP32-S3-Touch-LCD-1.28 | 1 | All-in-one ESP32-S3 board with 1.28" round 240x240 GC9A01 LCD, CST816S capacitive touch, QMI8658 IMU, LiPo charger. Touch screen is the primary input — tap to pause/resume, swipe up/down to adjust session durations. |
Assembly
5 stepsWhat's in the box
The Waveshare ESP32-S3-Touch-LCD-1.28 is a self-contained development board. It already has the 1.28" round 240×240 GC9A01 display, the CST816S capacitive touch layer, and the QMI8658 IMU soldered on. No external wiring to the display or touch controller is needed.
- Tip: Inspect the USB-C port and make sure no debris is inside.
- Tip: The ceramic antenna (the white block near one corner) must be kept clear of metal objects for Wi-Fi/BLE to work — not used for this project but good practice.
Connect USB-C cable
Plug a USB-C cable from the board's USB-C port to your computer or a 5V USB charger. The board is powered and ready to flash via Schematik's Deploy button.
- Tip: Use a data-capable USB-C cable, not a charge-only cable, so Schematik can communicate with the board.
- Tip: The onboard CH343P chip handles USB-to-serial conversion automatically — no extra adapter needed.
- ⚠ Do NOT connect anything else while the board is live unless you know what you are doing. The SH1.0 GPIO connector on the side exposes 3.3V GPIO pins.
Flash the firmware
Click the Deploy button in Schematik. The board will auto-reset into download mode via the CH343P RTS/DTR handshake. If the flash fails, hold BOOT then tap RESET, then release BOOT — this manually enters download mode. After flashing, the board restarts automatically.
- Tip: The first deploy may take 30-60 seconds as PlatformIO downloads the ESP32-S3 toolchain and libraries.
- Tip: Monitor the Deploy panel for 'Done in X seconds' to confirm success.
First run & touch controls
After flashing, the round display lights up with a red arc and a 25:00 countdown — that is your focus session. Touch controls: • Tap anywhere → pause / resume the timer • Swipe LEFT → decrease current session length by 1 minute (min 1 min) • Swipe RIGHT → increase current session length by 1 minute (max 60 min) • Hold for > 0.6 s → reset and restart the session • When session ends (DONE! screen appears) → tap to move to the next phase At the end of a focus session confetti bursts on screen for ~3 seconds before the DONE! prompt.
- Tip: The capacitive touch is most accurate in the central area of the round display; the very edge can be slightly less responsive — this is normal.
- Tip: Adjusting the session length mid-countdown restarts the countdown with the new duration.
- Tip: Focus sessions show a red arc; break sessions show a green arc; a paused timer shows a yellow arc.
Optional: LiPo battery
The board has an MX1.25 2-pin battery header (near the bottom edge) for a 3.7V LiPo battery. Connect a single-cell 3.7V LiPo with an MX1.25 connector to make the timer portable. The onboard ETA6096 charger handles charging automatically when USB-C is also connected.
- Tip: A 500–1000 mAh cell will run the timer for many hours.
- ⚠ Only use a single-cell 3.7V LiPo. Never exceed the voltage rating.
- ⚠ Ensure the LiPo's polarity matches the header — positive to the + pad. Check the silkscreen on the PCB before connecting.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| EXT | board USB-C → USB-C power / PC | power |
Firmware
ESP32// ============================================================
// Pomodoro Focus Timer — Waveshare ESP32-S3-Touch-LCD-1.28
// 240×240 round GC9A01 display · CST816S capacitive touch
//
// Controls (touch screen):
// Single tap → pause / resume timer
// Swipe LEFT → decrease current session by 1 min (min 1)
// Swipe RIGHT → increase current session by 1 min (max 60)
// Long-press (>600ms) → reset / restart session
// When timer just finished → tap to start next session
//
// States: SETTINGS → FOCUS → BREAK → (loop)
// Confetti fires when focus session completes.
// ============================================================
#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <TFT_eSPI.h>
// ---- Pin constants ----------------------------------------
#define TFT_BL_PIN 2
#define TOUCH_SDA_PIN 6
#define TOUCH_SCL_PIN 7
#define TOUCH_RST_PIN 13
#define TOUCH_INT_PIN 5
// ---- CST816S registers (I2C addr 0x15) --------------------
#define CST816S_ADDR 0x15
#define REG_GESTURE 0x01
#define REG_FINGER_NUM 0x02
#define REG_XPOS_H 0x03
#define REG_XPOS_L 0x04
#define REG_YPOS_H 0x05
#define REG_YPOS_L 0x06
// Gesture codes
#define GEST_NONE 0x00
#define GEST_SWIPE_UP 0x01
#define GEST_SWIPE_DOWN 0x02
#define GEST_SWIPE_LEFT 0x03
#define GEST_SWIPE_RIGHT 0x04
#define GEST_SINGLE_TAP 0x05
#define GEST_LONG_PRESS 0x0C
// ---- Display ----------------------------------------------
// Hoisted type definitions
enum Phase { FOCUS, BREAK };
enum RunState { RUNNING, PAUSED, DONE };
struct Particle {
float x, y, vx, vy;
uint16_t col;
uint8_t size;
bool alive;
};
struct TouchData { uint8_t gesture; bool touched; int x; int y; };
// Forward declarations
TouchData readTouch();
void initTouch();
void swapPhase();
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite spr = TFT_eSprite(&tft);
// ---- Colours (RGB565) -------------------------------------
#define COL_BG 0x0841 // very dark navy
#define COL_RING_BG 0x2124 // dim grey ring
#define COL_FOCUS 0xF800 // red-orange
#define COL_BREAK 0x07E0 // green
#define COL_PAUSED 0xFFE0 // yellow
#define COL_TEXT 0xFFFF // white
#define COL_SUB 0xC618 // light grey
// ---- Timer state ------------------------------------------
Phase phase = FOCUS;
RunState runState = RUNNING;
int focusMinutes = 25;
int breakMinutes = 5;
uint32_t sessionSecs = 0; // total seconds in current session
uint32_t remainSecs = 0; // seconds remaining
uint32_t lastTick = 0; // millis of last second decrement
// ---- Touch tracking ---------------------------------------
bool touchWasDown = false;
uint32_t touchDownAt = 0;
bool gestureHandled = false;
// ---- Confetti ---------------------------------------------
#define MAX_CONFETTI 60
Particle particles[MAX_CONFETTI];
bool confettiActive = false;
uint32_t confettiStart = 0;
#define CONFETTI_DURATION_MS 3000
// ---- Forward declarations ---------------------------------
void resetSession();
void drawFace();
void drawArc(float progress, uint16_t colour);
void drawConfettiFrame();
void spawnConfetti();
uint16_t randomCol();
// ---- CST816S helpers --------------------------------------
TouchData readTouch() {
TouchData td = {GEST_NONE, false, 0, 0};
Wire.beginTransmission(CST816S_ADDR);
Wire.write(REG_GESTURE);
if (Wire.endTransmission(false) != 0) return td;
Wire.requestFrom((uint8_t)CST816S_ADDR, (uint8_t)6);
if (Wire.available() < 6) return td;
uint8_t gest = Wire.read();
uint8_t fingers = Wire.read() & 0x0F;
uint8_t xh = Wire.read() & 0x0F;
uint8_t xl = Wire.read();
uint8_t yh = Wire.read() & 0x0F;
uint8_t yl = Wire.read();
td.gesture = gest;
td.touched = (fingers > 0);
td.x = (xh << 8) | xl;
td.y = (yh << 8) | yl;
return td;
}
void initTouch() {
pinMode(TOUCH_RST_PIN, OUTPUT);
digitalWrite(TOUCH_RST_PIN, LOW); delay(20);
digitalWrite(TOUCH_RST_PIN, HIGH); delay(50);
Wire.begin(TOUCH_SDA_PIN, TOUCH_SCL_PIN, 400000UL);
}
// ---- Helpers ----------------------------------------------
void resetSession() {
sessionSecs = (uint32_t)(phase == FOCUS ? focusMinutes : breakMinutes) * 60;
remainSecs = sessionSecs;
runState = RUNNING;
lastTick = millis();
confettiActive = false;
}
void swapPhase() {
phase = (phase == FOCUS) ? BREAK : FOCUS;
resetSession();
}
// ---- Arc drawing ------------------------------------------
#define ARC_R 105 // outer radius of arc
#define ARC_W 10 // arc width in pixels
#define CX 120
#define CY 120
void drawArc(float progress, uint16_t colour) {
// Draw background arc (dim)
for (int deg = 0; deg < 360; deg++) {
float rad = deg * 0.01745329f;
int x0 = CX + (int)((ARC_R - ARC_W) * sinf(rad));
int y0 = CY - (int)((ARC_R - ARC_W) * cosf(rad));
int x1 = CX + (int)(ARC_R * sinf(rad));
int y1 = CY - (int)(ARC_R * cosf(rad));
spr.drawLine(x0, y0, x1, y1, COL_RING_BG);
}
// Draw foreground arc (progress)
int endDeg = (int)(progress * 360.0f);
for (int deg = 0; deg < endDeg; deg++) {
float rad = deg * 0.01745329f;
int x0 = CX + (int)((ARC_R - ARC_W) * sinf(rad));
int y0 = CY - (int)((ARC_R - ARC_W) * cosf(rad));
int x1 = CX + (int)(ARC_R * sinf(rad));
int y1 = CY - (int)(ARC_R * cosf(rad));
spr.drawLine(x0, y0, x1, y1, colour);
}
}
// ---- Confetti helpers -------------------------------------
uint16_t randomCol() {
static const uint16_t cols[] = {0xF800, 0x07E0, 0x001F, 0xFFE0, 0xF81F, 0x07FF, 0xFD20};
return cols[random(0, 7)];
}
void spawnConfetti() {
for (int i = 0; i < MAX_CONFETTI; i++) {
particles[i].x = random(30, 210);
particles[i].y = random(30, 100);
particles[i].vx = ((float)random(-30, 30)) / 10.0f;
particles[i].vy = ((float)random(5, 20)) / 10.0f;
particles[i].col = randomCol();
particles[i].size = random(2, 5);
particles[i].alive = true;
}
confettiActive = true;
confettiStart = millis();
}
void drawConfettiFrame() {
for (int i = 0; i < MAX_CONFETTI; i++) {
if (!particles[i].alive) continue;
particles[i].x += particles[i].vx;
particles[i].y += particles[i].vy;
particles[i].vy += 0.15f; // gravity
if (particles[i].y > 240 || particles[i].x < 0 || particles[i].x > 240)
particles[i].alive = false;
else
spr.fillRect((int)particles[i].x, (int)particles[i].y,
particles[i].size, particles[i].size, particles[i].col);
}
}
// ---- Main face drawing ------------------------------------
void drawFace() {
spr.fillSprite(COL_BG);
uint16_t arcCol = (runState == PAUSED) ? COL_PAUSED :
(phase == FOCUS) ? COL_FOCUS : COL_BREAK;
float progress = (sessionSecs > 0) ?
1.0f - ((float)remainSecs / (float)sessionSecs) : 1.0f;
if (runState != DONE) {
drawArc(progress, arcCol);
}
if (confettiActive) {
drawConfettiFrame();
}
// ---- Centre text ----
spr.setTextDatum(MC_DATUM);
if (runState == DONE) {
// "DONE!" splash with confetti still going
spr.setTextColor(COL_FOCUS, COL_BG);
spr.setTextSize(1);
spr.loadFont(""); // default
spr.drawString(phase == BREAK ? "BREAK!" : "DONE!", CX, CY - 16, 4);
spr.setTextColor(COL_SUB, COL_BG);
spr.drawString("tap to continue", CX, CY + 20, 2);
if (confettiActive) drawConfettiFrame();
} else {
// Countdown mm:ss
uint32_t mins = remainSecs / 60;
uint32_t secs = remainSecs % 60;
char timeBuf[8];
snprintf(timeBuf, sizeof(timeBuf), "%02u:%02u", mins, secs);
spr.setTextColor(COL_TEXT, COL_BG);
spr.drawString(timeBuf, CX, CY - 10, 6);
// Phase label
const char* label = (phase == FOCUS) ? "FOCUS" : "BREAK";
uint16_t labelCol = (phase == FOCUS) ? COL_FOCUS : COL_BREAK;
spr.setTextColor(labelCol, COL_BG);
spr.drawString(label, CX, CY + 34, 2);
// Paused indicator
if (runState == PAUSED) {
spr.setTextColor(COL_PAUSED, COL_BG);
spr.drawString("paused", CX, CY + 54, 2);
}
// Adjustment hint (bottom)
spr.setTextColor(COL_SUB, COL_BG);
spr.drawString("< swipe > to adjust", CX, 200, 1);
spr.drawString("tap: pause/resume", CX, 212, 1);
spr.drawString("hold: reset", CX, 224, 1);
}
spr.pushSprite(0, 0);
}
// ---- setup() ----------------------------------------------
void setup() {
Serial.begin(115200);
// Backlight on
pinMode(TFT_BL_PIN, OUTPUT);
digitalWrite(TFT_BL_PIN, HIGH);
// Display init
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
// Sprite (full-screen double buffer)
spr.createSprite(240, 240);
spr.setSwapBytes(true);
// Touch
initTouch();
// Seed RNG
randomSeed(esp_random());
// Start first session
resetSession();
Serial.println("Pomodoro timer ready.");
}
// ---- loop() -----------------------------------------------
void loop() {
uint32_t now = millis();
// ---- Tick timer ----
if (runState == RUNNING && (now - lastTick >= 1000)) {
lastTick += 1000;
if (remainSecs > 0) {
remainSecs--;
}
if (remainSecs == 0 && runState == RUNNING) {
runState = DONE;
if (phase == FOCUS) spawnConfetti();
}
}
// ---- Advance confetti timeout ----
if (confettiActive && (now - confettiStart > CONFETTI_DURATION_MS)) {
confettiActive = false;
}
// ---- Touch handling ----
TouchData td = readTouch();
if (td.touched && !touchWasDown) {
// touch just pressed
touchWasDown = true;
touchDownAt = now;
gestureHandled = false;
}
if (td.touched && touchWasDown && !gestureHandled) {
uint32_t held = now - touchDownAt;
// Long-press → reset
if (held > 600) {
gestureHandled = true;
resetSession();
}
// Swipe detected
else if (td.gesture == GEST_SWIPE_LEFT && !gestureHandled) {
gestureHandled = true;
if (runState == DONE) {
// ignore swipe on done screen, treat as tap
} else {
if (phase == FOCUS) focusMinutes = max(1, focusMinutes - 1);
else breakMinutes = max(1, breakMinutes - 1);
// Adjust remaining proportionally
sessionSecs = (uint32_t)(phase == FOCUS ? focusMinutes : breakMinutes) * 60;
remainSecs = sessionSecs; // restart with new duration
}
}
else if (td.gesture == GEST_SWIPE_RIGHT && !gestureHandled) {
gestureHandled = true;
if (runState != DONE) {
if (phase == FOCUS) focusMinutes = min(60, focusMinutes + 1);
else breakMinutes = min(60, breakMinutes + 1);
sessionSecs = (uint32_t)(phase == FOCUS ? focusMinutes : breakMinutes) * 60;
remainSecs = sessionSecs;
}
}
}
if (!td.touched && touchWasDown) {
// finger lifted
uint32_t held = now - touchDownAt;
if (!gestureHandled && held < 600) {
// Short tap
if (runState == DONE) {
// Move to next phase
swapPhase();
} else if (runState == RUNNING) {
runState = PAUSED;
} else if (runState == PAUSED) {
runState = RUNNING;
lastTick = now; // avoid large tick jump
}
}
touchWasDown = false;
gestureHandled = false;
}
// ---- Draw ----
drawFace();
// Small delay to keep ~30 fps without hogging the CPU
delay(33);
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.