Idea to Build: An ESP32 Pomodoro Desk Timer With a WS2812 LED Ring and a Rotary Encoder - Compoden

Idea to Build: An ESP32 Pomodoro Desk Timer With a WS2812 LED Ring and a Rotary Encoder

Here's the idea: a little desk timer that does Pomodoro sessions (25 minutes of focus, 5 minutes of break) without you picking up your phone. Twist a knob to set the minutes, click to start, and a ring of 24 LEDs slowly drains as the session runs. Red means focus, green means break, and a buzzer chirps when it's time to switch.

It's one evening on a breadboard, and the code is below in full.

What you need

Wiring

Part Pin ESP32
LED ring 5V VIN (USB 5V)
LED ring GND GND
LED ring DIN GPIO16 through a 330 ohm resistor
Encoder + 3V3
Encoder GND GND
Encoder CLK GPIO32
Encoder DT GPIO33
Encoder SW GPIO25
Buzzer VCC 3V3
Buzzer GND GND
Buzzer S (signal) GPIO26

A few notes so it works the first time:

  • Power the ring from 5V, not 3V3. WS2812 LEDs want 5V. VIN on this board is the USB 5V rail when you're plugged in over USB.
  • Put the 1000µF capacitor across the ring's 5V and GND, right at the ring, long leg (+) to 5V. It soaks up the inrush when the LEDs switch on.
  • The 330 ohm resistor goes in series with DIN. It tames ringing on the data line and protects the first LED.
  • 3.3V data into a 5V ring usually works with short wires like this. If you see random flicker or wrong colours, add a 74AHCT125 level shifter on the data line.
  • Encoder on 3V3, not 5V. The ESP32's pins are not 5V tolerant, and the encoder's pull-ups go to whatever you feed its + pin.
  • Pin choice matters. GPIO16, 25, 26, 32 and 33 are plain GPIOs. Skip GPIO34-39 for the encoder (input-only, no internal pull-ups) and stay off strapping pins like GPIO0, 2, 12 and 15.

How it works

  • Turn the knob while idle: set focus time from 5 to 60 minutes in 5-minute steps. The ring shows it (60 minutes = full ring).
  • Click: start. Click again: pause (the ring blinks). Click once more: resume.
  • Hold the button for one second: reset.
  • Turn the knob while running: change brightness.
  • When focus ends you get three beeps and an automatic 5-minute green break. When the break ends it beeps and waits for you.

The encoder is read with interrupts and a small quadrature lookup table, so you don't need an encoder library. The only library is Adafruit NeoPixel (install it from the Library Manager). Use the ESP32 board package by Espressif, version 2.x or 3.x, which gives you tone().

The code

#include <Adafruit_NeoPixel.h>

#define RING_PIN 16
#define NUM_LEDS 24
#define ENC_A    32   // CLK
#define ENC_B    33   // DT
#define ENC_SW   25
#define BUZZER   26

Adafruit_NeoPixel ring(NUM_LEDS, RING_PIN, NEO_GRB + NEO_KHZ800);

enum Mode { IDLE, WORK, BREAK_TIME, PAUSED };
Mode mode = IDLE;
Mode pausedFrom = WORK;

int workMin = 25;
const int breakMin = 5;
int brightness = 40;            // keep it low: USB power, and it's a desk light

unsigned long phaseMs = 0;      // length of the current phase
unsigned long doneMs = 0;       // time already elapsed before the last resume
unsigned long startedAt = 0;

// ---------- encoder (interrupt + quadrature table) ----------
portMUX_TYPE encMux = portMUX_INITIALIZER_UNLOCKED;
volatile int encSteps = 0;
volatile uint8_t encState = 0;
const int8_t QDEC[16] = {0,-1,1,0, 1,0,0,-1, -1,0,0,1, 0,1,-1,0};

void IRAM_ATTR onEncoder() {
  portENTER_CRITICAL_ISR(&encMux);
  encState = ((encState << 2) | (digitalRead(ENC_A) << 1) | digitalRead(ENC_B)) & 0x0F;
  encSteps += QDEC[encState];
  portEXIT_CRITICAL_ISR(&encMux);
}

int readDetents() {
  static int acc = 0;
  portENTER_CRITICAL(&encMux);
  int s = encSteps;
  encSteps = 0;
  portEXIT_CRITICAL(&encMux);
  acc += s;
  int d = acc / 4;              // 4 transitions per click on this encoder
  acc -= d * 4;
  return d;
}

// ---------- timer logic ----------
void beep(int times) {
  for (int i = 0; i < times; i++) {
    tone(BUZZER, 2200);
    delay(120);
    noTone(BUZZER);
    delay(120);
  }
}

void startPhase(Mode m) {
  mode = m;
  phaseMs = (unsigned long)(m == WORK ? workMin : breakMin) * 60000UL;
  doneMs = 0;
  startedAt = millis();
}

unsigned long elapsedMs() {
  if (mode == PAUSED) return doneMs;
  return doneMs + (millis() - startedAt);
}

void shortClick() {
  if (mode == IDLE) { startPhase(WORK); beep(1); }
  else if (mode == WORK || mode == BREAK_TIME) {
    doneMs += millis() - startedAt;
    pausedFrom = mode;
    mode = PAUSED;
  } else if (mode == PAUSED) {
    startedAt = millis();
    mode = pausedFrom;
  }
}

void resetTimer() { mode = IDLE; beep(2); }

void handleButton() {
  static bool lastRaw = HIGH, down = false, longDone = false;
  static unsigned long changedAt = 0, downAt = 0;
  bool raw = digitalRead(ENC_SW);
  if (raw != lastRaw) { lastRaw = raw; changedAt = millis(); }
  if (millis() - changedAt < 30) return;         // debounce
  bool pressed = (raw == LOW);
  if (pressed && !down) { down = true; downAt = millis(); longDone = false; }
  if (pressed && down && !longDone && millis() - downAt > 1000) { longDone = true; resetTimer(); }
  if (!pressed && down) { down = false; if (!longDone) shortClick(); }
}

// ---------- drawing ----------
void drawFill(float lit, uint8_t r, uint8_t g, uint8_t b) {
  for (int i = 0; i < NUM_LEDS; i++) {
    float level = constrain(lit - i, 0.0f, 1.0f);   // last LED fades smoothly
    ring.setPixelColor(i, ring.Color(r * level, g * level, b * level));
  }
}

void draw() {
  ring.setBrightness(brightness);
  if (mode == IDLE) {
    drawFill(workMin * NUM_LEDS / 60.0f, 60, 120, 255);
  } else {
    Mode m = (mode == PAUSED) ? pausedFrom : mode;
    float left = 1.0f - (float)elapsedMs() / phaseMs;
    if (left < 0) left = 0;
    bool blinkOff = (mode == PAUSED) && ((millis() / 500) % 2);
    if (blinkOff) ring.clear();
    else if (m == WORK) drawFill(left * NUM_LEDS, 255, 40, 0);
    else drawFill(left * NUM_LEDS, 0, 255, 60);
  }
  ring.show();
}

void setup() {
  pinMode(ENC_A, INPUT_PULLUP);
  pinMode(ENC_B, INPUT_PULLUP);
  pinMode(ENC_SW, INPUT_PULLUP);
  encState = (digitalRead(ENC_A) << 1) | digitalRead(ENC_B);
  attachInterrupt(digitalPinToInterrupt(ENC_A), onEncoder, CHANGE);
  attachInterrupt(digitalPinToInterrupt(ENC_B), onEncoder, CHANGE);
  ring.begin();
  ring.clear();
  ring.show();
}

void loop() {
  handleButton();

  int d = readDetents();
  if (d != 0) {
    if (mode == IDLE) workMin = constrain(workMin + d * 5, 5, 60);
    else brightness = constrain(brightness + d * 8, 8, 160);
  }

  if ((mode == WORK || mode == BREAK_TIME) && elapsedMs() >= phaseMs) {
    if (mode == WORK) { beep(3); startPhase(BREAK_TIME); }
    else { beep(2); mode = IDLE; }
  }

  static unsigned long lastDraw = 0;
  if (millis() - lastDraw > 40) { lastDraw = millis(); draw(); }
}

If something's off

  • Knob goes the wrong way? Swap the CLK and DT wires.
  • One click moves two steps, or every other click does nothing? Change the 4 in readDetents() to 2.
  • Ring stays dark? Check that DIN (not DOUT) is wired, and that the ring's GND is shared with the ESP32.

How Soldr designs this from the idea

You don't have to plan any of this yourself. Type the idea at soldr.ai, something like "ESP32 Pomodoro timer with an LED ring, a knob to set minutes, and a beep at the end", and Soldr picks the parts from the Compoden catalog, then gives you the wiring and the code. Every part above is one you can order from Compoden, so the plan and the parts list match.

Build it, put it next to your keyboard, and let the ring keep you honest.

Back to blog

Open this project in the Soldr app → What is Soldr.ai → parts, wiring and code for what this guide builds