Desk Pal pomodoro tomato

A ripe-red 25-minute focus ring on the round puck. Tap to start, double-tap to skip, flip it over to jump phases.

Desk Pal pomodoro tomato
DifficultyBeginner
Build time50 min
Est. cost$30
Parts4
Files6
Steps3

Things you'll need

QtyPartType
1
Waveshare ESP32-S3-Touch-LCD-1.28 (Desk Pal puck)
Waveshare ESP32-S3-Touch-LCD-1.28 (Desk Pal puck) Round 240×240 GC9A01, CST816S touch, QMI8658 IMU, USB-C. Amazon B0CM68M8LR.
electronics
1
Tomato timer shell
Tomato timer shell Round tomato-red shell with a tiny stem. Print the body in red, stem in green.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 15–25 g. PETG if the puck lives in a bag.
consumable

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.

What you're building

A pomodoro timer: 25 minutes of focus (ripe-tomato red), then 5 minutes of break (leaf green), then the next tomato. The remaining time sits in the center. A progress arc walks around the rim like a pie getting eaten.

Tap to start or pause. Double-tap to skip a phase. Long-press to reset. Flip the puck upside-down to skip — like turning a kitchen timer over. Finished focus sessions are remembered even if you unplug USB.

What you will learn

  • How computers tell time with millis() (a stopwatch, not a wall clock).
  • How a phase (focus vs break) is just a variable that picks colors and duration.
  • How tap / double-tap / long-press are classified from one button-like sensor.
  • How a number can survive unplugging (saved in flash memory).

Meet the Desk Pal puck

This project uses the Waveshare ESP32-S3-Touch-LCD-1.28 — a round gadget about the size of a drink coaster.

  • A 240×240 color screen (think 240 dots across and 240 down).
  • A touch glass, so a finger is a mouse.
  • A tiny motion sensor (IMU) that feels shakes and flips.
  • Wi-Fi that only speaks 2.4 GHz. School guest networks that are 5 GHz only will be invisible to it.
  • A USB-C port for power and for loading the program.

The program you load is a .ino file — that is Arduino-style C++. You do not need to understand every line. This guide teaches the ideas, then shows the few lines that make each idea real.

Note

Word to know — firmware. That is just the program living on the chip. Flashing firmware is like installing an app, except the chip can only run one app at a time.

The finished gadget

Desk Pal pomodoro tomato
Desk Pal pomodoro tomato on Desk Pal puck (ESP32-S3).

Build it

1

Print the tomato

Red PLA for the body, green for the stem. 0.2 mm, 3 walls. Keep the stem outside the board cavity so the puck still snaps in.

Print the tomato
2

Flash the puck

Flash means copy the program onto the chip. Easiest path: use the attached pomodoro-tomato.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32S3 Dev Module, OPI PSRAM, 16 MB flash, USB CDC On Boot — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. The TFT setup must include USE_HSPI_PORT or the round screen stays black.

Flash the puck
3

Run a tomato

Tap to start. Work. Double-tap or flip to skip to break. The Mac browser sim can use ?demo=1 for 25-second tomatoes while you practice — do not change the real 25-minute numbers unless you want a toy timer.

Run a tomato

Lesson 1 — `millis()` is a stopwatch

The chip does not “know” 2:30 PM unless you add a clock. What it always has is millis() — milliseconds since it powered on. A timer is subtraction:

remaining = phase_length - (now - started_at)

When remaining hits zero, the phase flips. The arc around the rim is the same fraction: degrees = 360 × remaining / phase_length. That is the same math as a pie chart in math class.

Two lengths, two looks

pomodoro-tomato.ino
static const uint32_t FOCUS_MS = 25ul * 60ul * 1000ul; // 25 minutes
static const uint32_t BREAK_MS = 5ul * 60ul * 1000ul;  // 5 minutes

enum Phase : uint8_t { PHASE_FOCUS = 0, PHASE_BREAK = 1 };
// The palette (red vs green) is chosen from this enum.

Lesson 2 — One finger, three verbs

The touch glass does not send the words “double-tap.” It sends “finger down” and “finger up.” The program classifies:

  • One tap, then quiet → start or pause.
  • Two taps close together → skip.
  • Finger stays down ~0.7 s → reset.

That is how phones tell tap from press too. If you invent a new gesture later, you are writing a tiny classifier, not magic.

Lesson 3 — Flip is just gravity upside-down

The accelerometer’s “up” vector usually points out of the desk. Flip the puck and that vector points the other way. When the dot product (a measure of “are these two arrows agreeing?”) goes negative enough, the firmware treats it as skip. Same sensor as the 8-ball; different meaning.

Lesson 4 — Memory that survives unplug

Variables in RAM vanish when USB dies. Preferences (sometimes called NVS) writes a small number into flash — the same kind of memory that holds the program. Here it stores “how many focus sessions I finished today.”

That is the school-safe version of a save file.

Try this

  • Finish one focus and unplug for ten seconds. Plug back in: the count should still be there.
  • Type skip on serial if you do not want to wait 25 minutes while testing.
  • Draw the arc math: at 12:30 left of a 25:00 tomato, how many degrees of pie?

Talking to the board (serial)

Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).

Type a word, press Enter, and the board answers. Try help first. Then try status.

Commands for this project: start · pause · skip · reset · status · help

Tip

If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….


The complete program (reference)

Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.

Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.

pomodoro-tomato.ino (complete)

pomodoro-tomato.ino
// Circular pomodoro for Waveshare ESP32-S3-Touch-LCD-1.28
// 1.28" round GC9A01, CST816S touch, QMI8658 IMU (Amazon B0CM68M8LR).
//
// 25:00 focus (ripe tomato red) → 5:00 break (leaf green) → next tomato.
// Big remaining time in the center, progress arc around the rim.
//
// Touch: tap = start/pause, double-tap = skip phase, long-press = reset.
// IMU: flip upside-down (gravity invert, robot-face INVERT_DOT) = skip.
// NVS: completed focus count in Preferences ("tomato" / "done").
//
// Serial 115200: start, pause, skip, reset, status, help
// Screen stays USB-down (no IMU auto-rotate). Flip still skips.
//
// Arduino IDE: ESP32S3 Dev Module, OPI PSRAM, 16MB flash, USB CDC On Boot.
// TFT_eSPI: apply firmware/TFT_eSPI_Setup.h (USE_HSPI_PORT).
// Sim: python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/pomodoro-tomato
//      optional ?demo=1 uses 25s / 5s instead of minutes.

#include <Arduino.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <Preferences.h>
#include <math.h>

// Pins match board-sketches/shared/s3_puck_pins.h — do not change GPIO numbers.
static const int LCD_SIZE = 240;
static const int LCD_BL_PIN = 2;
static const int I2C_SDA_PIN = 6;
static const int I2C_SCL_PIN = 7;
static const int TP_RST_PIN = 13;
static const int TP_INT_PIN = 5;

static const uint8_t CST816S_ADDR = 0x15;
static const uint8_t QMI_WHO_AM_I = 0x00;
static const uint8_t QMI_CTRL1 = 0x02;
static const uint8_t QMI_CTRL2 = 0x03;
static const uint8_t QMI_CTRL3 = 0x04;
static const uint8_t QMI_CTRL7 = 0x08;
static const uint8_t QMI_AX_L = 0x35;
static const uint8_t QMI_ID = 0x05;
static const float ACCEL_LSB_PER_G = 4096.0f;
static const float INVERT_DOT = -0.52f;
static const float LP_ALPHA = 0.18f;

static const uint32_t FOCUS_MS = 25ul * 60ul * 1000ul;
static const uint32_t BREAK_MS = 5ul * 60ul * 1000ul;
static const uint32_t TAP_WAIT_MS = 350;
static const uint32_t LONG_PRESS_MS = 700;
static const uint32_t FLIP_COOL_MS = 800;

enum Phase : uint8_t { PHASE_FOCUS = 0, PHASE_BREAK = 1 };
static const char *PHASE_NAME[] = {"focus", "break"};

struct Palette {
  uint8_t bg[3], lo[3], mid[3], hi[3], ink[3], arc[3], trk[3], stem[3], leaf[3], seed[3];
};

static const Palette PAL_FOCUS = {
  {36, 8, 12}, {132, 22, 34}, {196, 36, 48}, {232, 92, 78},
  {255, 236, 220}, {255, 210, 160}, {82, 18, 28},
  {56, 92, 32}, {72, 140, 48}, {88, 16, 24}
};
static const Palette PAL_BREAK = {
  {10, 24, 14}, {28, 78, 36}, {56, 142, 64}, {142, 198, 96},
  {232, 255, 224}, {198, 240, 150}, {20, 52, 28},
  {40, 80, 28}, {90, 176, 70}, {24, 56, 28}
};

struct Vec3 { float x, y, z; };

TFT_eSPI tft;
TFT_eSprite spr(&tft);
Preferences prefs;

static bool imuOk = false;
static bool touchOk = false;
static uint8_t imuAddr = 0;
static Vec3 accRaw = {0, 0, 1};
static Vec3 accLp = {0, 0, 1};
static Vec3 restUp = {0, 0, 1};
static bool restReady = false;
static bool inverted = false;
static bool wasInverted = false;
static uint32_t lastFlipSkipAt = 0;

static volatile bool touchIrq = false;
static bool fingerDown = false;
static uint32_t fingerDownAt = 0;
static int16_t touchX0 = 0, touchY0 = 0, touchX = 0, touchY = 0;
static bool longPressFired = false;
static uint32_t pendingTapAt = 0;

static Phase phase = PHASE_FOCUS;
static bool running = false;
static uint32_t remainingMs = FOCUS_MS;
static uint32_t lastTickAt = 0;
static uint32_t tomatoes = 0;
static bool dirty = true;
static uint32_t lastDrawAt = 0;

static int32_t since(uint32_t now, uint32_t t) { return (int32_t)(now - t); }

static uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) {
  return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}

static uint16_t rgbv(const uint8_t c[3]) { return rgb(c[0], c[1], c[2]); }

static uint16_t mixrgb(const uint8_t a[3], const uint8_t b[3], float t) {
  if (t < 0) t = 0;
  if (t > 1) t = 1;
  return rgb((uint8_t)(a[0] + (b[0] - a[0]) * t),
             (uint8_t)(a[1] + (b[1] - a[1]) * t),
             (uint8_t)(a[2] + (b[2] - a[2]) * t));
}

static float lerpf(float a, float b, float t) { return a + (b - a) * t; }

static float vecLen(const Vec3 &v) { return sqrtf(v.x * v.x + v.y * v.y + v.z * v.z); }

static Vec3 vecNorm(const Vec3 &v) {
  const float n = vecLen(v);
  if (n < 1e-5f) return (Vec3){0, 0, 1};
  return (Vec3){v.x / n, v.y / n, v.z / n};
}

static float vecDot(const Vec3 &a, const Vec3 &b) { return a.x * b.x + a.y * b.y + a.z * b.z; }

static uint32_t phaseLen(Phase p) { return p == PHASE_FOCUS ? FOCUS_MS : BREAK_MS; }

static void fmtTime(uint32_t ms, char *buf, size_t n) {
  uint32_t sec = ms / 1000ul;
  snprintf(buf, n, "%02lu:%02lu", (unsigned long)(sec / 60ul), (unsigned long)(sec % 60ul));
}

static void persistTomatoes() { prefs.putUInt("done", tomatoes); }

static void printRemainLine(const char *verb) {
  char t[8];
  fmtTime(remainingMs, t, sizeof(t));
  Serial.printf("%s phase=%s run=%d remain=%s tomatoes=%lu\n",
                verb, PHASE_NAME[phase], running ? 1 : 0, t, (unsigned long)tomatoes);
}

static void enterPhase(Phase next, bool keepRunning) {
  phase = next;
  remainingMs = phaseLen(phase);
  running = keepRunning;
  lastTickAt = millis();
  dirty = true;
}

static void startRun() {
  if (running) return;
  running = true;
  lastTickAt = millis();
  dirty = true;
  printRemainLine("start");
}

static void pauseRun() {
  if (!running) return;
  running = false;
  dirty = true;
  printRemainLine("pause");
}

static void toggleRun() {
  if (running) pauseRun();
  else startRun();
}

static void skipPhase() {
  const bool keep = running;
  if (phase == PHASE_FOCUS) enterPhase(PHASE_BREAK, keep);
  else enterPhase(PHASE_FOCUS, keep);
  printRemainLine("skip");
}

static void resetCycle() {
  running = false;
  enterPhase(PHASE_FOCUS, false);
  printRemainLine("reset");
}

static void completePhase() {
  if (phase == PHASE_FOCUS) {
    tomatoes++;
    persistTomatoes();
    enterPhase(PHASE_BREAK, true);
  } else {
    enterPhase(PHASE_FOCUS, true);
  }
  printRemainLine("done");
}

// ---------------------------------------------------------------- IMU
static bool imuWrite(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(imuAddr);
  Wire.write(reg);
  Wire.write(val);
  return Wire.endTransmission() == 0;
}

static bool imuReadBytes(uint8_t reg, uint8_t *buf, uint8_t n) {
  Wire.beginTransmission(imuAddr);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)imuAddr, (int)n) != n) return false;
  for (uint8_t i = 0; i < n; i++) buf[i] = Wire.read();
  return true;
}

static bool imuProbe(uint8_t addr) {
  imuAddr = addr;
  uint8_t id = 0;
  if (!imuReadBytes(QMI_WHO_AM_I, &id, 1)) return false;
  return id == QMI_ID;
}

static bool imuBegin() {
  if (!imuProbe(0x6B) && !imuProbe(0x6A)) {
    imuAddr = 0;
    return false;
  }
  imuWrite(QMI_CTRL1, 0x60);
  imuWrite(QMI_CTRL2, 0x23);
  imuWrite(QMI_CTRL3, 0x43);
  imuWrite(QMI_CTRL7, 0x03);
  delay(20);
  return true;
}

static bool imuRead() {
  uint8_t buf[6];
  if (!imuReadBytes(QMI_AX_L, buf, 6)) return false;
  auto s16 = [](uint8_t lo, uint8_t hi) -> int16_t {
    return (int16_t)((uint16_t)lo | ((uint16_t)hi << 8));
  };
  accRaw.x = s16(buf[0], buf[1]) / ACCEL_LSB_PER_G;
  accRaw.y = s16(buf[2], buf[3]) / ACCEL_LSB_PER_G;
  accRaw.z = s16(buf[4], buf[5]) / ACCEL_LSB_PER_G;
  return true;
}

static void printHelp() {
  Serial.println("start | pause | skip | reset | status | help");
  Serial.println("tap=start/pause  double-tap=skip  long-press=reset  flip=skip");
}

static void updateMotion(uint32_t now) {
  if (!imuOk || !imuRead()) return;
  accLp.x = lerpf(accLp.x, accRaw.x, LP_ALPHA);
  accLp.y = lerpf(accLp.y, accRaw.y, LP_ALPHA);
  accLp.z = lerpf(accLp.z, accRaw.z, LP_ALPHA);

  const Vec3 upNow = vecNorm(accLp);
  if (!restReady && now > 400) {
    restUp = upNow;
    restReady = true;
  }
  const float align = restReady ? vecDot(upNow, restUp) : 1.0f;
  inverted = restReady && align < INVERT_DOT;

  if (inverted && !wasInverted && since(now, lastFlipSkipAt) > (int32_t)FLIP_COOL_MS) {
    lastFlipSkipAt = now;
    skipPhase();
  }
  wasInverted = inverted;
}

// -------------------------------------------------------------- touch
static void touchReset() {
  pinMode(TP_RST_PIN, OUTPUT);
  pinMode(TP_INT_PIN, INPUT_PULLUP);
  digitalWrite(TP_RST_PIN, LOW);
  delay(12);
  digitalWrite(TP_RST_PIN, HIGH);
  delay(80);
}

static bool touchWrite(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(CST816S_ADDR);
  Wire.write(reg);
  Wire.write(val);
  return Wire.endTransmission() == 0;
}

static bool touchBegin() {
  Wire.beginTransmission(CST816S_ADDR);
  Wire.write(0xA7);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)CST816S_ADDR, 1) != 1) return false;
  Wire.read();
  touchWrite(0xFE, 0x01);
  touchWrite(0xFA, 0x60);
  return true;
}

static bool readTouch(uint8_t *fingers, int16_t *x, int16_t *y) {
  Wire.beginTransmission(CST816S_ADDR);
  Wire.write(0x02);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)CST816S_ADDR, 5) < 5) return false;
  *fingers = Wire.read() & 0x0F;
  const uint8_t xh = Wire.read();
  const uint8_t xl = Wire.read();
  const uint8_t yh = Wire.read();
  const uint8_t yl = Wire.read();
  *x = ((xh & 0x0F) << 8) | xl;
  *y = ((yh & 0x0F) << 8) | yl;
  return true;
}

static void IRAM_ATTR onTouchIrq() { touchIrq = true; }

static void onSingleTap() { toggleRun(); }

static void onDoubleTap() { skipPhase(); }

static void onLongPress() { resetCycle(); }

static void handleTouch(uint32_t now) {
  if (!touchOk) return;
  const bool irq = touchIrq || digitalRead(TP_INT_PIN) == LOW;
  touchIrq = false;
  if (!irq && !fingerDown) return;

  uint8_t fingers = 0;
  int16_t x = 0, y = 0;
  const bool ok = readTouch(&fingers, &x, &y);
  const bool down = ok && fingers > 0;

  if (down) {
    touchX = x;
    touchY = y;
    if (!fingerDown) {
      fingerDown = true;
      fingerDownAt = now;
      touchX0 = x;
      touchY0 = y;
      longPressFired = false;
    } else if (!longPressFired && since(now, fingerDownAt) > (int32_t)LONG_PRESS_MS &&
               abs(x - touchX0) < 24 && abs(y - touchY0) < 24) {
      longPressFired = true;
      pendingTapAt = 0;
      onLongPress();
    }
    return;
  }

  if (fingerDown) {
    fingerDown = false;
    if (longPressFired) return;
    const int16_t dx = touchX - touchX0;
    const int16_t dy = touchY - touchY0;
    if (abs(dx) > 40 || abs(dy) > 40) return;
    if (since(now, fingerDownAt) > 600) return;
    if (pendingTapAt && since(now, pendingTapAt) < (int32_t)TAP_WAIT_MS) {
      pendingTapAt = 0;
      onDoubleTap();
    } else {
      pendingTapAt = now;
    }
  }
}

static void flushPendingTap(uint32_t now) {
  if (pendingTapAt && since(now, pendingTapAt) >= (int32_t)TAP_WAIT_MS) {
    pendingTapAt = 0;
    onSingleTap();
  }
}

// -------------------------------------------------------------- serial
static void printStatus() {
  char t[8];
  fmtTime(remainingMs, t, sizeof(t));
  Serial.printf("phase=%s run=%d remain=%s tomatoes=%lu imu=%d touch=%d invert=%d\n",
                PHASE_NAME[phase], running ? 1 : 0, t, (unsigned long)tomatoes,
                imuOk ? 1 : 0, touchOk ? 1 : 0, inverted ? 1 : 0);
}

static void handleSerial() {
  static String line;
  while (Serial.available()) {
    const char c = (char)Serial.read();
    if (c == '\r') continue;
    if (c != '\n') {
      if (line.length() < 80) line += c;
      continue;
    }
    line.trim();
    if (line.length() == 0) {
      line = "";
      continue;
    }
    line.toLowerCase();
    if (line == "start") startRun();
    else if (line == "pause") pauseRun();
    else if (line == "skip") skipPhase();
    else if (line == "reset") resetCycle();
    else if (line == "status") printStatus();
    else if (line == "help") {
      printHelp();
    } else {
      Serial.println("? (try help)");
    }
    line = "";
  }
}

// ---------------------------------------------------------------- draw
static void drawArcSweep(int cx, int cy, float rInner, float rOuter, float a0, float a1, uint16_t col) {
  if (a1 <= a0 + 0.001f) return;
  const float step = 1.15f / rOuter;
  for (float a = a0; a <= a1; a += step) {
    const float c = cosf(a);
    const float s = sinf(a);
    for (float r = rInner; r <= rOuter; r += 1.0f) {
      const int x = cx + (int)(c * r + 0.5f);
      const int y = cy + (int)(s * r + 0.5f);
      if ((unsigned)x < (unsigned)LCD_SIZE && (unsigned)y < (unsigned)LCD_SIZE) {
        spr.drawPixel(x, y, col);
      }
    }
  }
}

static void drawRing(int cx, int cy, float rInner, float rOuter, uint16_t col) {
  drawArcSweep(cx, cy, rInner, rOuter, 0, 6.2832f, col);
}

// 7-seg bits: A B C D E F G
static const uint8_t DIGIT_SEGS[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

static void segH(int x, int y, int w, int t, uint16_t col) {
  spr.fillRoundRect(x, y, w, t, t / 2, col);
}

static void segV(int x, int y, int h, int t, uint16_t col) {
  spr.fillRoundRect(x, y, t, h, t / 2, col);
}

static void drawDigit(int x, int y, int w, int h, int t, int d, uint16_t col) {
  if (d < 0 || d > 9) return;
  const uint8_t m = DIGIT_SEGS[d];
  const int iw = w - 2 * t;
  const int ih = (h - 3 * t) / 2;
  if (m & 0x01) segH(x + t, y, iw, t, col);                         // A
  if (m & 0x02) segV(x + w - t, y + t, ih, t, col);                 // B
  if (m & 0x04) segV(x + w - t, y + 2 * t + ih, ih, t, col);        // C
  if (m & 0x08) segH(x + t, y + h - t, iw, t, col);                 // D
  if (m & 0x10) segV(x, y + 2 * t + ih, ih, t, col);                // E
  if (m & 0x20) segV(x, y + t, ih, t, col);                         // F
  if (m & 0x40) segH(x + t, y + t + ih, iw, t, col);                // G
}

static void drawTime(int cx, int cy, uint32_t ms, uint16_t col, bool colonOn) {
  const int w = 28, h = 48, t = 5, gap = 6, colon = 10;
  const int total = w * 4 + gap * 3 + colon;
  int x = cx - total / 2;
  const int y = cy - h / 2;
  const uint32_t sec = ms / 1000ul;
  const int mm = (int)(sec / 60ul);
  const int ss = (int)(sec % 60ul);
  drawDigit(x, y, w, h, t, (mm / 10) % 10, col); x += w + gap;
  drawDigit(x, y, w, h, t, mm % 10, col); x += w + gap;
  if (colonOn) {
    spr.fillRoundRect(x + 2, y + 12, 6, 6, 2, col);
    spr.fillRoundRect(x + 2, y + 30, 6, 6, 2, col);
  }
  x += colon;
  drawDigit(x, y, w, h, t, (ss / 10) % 10, col); x += w + gap;
  drawDigit(x, y, w, h, t, ss % 10, col);
}

static void drawTomato(const Palette &pal) {
  const int cx = 120, cy = 124;
  spr.fillCircle(cx, cy + 4, 100, rgbv(pal.lo));
  spr.fillCircle(cx - 2, cy - 2, 92, rgbv(pal.mid));
  spr.fillCircle(cx - 22, cy - 28, 22, rgbv(pal.hi));
  spr.fillCircle(cx + 36, cy + 22, 11, mixrgb(pal.mid, pal.lo, 0.55f));
  spr.fillEllipse(cx, 124, 78, 36, mixrgb(pal.bg, pal.lo, 0.45f));

  spr.fillRoundRect(114, 28, 12, 20, 4, rgbv(pal.stem));
  spr.fillCircle(100, 36, phase == PHASE_BREAK ? 16 : 13, rgbv(pal.leaf));
  spr.fillCircle(140, 34, phase == PHASE_BREAK ? 17 : 13, rgbv(pal.leaf));
  spr.fillCircle(104, 32, 5, mixrgb(pal.leaf, pal.hi, 0.35f));
  spr.fillCircle(144, 30, 5, mixrgb(pal.leaf, pal.hi, 0.35f));
}

static void drawPips(const Palette &pal) {
  const int n = tomatoes > 10 ? 10 : (int)tomatoes;
  if (n <= 0) return;
  const int gap = 11;
  const int x0 = 120 - (n - 1) * gap / 2;
  for (int i = 0; i < n; i++) {
    spr.fillCircle(x0 + i * gap, 186, 3, rgbv(pal.seed));
    spr.fillCircle(x0 + i * gap - 1, 185, 1, rgbv(pal.hi));
  }
}

static void drawPauseBars(uint16_t col) {
  spr.fillRoundRect(108, 64, 8, 18, 3, col);
  spr.fillRoundRect(124, 64, 8, 18, 3, col);
}

static void drawFrame(uint32_t now) {
  const Palette &pal = phase == PHASE_FOCUS ? PAL_FOCUS : PAL_BREAK;
  spr.fillSprite(rgbv(pal.bg));
  drawTomato(pal);

  const float frac = remainingMs / (float)phaseLen(phase);
  const float a0 = -1.5708f;
  const float pulse = running ? 1.0f : 0.62f + 0.38f * (0.5f + 0.5f * sinf(now / 280.0f));
  drawRing(120, 120, 107, 116, rgbv(pal.trk));
  drawArcSweep(120, 120, 107, 116, a0, a0 + frac * 6.2832f, mixrgb(pal.trk, pal.arc, pulse));

  const bool colonOn = !running || ((now / 500) & 1);
  drawTime(120, 122, remainingMs, rgbv(pal.ink), colonOn);
  if (!running) drawPauseBars(mixrgb(pal.ink, pal.mid, 0.25f));
  drawPips(pal);

  spr.pushSprite(0, 0);
}

static void tickTimer(uint32_t now) {
  if (!running) return;
  const uint32_t dt = now - lastTickAt;
  lastTickAt = now;
  if (dt == 0) return;
  if (remainingMs > dt) remainingMs -= dt;
  else {
    remainingMs = 0;
    completePhase();
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LCD_BL_PIN, OUTPUT);
  digitalWrite(LCD_BL_PIN, HIGH);

  tft.init();
  tft.setRotation(0);
  tft.fillScreen(TFT_BLACK);

  spr.setColorDepth(16);
  if (spr.createSprite(LCD_SIZE, LCD_SIZE) == nullptr) {
    Serial.println("sprite alloc failed");
    while (true) delay(1000);
  }

  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  Wire.setClock(400000);
  touchReset();
  touchOk = touchBegin();
  if (touchOk) attachInterrupt(digitalPinToInterrupt(TP_INT_PIN), onTouchIrq, FALLING);
  imuOk = imuBegin();

  prefs.begin("tomato", false);
  tomatoes = prefs.getUInt("done", 0);
  remainingMs = FOCUS_MS;
  lastTickAt = millis();

  Serial.println(imuOk ? "QMI8658 ready" : "QMI8658 not found — flip skip off");
  Serial.println(touchOk ? "CST816S ready" : "CST816S not found — touch off");
  printStatus();
  Serial.println("pomodoro tomato ready (type 'help')");
  dirty = true;
}

void loop() {
  const uint32_t now = millis();
  handleSerial();
  handleTouch(now);
  flushPendingTap(now);
  updateMotion(now);
  tickTimer(now);

  const uint32_t drawEvery = running ? 40 : 80;
  if (dirty || since(now, lastDrawAt) >= (int32_t)drawEvery) {
    drawFrame(now);
    lastDrawAt = now;
    dirty = false;
  }
  delay(12);
}

Board and housing

Licensed under CC BY-SA 4.0. Check with the author before commercial use.