Community project

Pico Pomodoro Timer

Raspberry Pi Pico
Photo of Pico Pomodoro Timer
Generated with AI

roniejoephv

Published August 13, 2026

This guide builds a Pomodoro timer on a Raspberry Pi Pico with visual and audio feedback. The 128×64 OLED display shows the current phase and remaining time, while a 12-LED NeoPixel ring provides a glanceable progress indicator that changes color for focus, short break, and long break sessions. A rotary encoder adjusts session lengths, four buttons control playback and reset, and an active buzzer signals phase transitions.

The guide includes a complete wiring diagram showing how to safely integrate 3.3 V logic (OLED, encoder, buttons) and 5 V NeoPixels using a level shifter, a full parts list with recommended suppliers, Arduino-compatible firmware with debounced input handling and timer logic, and step-by-step assembly instructions with safety checks before powering on.

Wiring diagram

Wiring diagram for Pico Pomodoro Timer

Gather all the parts

QtyComponent
1

SSD1306 OLED

128x64 I2C

0.96 inch 128x64 OLED display with I2C interface

1

KY-040 Rotary Encoder Module

Rotary encoder + push-button

5-pin incremental rotary encoder breakout with integrated momentary push switch. CLK and DT are the quadrature outputs; SW is the built-in push-button output and should not be modelled as a separate Push Button component.

1

Push Button

Tactile switch

Momentary push button switch

1

Push Button

Tactile switch

Momentary push button switch

1

Push Button

Tactile switch

Momentary push button switch

1

Buzzer

3.3 V active buzzer

Piezo buzzer for sound output

1

DFRobot DFR0888-12 WS2812 12-LED Ring

12 LEDs

Exact DFRobot DFR0888-12 addressable RGB ring with 12 serial WS2812 LEDs, 47 mm outer diameter, 26 mm opening, 3.3-5 V supply, and PH2.0-3P IN/OUT connectors for cascading.

1

74AHCT125 Quad Buffer / Level Shifter

74AHCT125

Quad non-inverting buffer/line driver with 3-state outputs and active-low output-enable pins. In 5 V AHCT/HCT designs, 3.3 V MCU outputs are high enough for the TTL-level inputs, making it a common one-way 3.3 V to 5 V level shifter for WS2812/NeoPixel data and other fast digital lines.

1

Resistor

330 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

1000 µF, >=6.3 V

Through-hole resistor (current-limiting in series with an LED)

Assemble it in 7 steps

1. Keep the Pico unpowered while wiring

Unplug the Raspberry Pi Pico USB cable. Use the Pico 3V3(OUT) rail only for the OLED and encoder module. The USB VBUS/5V rail is used only for the NeoPixel ring, AHCT buffer, and capacitor.

  • Make one common ground rail joining the Pico, OLED, encoder, buttons, buzzer, level shifter, ring, and capacitor.
  • Use a USB supply/cable capable of at least 1 A; the firmware intentionally limits ring brightness, but startup faults and full-brightness test code can draw much more current.
  • Never connect 5 V directly to any Pico GPIO, OLED I2C line, encoder output, or button input.
  • Verify breadboard power rails are not split in the middle before connecting USB.

2. Wire the OLED and rotary encoder at 3.3 V

Connect oled_1 VCC to Pico 3V3(OUT), GND to GND, SDA to GP4, and SCL to GP5. Connect encoder_1 VCC to 3V3(OUT), GND to GND, CLK to GP6, DT to GP7, and SW to GP8.

  • Most SSD1306 modules use I2C address 0x3C, which the firmware expects.
  • Power the KY-040 encoder from 3.3 V, not 5 V, so its CLK/DT/SW outputs are safe for the Pico.
  • If your OLED board is labelled 5 V only or has pull-ups permanently tied to 5 V, do not connect its I2C pins directly to the Pico. Use a 3.3 V-capable OLED module.

3. Wire the four active-low buttons

For button_start_1, button_reset_1, button_skip_1, and the encoder_1 push-button already wired in step 2, connect one switch terminal to the shared ground. Connect the other terminals to GP9 (start/pause button), GP10 (reset), and GP11 (skip). The encoder SW goes to GP8. The firmware enables internal pull-ups, so a press reads LOW.

  • Tactile switches have internally common pairs of legs; use pins from opposite sides of the switch, not two legs on the same side.
  • Keep button leads short. Firmware debounce is 30 ms; if a particularly noisy mechanical encoder still skips, add 10 nF capacitors from CLK and DT to ground near the encoder.
  • Do not add external pull-ups to 5 V. All input pull-ups must remain at 3.3 V.

4. Wire the active buzzer

Connect buzzer_1 GND to the shared ground and SIGNAL to GP12. Use a 3.3 V active buzzer module; it produces its own tone whenever GP12 is HIGH.

  • If the buzzer is very loud, cover its sound port partly with tape rather than adding a resistor in its ground line.
  • A bare high-current buzzer is not the same as a 3.3 V active buzzer module and may need a transistor driver; do not connect one directly to GP12.

5. Build the protected 5 V NeoPixel data path

Connect levelshifter_1 VCC to USB 5 V (VBUS) and GND to common ground. Tie 1OE to ground to enable channel 1. Connect Pico GP13 to 1A. Connect 1Y to one end of data_resistor_1; connect the other resistor end to ring_1 DATA_IN. Tie 2OE, 3OE, and 4OE to 5 V so unused AHCT outputs are disabled. Leave the unused buffer inputs and outputs unconnected.

  • For a bare 74AHCT125 DIP package, use its datasheet pin numbers; the signal labels in the wiring diagram describe its logical pins.
  • Place the 330 ohm resistor close to the ring DATA_IN pad and keep the data wire short.
  • The AHCT125 must run from 5 V. It converts the Pico’s 3.3 V data signal into a reliable 5 V WS2812 signal; do not omit it when the ring is powered at 5 V.

6. Connect and protect the NeoPixel 5 V supply

Connect ring_1 VCC to USB 5 V (VBUS) and ring_1 GND to common ground. Install bulk_capacitor_1 directly across the ring supply: P1/positive to 5 V and P2/negative/striped lead to ground. Leave DATA_OUT unconnected.

  • Put the capacitor physically close to the ring power pads.
  • The ring’s 5 V and the Pico’s ground must share a common ground for data to work.
  • Observe the capacitor polarity exactly: its striped negative lead goes to GND. Reversing an electrolytic capacitor can cause it to fail dangerously.
  • Do not power the ring from the Pico 3V3(OUT) pin.

7. Inspect before USB power

Check every 5 V lead is limited to the NeoPixel ring, AHCT125 VCC, capacitor positive lead, and the board’s VBUS distribution. Confirm no 5 V line touches GP4–GP13. Then connect the Pico by USB and use Schematik’s Deploy button to load the firmware.

  • On first power-up, the display should show FOCUS and 25:00. Either the encoder push-button or the separate start/pause button starts the timer.
  • Turn the encoder only while paused to adjust the currently selected period in one-minute steps; reset restores the period’s configured time and skip advances phase.
  • If anything becomes hot, disconnect USB immediately and re-check the 5 V, ground, capacitor polarity, and level-shifter wiring.

Review all connections

1. Connections between "oled_1" and "Raspberry Pi Pico"

Functionoled_1Raspberry Pi Pico
powerVCC3V3
groundGNDGND
i2cSDAGPIO 4
i2cSCLGPIO 5

2. Connections between "encoder_1" and "Raspberry Pi Pico"

Functionencoder_1Raspberry Pi Pico
powerVCC3V3
groundGNDGND
digitalCLKGPIO 6
digitalDTGPIO 7
digitalSWGPIO 8

3. Connections between "button_start_1" and "Raspberry Pi Pico"

Functionbutton_start_1Raspberry Pi Pico
groundGNDGND
digitalSIGNALGPIO 9

4. Connections between "button_reset_1" and "Raspberry Pi Pico"

Functionbutton_reset_1Raspberry Pi Pico
groundGNDGND
digitalSIGNALGPIO 10

5. Connections between "button_skip_1" and "Raspberry Pi Pico"

Functionbutton_skip_1Raspberry Pi Pico
groundGNDGND
digitalSIGNALGPIO 11

6. Connections between "buzzer_1" and "Raspberry Pi Pico"

Functionbuzzer_1Raspberry Pi Pico
groundGNDGND
digitalSIGNALGPIO 12

7. Connections between "levelshifter_1" and "Raspberry Pi Pico"

Functionlevelshifter_1Raspberry Pi Pico
powerVCC5V
groundGNDGND
digital1AGPIO 13
ground1OEGND
digital1Y → Resistor P1EXT
power2OE5V
power3OE5V
power4OE5V

8. Connections between "data_resistor_1" and "Raspberry Pi Pico"

Functiondata_resistor_1Raspberry Pi Pico
digitalP2 → DFRobot DFR0888-12 WS2812 12-LED Ring DATA_INEXT

9. Connections between "ring_1" and "Raspberry Pi Pico"

Functionring_1Raspberry Pi Pico
powerVCC5V
groundGNDGND

10. Connections between "bulk_capacitor_1" and "Raspberry Pi Pico"

Functionbulk_capacitor_1Raspberry Pi Pico
powerP15V
groundP2GND

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>


// Hoisted type definitions
enum TimerPhase { FOCUS, SHORT_BREAK, LONG_BREAK };

struct DebouncedButton {
  uint8_t pin;
  bool stableState;
  bool previousStableState;
  bool rawState;
  uint32_t changedAt;
};


// Forward declarations
void setBuzzer(bool on);
void queueBeeps(uint8_t count, uint32_t firstDelay);
void serviceBuzzer();
bool pressed(DebouncedButton &button);
void resetCurrentTimer();
void beginBreak();
void beginFocus();
void finishPhase();
void handleEncoder();
void handleInputs();
uint32_t scaleColor(uint8_t r, uint8_t g, uint8_t b, uint8_t level);
void updateRing();
void drawDisplay();
void serviceTimer();

constexpr uint8_t OLED_SDA_PIN = 4;
constexpr uint8_t OLED_SCL_PIN = 5;
constexpr uint8_t ENCODER_CLK_PIN = 6;
constexpr uint8_t ENCODER_DT_PIN = 7;
constexpr uint8_t ENCODER_SW_PIN = 8;
constexpr uint8_t START_BUTTON_PIN = 9;
constexpr uint8_t RESET_BUTTON_PIN = 10;
constexpr uint8_t SKIP_BUTTON_PIN = 11;
constexpr uint8_t BUZZER_PIN = 12;
constexpr uint8_t NEOPIXEL_PIN = 13;

constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
constexpr uint8_t PIXEL_COUNT = 12;
constexpr uint32_t FOCUS_SECONDS = 25UL * 60UL;
constexpr uint32_t SHORT_BREAK_SECONDS = 5UL * 60UL;
constexpr uint32_t LONG_BREAK_SECONDS = 15UL * 60UL;
constexpr uint32_t LAST_FIVE_MINUTES = 5UL * 60UL;
constexpr uint32_t LAST_TEN_SECONDS = 10UL;
constexpr uint32_t DEBOUNCE_MS = 30;
constexpr uint32_t RING_FRAME_MS = 50;
constexpr uint32_t BEEP_TICK_MS = 70;

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
Adafruit_NeoPixel ring(PIXEL_COUNT, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800);


TimerPhase phase = FOCUS;
bool running = false;
uint8_t completedFocusCycles = 0;
uint32_t configuredSeconds = FOCUS_SECONDS;
uint32_t remainingSeconds = FOCUS_SECONDS;
uint32_t lastSecondMs = 0;
uint32_t lastRingMs = 0;
uint32_t lastTickSecond = 0xFFFFFFFFUL;
bool displayDirty = true;



DebouncedButton encoderButton{ENCODER_SW_PIN, HIGH, HIGH, HIGH, 0};
DebouncedButton startButton{START_BUTTON_PIN, HIGH, HIGH, HIGH, 0};
DebouncedButton resetButton{RESET_BUTTON_PIN, HIGH, HIGH, HIGH, 0};
DebouncedButton skipButton{SKIP_BUTTON_PIN, HIGH, HIGH, HIGH, 0};

int lastEncoderClk = HIGH;
uint32_t buzzerOffAt = 0;
uint8_t queuedBeeps = 0;
uint8_t beepsStarted = 0;
bool buzzerOn = false;
uint32_t nextBeepAt = 0;

const char *phaseName() {
  if (phase == FOCUS) return "FOCUS";
  if (phase == SHORT_BREAK) return "SHORT BREAK";
  return "LONG BREAK";
}

void setBuzzer(bool on) {
  digitalWrite(BUZZER_PIN, on ? HIGH : LOW);
  buzzerOn = on;
}

void queueBeeps(uint8_t count, uint32_t firstDelay = 0) {
  queuedBeeps = count;
  beepsStarted = 0;
  nextBeepAt = millis() + firstDelay;
}

void serviceBuzzer() {
  uint32_t now = millis();
  if (buzzerOn && (int32_t)(now - buzzerOffAt) >= 0) {
    setBuzzer(false);
    if (beepsStarted < queuedBeeps) nextBeepAt = now + 120;
  }
  if (!buzzerOn && beepsStarted < queuedBeeps && (int32_t)(now - nextBeepAt) >= 0) {
    setBuzzer(true);
    ++beepsStarted;
    buzzerOffAt = now + ((queuedBeeps == 1) ? 700 : 160);
  }
}

bool pressed(DebouncedButton &button) {
  bool reading = digitalRead(button.pin);
  uint32_t now = millis();
  if (reading != button.rawState) {
    button.rawState = reading;
    button.changedAt = now;
  }
  if ((now - button.changedAt) >= DEBOUNCE_MS && button.stableState != button.rawState) {
    button.previousStableState = button.stableState;
    button.stableState = button.rawState;
    return button.previousStableState == HIGH && button.stableState == LOW;
  }
  return false;
}

void resetCurrentTimer() {
  running = false;
  remainingSeconds = configuredSeconds;
  lastTickSecond = 0xFFFFFFFFUL;
  displayDirty = true;
}

void beginBreak() {
  if (completedFocusCycles >= 4) {
    phase = LONG_BREAK;
    configuredSeconds = LONG_BREAK_SECONDS;
  } else {
    phase = SHORT_BREAK;
    configuredSeconds = SHORT_BREAK_SECONDS;
  }
  remainingSeconds = configuredSeconds;
  running = false;
  lastTickSecond = 0xFFFFFFFFUL;
  queueBeeps(1);
  displayDirty = true;
}

void beginFocus() {
  phase = FOCUS;
  configuredSeconds = FOCUS_SECONDS;
  remainingSeconds = configuredSeconds;
  running = false;
  lastTickSecond = 0xFFFFFFFFUL;
  queueBeeps(2);
  displayDirty = true;
}

void finishPhase() {
  if (phase == FOCUS) {
    if (completedFocusCycles < 4) ++completedFocusCycles;
    beginBreak();
  } else {
    if (phase == LONG_BREAK) completedFocusCycles = 0;
    beginFocus();
  }
}

void handleEncoder() {
  int clk = digitalRead(ENCODER_CLK_PIN);
  if (clk != lastEncoderClk && clk == LOW && !running) {
    int direction = (digitalRead(ENCODER_DT_PIN) != clk) ? 1 : -1;
    int32_t adjusted = (int32_t)remainingSeconds + direction * 60;
    if (adjusted < 60) adjusted = 60;
    if (adjusted > 99 * 60) adjusted = 99 * 60;
    remainingSeconds = (uint32_t)adjusted;
    configuredSeconds = remainingSeconds;
    displayDirty = true;
  }
  lastEncoderClk = clk;
}

void handleInputs() {
  handleEncoder();
  if (pressed(encoderButton) || pressed(startButton)) {
    running = !running;
    lastSecondMs = millis();
    displayDirty = true;
  }
  if (pressed(resetButton)) resetCurrentTimer();
  if (pressed(skipButton)) finishPhase();
}

uint32_t scaleColor(uint8_t r, uint8_t g, uint8_t b, uint8_t level) {
  return ring.Color((uint16_t)r * level / 255, (uint16_t)g * level / 255, (uint16_t)b * level / 255);
}

void updateRing() {
  uint32_t now = millis();
  if (now - lastRingMs < RING_FRAME_MS) return;
  lastRingMs = now;

  float wave = (sinf((float)now * 0.005f) + 1.0f) * 0.5f;
  uint8_t brightness = 0;
  uint8_t r = 0, g = 0, b = 0;

  if (phase == FOCUS) {
    if (remainingSeconds == 0) {
      r = 0; g = 180; b = 20; brightness = 180;
    } else {
      r = 25; g = 80; b = 220;
      uint8_t floorLevel = 20;
      uint8_t ceilingLevel = 90;
      if (remainingSeconds <= LAST_FIVE_MINUTES) {
        float urgency = 1.0f - (float)remainingSeconds / LAST_FIVE_MINUTES;
        floorLevel = 30 + (uint8_t)(urgency * 55.0f);
        ceilingLevel = 115 + (uint8_t)(urgency * 120.0f);
        r = 180; g = 35; b = 20;
      }
      brightness = floorLevel + (uint8_t)((ceilingLevel - floorLevel) * wave);
      if (!running) brightness = 18;
    }
  } else {
    r = 210; g = 130; b = 0;
    brightness = running ? (35 + (uint8_t)(125 * wave)) : 18;
  }

  for (uint8_t i = 0; i < PIXEL_COUNT; ++i) ring.setPixelColor(i, scaleColor(r, g, b, brightness));
  ring.show();
}

void drawDisplay() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(phaseName());
  display.setCursor(92, 0);
  display.print(running ? "RUN" : "PAUSE");

  uint32_t minutes = remainingSeconds / 60;
  uint32_t seconds = remainingSeconds % 60;
  char timeBuffer[8];
  snprintf(timeBuffer, sizeof(timeBuffer), "%02lu:%02lu", (unsigned long)minutes, (unsigned long)seconds);
  display.setTextSize(3);
  display.setCursor(18, 15);
  display.print(timeBuffer);

  display.setTextSize(1);
  display.setCursor(0, 45);
  display.print("Focus cycles: ");
  display.print(completedFocusCycles);
  display.print("/4");

  display.drawRect(0, 55, 128, 9, SSD1306_WHITE);
  uint32_t elapsed = configuredSeconds > remainingSeconds ? configuredSeconds - remainingSeconds : 0;
  uint8_t fill = configuredSeconds ? (uint32_t)126 * elapsed / configuredSeconds : 0;
  if (fill > 0) display.fillRect(1, 56, fill, 7, SSD1306_WHITE);
  display.display();
  displayDirty = false;
}

void serviceTimer() {
  if (!running) return;
  uint32_t now = millis();
  while (now - lastSecondMs >= 1000 && running) {
    lastSecondMs += 1000;
    if (remainingSeconds > 0) --remainingSeconds;
    displayDirty = true;
    if (remainingSeconds <= LAST_TEN_SECONDS && remainingSeconds > 0 && remainingSeconds != lastTickSecond) {
      lastTickSecond = remainingSeconds;
      queueBeeps(1);
    }
    if (remainingSeconds == 0) finishPhase();
  }
}

void setup() {
  pinMode(ENCODER_CLK_PIN, INPUT_PULLUP);
  pinMode(ENCODER_DT_PIN, INPUT_PULLUP);
  pinMode(ENCODER_SW_PIN, INPUT_PULLUP);
  pinMode(START_BUTTON_PIN, INPUT_PULLUP);
  pinMode(RESET_BUTTON_PIN, INPUT_PULLUP);
  pinMode(SKIP_BUTTON_PIN, INPUT_PULLUP);
  pinMode(BUZZER_PIN, OUTPUT);
  setBuzzer(false);
  lastEncoderClk = digitalRead(ENCODER_CLK_PIN);
Wire.begin();
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.clearDisplay();
  display.display();

  ring.begin();
  ring.setBrightness(255);
  ring.clear();
  ring.show();
  lastSecondMs = millis();
}

void loop() {
  handleInputs();
  serviceTimer();
  serviceBuzzer();
  updateRing();
  if (displayDirty) drawDisplay();
}

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.

Open in Schematik