
What you'll build
This guide walks through a desk-sized ESP32 focus timer for Pomodoro-style work sessions. The OLED shows the current mode and countdown, the rotary encoder gives you one physical control for start, pause, and reset, the buzzer gives short alerts, and the LED ring shows progress at a glance.
The build uses common parts: an ESP32 development board, SSD1306 I2C OLED, KY-040 rotary encoder, piezo buzzer, and a 16-pixel WS2812B ring. The starter code keeps the behaviour intentionally simple: 25 minutes of focus, 5 minutes of break, pause and resume from the encoder button, and a reset shortcut while paused.
It is deliberately offline: no app account, no Wi-Fi credentials, and no cloud service. Once it is flashed, the timer lives on your desk and works from the encoder alone.
Wiring diagram
Wiring diagram
Components needed
Assembly
Connect the OLED display
Wire the SSD1306 OLED VCC to 3.3V, GND to ground, SDA to GPIO21, and SCL to GPIO22.
- Most small SSD1306 boards use I2C address 0x3C, which is what the starter code expects.
- Check the display module label before powering it. Use 3.3V if the board does not explicitly support 5V.
Wire the rotary encoder
Connect encoder VCC to 3.3V, GND to ground, CLK to GPIO32, DT to GPIO33, and SW to GPIO25.
- Keep the encoder wires short to avoid jumpy readings on the CLK and DT pins.
- The button pin uses the ESP32 internal pull-up, so the switch must close to ground.
Add the buzzer and LED ring
Connect the piezo buzzer signal to GPIO26 and its other lead to GND. Connect the WS2812B ring DIN to GPIO4, GND to common ground, and 5V to a suitable 5V rail.
- A 330 ohm resistor in series with the LED data line helps protect the first pixel.
- Do not power a full LED ring from a weak 3.3V rail. Use 5V for the ring and share ground with the ESP32.
Upload and try a session
Install the Adafruit SSD1306, Adafruit GFX, and FastLED libraries, then deploy the sketch. Press the encoder button to start, press again to pause, and turn the encoder while paused to reset to a focus or break session.
- If the OLED stays blank, check the I2C address and the SDA/SCL wiring first.
- The sketch is a local timer only. It does not sync with calendar apps or cloud services.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | focus-oled-1 VCC | POWER |
| GND | focus-oled-1 GND | GROUND |
| GPIO 21 | focus-oled-1 SDA | I2C |
| GPIO 22 | focus-oled-1 SCL | I2C |
| 3V3 | focus-encoder-1 VCC | POWER |
| GND | focus-encoder-1 GND | GROUND |
| GPIO 32 | focus-encoder-1 CLK | DIGITAL |
| GPIO 33 | focus-encoder-1 DT | DIGITAL |
| GPIO 25 | focus-encoder-1 SW | DIGITAL |
| GND | focus-buzzer-1 GND | GROUND |
| GPIO 26 | focus-buzzer-1 SIG | PWM |
| 5V | focus-led-ring-1 5V | POWER |
| GND | focus-led-ring-1 GND | GROUND |
| GPIO 4 | focus-led-ring-1 DIN | DATA |
Code
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <FastLED.h>
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RESET -1
#define ENCODER_CLK 32
#define ENCODER_DT 33
#define ENCODER_SW 25
#define BUZZER_PIN 26
#define LED_PIN 4
#define NUM_LEDS 16
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RESET);
CRGB leds[NUM_LEDS];
const unsigned long WORK_MINUTES = 25;
const unsigned long BREAK_MINUTES = 5;
enum TimerMode { SET_WORK, RUN_WORK, RUN_BREAK, PAUSED };
TimerMode mode = SET_WORK;
TimerMode pausedFrom = RUN_WORK;
unsigned long remainingSeconds = WORK_MINUTES * 60;
unsigned long lastTick = 0;
int lastClk = HIGH;
void beep(int frequency, int durationMs) {
tone(BUZZER_PIN, frequency, durationMs);
delay(durationMs + 20);
}
void showProgress() {
unsigned long total = mode == RUN_BREAK ? BREAK_MINUTES * 60 : WORK_MINUTES * 60;
int lit = map(remainingSeconds, 0, total, 0, NUM_LEDS);
for (int i = 0; i < NUM_LEDS; i++) {
if (i < lit) {
leds[i] = mode == RUN_BREAK ? CRGB::Green : CRGB::DeepSkyBlue;
} else {
leds[i] = CRGB::Black;
}
}
FastLED.show();
}
void drawScreen() {
unsigned long minutes = remainingSeconds / 60;
unsigned long seconds = remainingSeconds % 60;
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.println(mode == RUN_BREAK ? "Break" : mode == PAUSED ? "Paused" : "Focus");
display.setTextSize(3);
display.setCursor(16, 22);
if (minutes < 10) display.print('0');
display.print(minutes);
display.print(':');
if (seconds < 10) display.print('0');
display.print(seconds);
display.setTextSize(1);
display.setCursor(0, 56);
display.println("Press: start/pause Turn: reset");
display.display();
}
void startWork() {
mode = RUN_WORK;
remainingSeconds = WORK_MINUTES * 60;
lastTick = millis();
beep(1200, 90);
}
void startBreak() {
mode = RUN_BREAK;
remainingSeconds = BREAK_MINUTES * 60;
lastTick = millis();
beep(900, 100);
beep(1400, 120);
}
void handleButton() {
static int lastButton = HIGH;
int now = digitalRead(ENCODER_SW);
if (lastButton == HIGH && now == LOW) {
if (mode == SET_WORK) {
startWork();
} else if (mode == RUN_WORK || mode == RUN_BREAK) {
pausedFrom = mode;
mode = PAUSED;
beep(600, 80);
} else if (mode == PAUSED) {
mode = pausedFrom;
lastTick = millis();
beep(1000, 80);
}
}
lastButton = now;
}
void handleEncoder() {
int clk = digitalRead(ENCODER_CLK);
if (clk != lastClk && clk == LOW) {
bool clockwise = digitalRead(ENCODER_DT) != clk;
if (mode == SET_WORK || mode == PAUSED) {
remainingSeconds = clockwise ? WORK_MINUTES * 60 : BREAK_MINUTES * 60;
mode = SET_WORK;
beep(clockwise ? 1500 : 800, 40);
}
}
lastClk = clk;
}
void setup() {
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
pinMode(ENCODER_SW, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
Wire.begin(OLED_SDA, OLED_SCL);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
FastLED.addLeds<NEOPIXEL, LED_PIN>(leds, NUM_LEDS);
FastLED.setBrightness(80);
drawScreen();
}
void loop() {
handleButton();
handleEncoder();
if ((mode == RUN_WORK || mode == RUN_BREAK) && millis() - lastTick >= 1000) {
lastTick += 1000;
if (remainingSeconds > 0) remainingSeconds--;
if (remainingSeconds == 0) {
if (mode == RUN_WORK) startBreak();
else startWork();
}
}
showProgress();
drawScreen();
delay(80);
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the ESP32 Focus Timer.
Open in Schematik →