Idea to Build: An ESP32 Pomodoro Desk Timer With a WS2812 LED Ring and a Rotary Encoder
Share
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
- ESP32 30-Pin Development Board (CP2102)
- RGB LED 24 Bit WS2812 Circular Development Board
- Rotary Encoder, 20 Pulse Incremental with Push Button
- Passive Buzzer Module (3-5V)
- 1000µF 25V Electrolytic Capacitor
- Resistor Variety Pack (you need one 330 ohm)
- MB102 830-Point Breadboard with Jumper Wires
- Jumper Wire Combo (M-M, M-F, F-F)
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
4inreadDetents()to2. - 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.