Community project
Pico Pomodoro Timer
Generated with AIThis 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
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| SSD1306 OLED128x64 I2C | 1 | 0.96 inch 128x64 OLED display with I2C interface |
| KY-040 Rotary Encoder ModuleRotary encoder + push-button | 1 | 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. |
| Push ButtonTactile switch | 1 | Momentary push button switch |
| Push ButtonTactile switch | 1 | Momentary push button switch |
| Push ButtonTactile switch | 1 | Momentary push button switch |
| Buzzer3.3 V active buzzer | 1 | Piezo buzzer for sound output |
| DFRobot DFR0888-12 WS2812 12-LED Ring12 LEDs | 1 | 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. |
| 74AHCT125 Quad Buffer / Level Shifter74AHCT125 | 1 | 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. |
| Resistor330 Ω | 1 | Through-hole resistor (current-limiting in series with an LED) |
| Resistor1000 µF, >=6.3 V | 1 | Through-hole resistor (current-limiting in series with an LED) |
Assembly
7 stepsKeep 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.
- Tip: Make one common ground rail joining the Pico, OLED, encoder, buttons, buzzer, level shifter, ring, and capacitor.
- Tip: 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.
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.
- Tip: Most SSD1306 modules use I2C address 0x3C, which the firmware expects.
- Tip: 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.
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.
- Tip: Tactile switches have internally common pairs of legs; use pins from opposite sides of the switch, not two legs on the same side.
- Tip: 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.
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.
- Tip: 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.
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.
- Tip: For a bare 74AHCT125 DIP package, use its datasheet pin numbers; the signal labels in the wiring diagram describe its logical pins.
- Tip: 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.
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.
- Tip: Put the capacitor physically close to the ring power pads.
- Tip: 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.
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.
- Tip: 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.
- Tip: 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.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| 3V3 | oled_1 VCC | power |
| GND | oled_1 GND | ground |
| GPIO 4 | oled_1 SDA | i2c |
| GPIO 5 | oled_1 SCL | i2c |
| 3V3 | encoder_1 VCC | power |
| GND | encoder_1 GND | ground |
| GPIO 6 | encoder_1 CLK | digital |
| GPIO 7 | encoder_1 DT | digital |
| GPIO 8 | encoder_1 SW | digital |
| GND | button_start_1 GND | ground |
| GPIO 9 | button_start_1 SIGNAL | digital |
| GND | button_reset_1 GND | ground |
| GPIO 10 | button_reset_1 SIGNAL | digital |
| GND | button_skip_1 GND | ground |
| GPIO 11 | button_skip_1 SIGNAL | digital |
| GND | buzzer_1 GND | ground |
| GPIO 12 | buzzer_1 SIGNAL | digital |
| 5V | levelshifter_1 VCC | power |
| GND | levelshifter_1 GND | ground |
| GPIO 13 | levelshifter_1 1A | digital |
| GND | levelshifter_1 1OE | ground |
| EXT | levelshifter_1 1Y → Resistor P1 | digital |
| 5V | levelshifter_1 2OE | power |
| 5V | levelshifter_1 3OE | power |
| 5V | levelshifter_1 4OE | power |
| EXT | data_resistor_1 P2 → DFRobot DFR0888-12 WS2812 12-LED Ring DATA_IN | digital |
| 5V | ring_1 VCC | power |
| GND | ring_1 GND | ground |
| 5V | bulk_capacitor_1 P1 | power |
| GND | bulk_capacitor_1 P2 | ground |
Firmware
Raspberry Pi Pico#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();
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
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.