What you will learn
- Map a raw ADC reading to a duration in ten-second steps.
- Distinguish READY, RUNNING, PAUSED, and DONE and define event priority.
- Preserve the millisecond remainder during pause and resume its countdown.
- Round displayed seconds upward without changing time accounting.
- Connect the additional PN2222A/1N4148 buzzer driver and bound the sound window.
- Explain how synchronous OLED transfers affect button and output response.
Before you start
Complete courses 11, 13, and 18: the SSD1306 OLED, an active buzzer with a transistor, and button events in a timing task.
Reference setup
- Board in Arduino IDE
- ESP32 Dev Module
- Arduino-ESP32
- 3.3.12
- Serial Monitor
- 115200 baud
This is a reference profile, not an identification of the pictured board. Adapt the GPIO mapping and verify the circuit before using ESP32-C3/S2/S3 or a differently labelled board.
What we will build
We will build a workshop timer: a potentiometer selects the duration, one button starts, pauses, and resumes the countdown, and another resets it. An OLED shows the state and remaining time. At completion, an active buzzer produces repeated short signals within a ten-second window, while the completion screen remains until acknowledged.
The selectable duration is 10–300 seconds in ten-second steps. Possible uses include timing a task, a short experiment, or turns within a group. This is an educational timing function without a claim of calibrated precision. The aim is to combine user events, elapsed-time accounting, a display, and sound in one understandable program.
What you will learn
- Map a potentiometer reading to discrete durations.
- Separate the selected duration from an active task's remaining time.
- Preserve remaining milliseconds on pause and resume correctly.
- Give RESET priority over other commands.
- Round displayed seconds upward without changing the time calculation.
- Explain how screen transfers affect button response and sound timing.
Prerequisites
Complete courses 11, 13, and 18: OLED wiring, an active buzzer with a transistor, and event handling in a timing task. You should understand INPUT_PULLUP, the difference between raw and qualified button states, and intervals measured with millis(). Use a classic ESP32/ESP32-WROOM-32 and the stated GPIO assignments. This layout is not verified for other ESP32 families.
Equipment and variant checks
| Component | Quantity | Specification and notes |
|---|---|---|
| Development board with a classic ESP32-WROOM-32 module | 1 | Match the reference GPIO labels; check physical header positions on the actual board. |
| USB data and power cable | 1 | Use the connector fitted to your board; the pictured kit lists Micro-USB. |
| Solderless breadboard | 1 | The kit lists 830 tie points. Check whether the power rails are split. |
| 0.96-inch OLED — reference I2C SSD1306 128×64 | 1 | VCC/GND/SDA/SCL, 3.3 V supply, onboard pull-ups to 3V3. The image does not confirm the controller, interface or resolution. |
| 10 kΩ potentiometer | 1 | Two end terminals and a wiper W; identify their physical positions on the actual component. |
| Momentary pushbutton | 2 | Use two terminals connected only when pressed; two legs on the same side may already be joined. |
| Active buzzer — 5 V, up to 20 mA reference | 1 | The photo lists an active buzzer but does not establish its voltage or current. For this circuit use a documented 5 V model with operating current no higher than 20 mA. |
| Additional PN2222A NPN transistor | 1Required extra — not in kit photo | ADDITIONAL, not listed in the kit. Identify B/C/E for the exact manufacturer; PN2222A and P2N2222A may have different lead orders. ADDITIONAL: the transistor is not listed in the kit photograph. |
| Additional 1N4148 protective diode | 1Required extra — not in kit photo | ADDITIONAL, not listed in the kit. The band marks cathode K. In the buzzer circuit the cathode faces +5 V. ADDITIONAL: the diode is not listed in the kit photograph. |
| 1 kΩ resistor | 1 | Base resistor; course 14 also uses one across the piezo. The kit quantity at this resistance is unconfirmed. |
| 10 kΩ resistor | 1 | R2 is the base pull-down from Q1.B to GND. |
| Jumper wires | 21 | Use male-to-male or female-to-male leads to suit the board headers. Approximate quantity; depends on layout and connector types. |
The PN2222A and 1N4148 are additional required components not listed in the kit photograph. Verify the transistor's exact B/C/E pin order: similar names such as PN2222A and P2N2222A do not guarantee matching leads. onsemi — PN2222A
The reference is an active buzzer rated for 5 V and an operating current no greater than 20 mA. A passive piezo does not produce the same sustained sound with this program. The OLED must be an I2C SSD1306, 128 × 64, suitable for 3.3 V; the photograph does not identify its controller. Check R1, 1 kΩ; R2, 10 kΩ; and the 10 kΩ potentiometer. Each button's chosen logical contacts must be open before pressing. Two permanently connected leads of a four-leg switch are not the contact pair that closes when pressed.
States and command rules
In READY, select a duration. A qualified START enters RUNNING and captures that selection. In RUNNING, the same button enters PAUSED; from PAUSED, it resumes the remaining time. Turning the potentiometer during running or pause does not change the task already started.
Expiration enters DONE: the display shows zero, and the sound window lasts ten seconds from the logical deadline. DONE remains afterward, but the buzzer is quiet. A new START acknowledges completion and returns to READY; another fresh press is required to begin a second countdown. RESET returns any operating state to READY and silences the buzzer. If START and RESET qualify in the same iteration, RESET wins. Expiration wins over a pause request at the deadline: that START event is ignored and DONE remains.
Wiring diagram and assembly order
Wiring diagram
OLED and potentiometer use 3V3. The buzzer uses the verified USB5V rail through Q1 with protective D1. Each button connects its GPIO to GND. Q1 and D1 are additional parts. Verify the SSD1306 controller, address and terminal labels.
| From | To | Connection |
|---|---|---|
ESP32.3V3 | OLED1.VCC | Reference 128×64 I2C SSD1306, powered at 3.3 V. |
ESP32.GND | OLED1.GND | Common ground. |
ESP32.GPIO21 | OLED1.SDA | I2C data. |
ESP32.GPIO22 | OLED1.SCL | I2C clock, 100 kHz reference. |
ESP32.3V3 | RV1.END_A | One end of the 10 kΩ potentiometer. |
ESP32.GND | RV1.END_B | The other end terminal. |
RV1.W | ESP32.GPIO34 | Wiper W to GPIO34, an ADC1 input. |
ESP32.GPIO26 | R1.1 | R1, 1 kΩ, limits base current. |
R1.2 | Q1.B | Base of the additional PN2222A; verify physical B/C/E lead order. |
Q1.B | R2.1 | R2, 10 kΩ, pulls the base down. |
R2.2 | ESP32.GND | Other end of the base pull-down resistor. |
Q1.E | ESP32.GND | Emitter to common ground. |
ESP32.USB5V | BZ1.PLUS | Documented 5 V active buzzer, up to 20 mA operating current. |
BZ1.MINUS | Q1.C | Buzzer negative terminal to collector. |
D1.A | BZ1.MINUS | Protective 1N4148 anode at the buzzer negative terminal. |
D1.K | ESP32.USB5V | Diode cathode, marked with a band, to +5 V. |
ESP32.GPIO27 | SW1.CONTACT_A | START/PAUSE button; INPUT_PULLUP input. |
SW1.CONTACT_B | ESP32.GND | The contact pair that connects only while pressed. |
ESP32.GPIO32 | SW2.CONTACT_A | RESET button; INPUT_PULLUP input. |
SW2.CONTACT_B | ESP32.GND | The contact pair that connects only while pressed. |
- Disconnect USB and establish common GND for every branch.
- Connect OLED VCC → 3V3, GND → GND, SDA → GPIO21, and SCL → GPIO22. Its I2C pull-up resistors must lead to 3.3 V.
- Connect the potentiometer's end terminals to 3V3 and GND, and its wiper to GPIO34.
- Connect START/PAUSE/RESUME button SW1 between GPIO27 and GND. RESET button SW2 connects between GPIO32 and GND.
- Connect GPIO26 through R1, 1 kΩ, to Q1.B. Place R2, 10 kΩ, between Q1.B and GND; connect Q1.E to GND.
- Connect Q1.C to BZ1 negative, and BZ1 positive to the board's verified USB-derived 5 V rail.
- Place D1 across the buzzer: anode to BZ1 negative, banded cathode to USB 5 V.
- Check the supply, diode and buzzer polarity, B/C/E assignments, and every connection before connecting USB.
A HIGH on GPIO26 turns the transistor on, while the buzzer's current comes from the 5 V rail. The GPIO is not its direct current source. R2 pulls the base low while the output is not driving. Verify the USB cable and board power-path capacity; do not parallel an independent 5 V supply with USB. Espressif — DevKitC power inputs
Preparing the Arduino environment
Use Arduino IDE 2.x, esp32 by Espressif Systems 3.3.12, board ESP32 Dev Module, and the Serial Monitor at 115200 baud. Install Adafruit SSD1306 2.5.17, Adafruit GFX Library 1.12.6, and Adafruit BusIO 1.17.4. Wire comes with the ESP32 package.
Extract the example and open workshop_timer.ino from its matching folder. Check the port, run Verify, and then Upload. The reference OLED address is 0x3C; change it to 0x3D only when confirmed. The program probes the address before initializing the display. An acknowledgement does not prove that the controller is SSD1306. If the startup check fails, the timer cannot start and the buzzer remains off.
| Library and release | Version | Dependencies |
|---|---|---|
| Adafruit SSD1306 | 2.5.17 | Adafruit GFX Library 1.12.6 |
| Adafruit GFX Library | 1.12.6 | Adafruit BusIO 1.17.4 |
| Adafruit BusIO | 1.17.4 | None |
Wire is included in the ESP32 core; do not install it separately through Library Manager.
Complete program
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
constexpr uint8_t BUZZER_PIN = 26;
constexpr uint8_t START_PIN = 27;
constexpr uint8_t RESET_PIN = 32;
constexpr uint8_t POT_PIN = 34;
constexpr uint8_t SDA_PIN = 21, SCL_PIN = 22;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint32_t DEBOUNCE_MS = 30;
constexpr uint32_t POT_SAMPLE_MS = 100;
constexpr uint32_t DISPLAY_MS = 200;
constexpr uint32_t SOUND_WINDOW_MS = 10000;
// A fresh event needs 30 ms at one level. A held or inhibited press must
// release stably before another event can be accepted.
struct DebouncedButton {
uint8_t pin;
bool rawHigh = true, stableHigh = true, armed = true;
bool pressed = false, released = false;
uint32_t rawChangedAt = 0;
explicit DebouncedButton(uint8_t inputPin) : pin(inputPin) {}
void begin(uint32_t now) {
pinMode(pin, INPUT_PULLUP);
rawHigh = stableHigh = digitalRead(pin) == HIGH;
armed = rawHigh;
rawChangedAt = now;
pressed = released = false;
}
void update(uint32_t now) {
pressed = released = false;
const bool readingHigh = digitalRead(pin) == HIGH;
if (readingHigh != rawHigh) {
rawHigh = readingHigh;
rawChangedAt = now;
}
if (static_cast<uint32_t>(now - rawChangedAt) < DEBOUNCE_MS) return;
if (rawHigh != stableHigh) {
stableHigh = rawHigh;
if (stableHigh) released = true;
else if (armed) {
pressed = true;
armed = false;
}
}
// Also recovers from an inhibited LOW pulse too short to become stable.
if (rawHigh && stableHigh) armed = true;
}
void inhibit() { armed = false; pressed = false; }
bool isReleased(uint32_t now) const {
return rawHigh && stableHigh &&
static_cast<uint32_t>(now - rawChangedAt) >= DEBOUNCE_MS;
}
};
Adafruit_SSD1306 display(128, 64, &Wire, -1, 100000UL, 100000UL);
bool ready = false;
bool initializeDisplay() {
if (!Wire.begin(SDA_PIN, SCL_PIN)) {
Serial.println("OLED ERROR: I2C initialization failed; reset to retry.");
return false;
}
Wire.setClock(100000);
Wire.setTimeOut(50);
Wire.beginTransmission(OLED_ADDRESS);
if (Wire.endTransmission() != 0) {
Serial.println("OLED ERROR: address did not ACK; check wiring/address and reset.");
return false;
}
Serial.print("OLED address ACK: 0x"); Serial.println(OLED_ADDRESS, HEX);
Serial.println("An ACK does not identify the display controller.");
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false)) {
Serial.println("OLED ERROR: buffer initialization failed; reset to retry.");
return false;
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setTextWrap(false);
return true;
}
DebouncedButton startButton(START_PIN), resetButton(RESET_PIN);
enum TimerState { READY, RUNNING, PAUSED, DONE };
TimerState state = READY;
TimerState drawnState = READY;
bool screenDrawn = false;
uint16_t selectedSeconds = 10, drawnSeconds = 0;
uint32_t remainingAtStart = 0, runStartedAt = 0, doneAt = 0;
uint32_t lastPotAt = 0, lastDisplayAt = 0;
uint16_t durationFromRaw(uint16_t raw) {
return static_cast<uint16_t>(10UL * (1UL + static_cast<uint32_t>(raw) * 29UL / 4095UL));
}
uint32_t remainingMs(uint32_t now) {
if (state != RUNNING) return remainingAtStart;
const uint32_t elapsed = static_cast<uint32_t>(now - runStartedAt);
return elapsed >= remainingAtStart ? 0UL : remainingAtStart - elapsed;
}
void returnToReady(uint32_t now) {
state = READY;
remainingAtStart = 0;
selectedSeconds = durationFromRaw(analogRead(POT_PIN));
lastPotAt = now;
digitalWrite(BUZZER_PIN, LOW);
}
void updateBuzzer(uint32_t now) {
const uint32_t elapsed = static_cast<uint32_t>(now - doneAt);
const bool on = state == DONE && elapsed < SOUND_WINDOW_MS && elapsed % 1000UL < 200UL;
digitalWrite(BUZZER_PIN, on ? HIGH : LOW);
}
void drawTimer(uint32_t now) {
const uint32_t remaining = remainingMs(now);
const uint16_t seconds = state == READY ? selectedSeconds :
static_cast<uint16_t>((remaining + 999UL) / 1000UL);
if (screenDrawn && drawnState == state && drawnSeconds == seconds) return;
if (screenDrawn && drawnState == state &&
static_cast<uint32_t>(now - lastDisplayAt) < DISPLAY_MS) return;
display.clearDisplay();
display.setCursor(0, 0); display.print("Workshop timer");
display.setCursor(0, 12);
switch (state) {
case READY: display.print("READY: turn the knob"); break;
case RUNNING: display.print("RUNNING"); break;
case PAUSED: display.print("PAUSED"); break;
case DONE: display.print("DONE"); break;
}
display.setTextSize(2);
display.setCursor(0, 25); display.print(seconds); display.print(" s");
display.setTextSize(1);
display.setCursor(0, 46);
if (state == READY) display.print("START: run");
else if (state == RUNNING) display.print("START: pause");
else if (state == PAUSED) display.print("START: resume");
else display.print("START: acknowledge");
display.setCursor(0, 56); display.print("RESET: ready / quiet");
display.display(); // Synchronous I2C: time spent here is part of the timer.
drawnState = state;
drawnSeconds = seconds;
screenDrawn = true;
lastDisplayAt = now;
}
void setup() {
Serial.begin(115200);
pinMode(BUZZER_PIN, OUTPUT); digitalWrite(BUZZER_PIN, LOW);
startButton.begin(millis()); resetButton.begin(millis());
analogReadResolution(12);
analogSetPinAttenuation(POT_PIN, ADC_11db);
selectedSeconds = durationFromRaw(analogRead(POT_PIN));
ready = initializeDisplay();
if (!ready) return;
lastPotAt = millis();
drawTimer(millis());
Serial.println("Timer uses elapsed millis; very brief taps can be missed during I2C.");
}
void loop() {
if (!ready) return;
const uint32_t now = millis();
startButton.update(now); resetButton.update(now);
if (resetButton.pressed) {
// RESET wins over expiry, START, PAUSE and RESUME in this iteration.
returnToReady(now);
} else {
bool justExpired = false;
if (state == RUNNING && remainingMs(now) == 0) {
// Anchor completion to its scheduled deadline, even after a slow loop.
doneAt = runStartedAt + remainingAtStart;
remainingAtStart = 0;
state = DONE;
justExpired = true;
}
// At the deadline, expiry wins over a newly qualified pause request.
if (startButton.pressed && !justExpired) {
if (state == READY) {
remainingAtStart = static_cast<uint32_t>(selectedSeconds) * 1000UL;
runStartedAt = now;
state = RUNNING;
} else if (state == RUNNING) {
remainingAtStart = remainingMs(now);
state = PAUSED;
} else if (state == PAUSED) {
runStartedAt = now;
state = RUNNING;
} else returnToReady(now);
}
if (state == READY && static_cast<uint32_t>(now - lastPotAt) >= POT_SAMPLE_MS) {
lastPotAt = now;
selectedSeconds = durationFromRaw(analogRead(POT_PIN));
}
}
updateBuzzer(now);
drawTimer(now);
}
Both buttons use INPUT_PULLUP, so pressed means LOW. A new level must remain stable for 30 ms before acceptance. Holding a button does not repeat its action, and a button held during startup must first be released. Use ordinary, deliberate presses: the program occasionally transfers a complete image, so a very short tap can occur entirely between two input samples.
Time accounting, display, and sound
The potentiometer is sampled every 100 ms in READY, using 12-bit ADC resolution and ADC_11db. durationFromRaw() applies 10 * (1 + raw * 29UL / 4095): 0 produces 10 s, 2048 produces 150 s, and 4095 produces 300 s. Integer division determines the steps; the midpoint reading does not produce 155 or 160 s. This mapping is not a calibration of shaft angle. Espressif — ADC API
At the start of a running segment, the program stores remainingAtStart and runStartedAt. remainingMs() subtracts actual elapsed milliseconds, with zero as the lower bound. On pause, it first accounts for elapsed time and then saves the remainder. Resume creates a new starting timestamp using that remainder. Time spent paused is excluded. This subtraction does not depend on the number of loop() iterations and supports the 32-bit counter wrapping through zero for these short intervals. Arduino — Blink Without Delay
drawTimer() calculates displayed seconds as (remaining + 999) / 1000. For example, 27,500 ms is shown as 28 s, while calculations and pause storage still use 27,500 ms. It transfers changed content only; within one state, at least 200 ms must separate transfers. A state change can request an earlier update. I2C runs at 100 kHz in both the Wire configuration and the SSD1306 constructor's two clock settings. Initialization with periphBegin=false preserves the configured bus.
Image transfer is synchronous: a 1024-byte frame takes approximately 92 ms for data and acknowledgements alone at 100 kHz, before additional overhead. This is a calculation, not a measured result from the circuit. Transfer time is included in elapsed-time accounting, but inputs and outputs are serviced when the program next reaches them. The completion sound may start after the logical deadline, and sound edges can be delayed. updateBuzzer() requests a nominal 200 ms on and 800 ms off. doneAt stores the scheduled deadline, so delayed handling does not extend the ten-second window; it can shorten the audible beginning. Adafruit — SSD1306 implementation
Practical experiment and expectations
Choose a short duration and first check start, pause, resume, and RESET individually. Observe READY before starting to confirm the selection. Then try the following cases and record each result.
| Action | Expected behaviour |
|---|---|
| Turn the potentiometer in READY | Selection changes in ten-second steps |
| Start, then turn the potentiometer | The active task retains its captured duration |
| Pause and wait several seconds | Remaining time stays unchanged |
| Resume after the pause | Countdown continues from the saved remainder |
| Press RESET during the sound | READY and buzzer off |
| Let the entire sound window elapse | DONE remains and the sound stops |
To check startup holding, disconnect USB, hold SW1, reconnect USB, and release it. That alone must not start the countdown; press again afterward. To investigate priority, press both buttons so they qualify together. If physical timing differences make them qualify separately, you are observing two ordered events rather than the simultaneous case.
Common problems
| Problem | What to check |
|---|---|
| Display does not initialize | SSD1306 type, 128 × 64, 21/22, address, and 3.3 V |
| A button always appears pressed | Its internal connections and GPIO-to-GND wiring |
| A short tap is sometimes missed | Hold long enough for sampling and 30 ms stability |
| No sound | Active buzzer type, Q1 B/C/E, R1/R2, 5 V, and polarity |
| Selection does not follow the knob | Changes are accepted only in READY |
| Paused display shows one second more than expected | Remaining time is rounded upward for display |
If the screen is disconnected during operation, the library does not guarantee complete detection of that physical fault. The initial address probe and initialization return value have limited scope; a successful call does not prove that every image was physically displayed.
Independent challenges
- A 40-second timer starts at t = 0. Pause is processed at t = 12,500 ms, and resume at t = 20,000 ms. Calculate the remainder, the seconds shown while paused, and the new logical deadline.
- Shorten the completion sound window to three seconds while retaining the 200/800 ms pattern. Should the completion screen disappear automatically?
Worked answers
For the first challenge, the remainder is 40,000 − 12,500 = 27,500 ms, displayed as 28 s. The new segment begins at 20,000 ms and expires at 20,000 + 27,500 = 47,500 ms. The 7500 ms pause consumed no active time. These are event-processing timestamps; do not equate them with the first physical touch of a button.
For the second challenge, change SOUND_WINDOW_MS from 10000 to 3000. The 1000 ms period and 200 ms on portion remain unchanged, producing three nominal sound intervals. DONE and zero remain visible until START acknowledgement or RESET. Ending sound and leaving the state are separate decisions.
Questions and answers
Why not subtract one second per screen update? Transfer and other processing durations vary; the number of images is not a reliable timebase.
What happens if pause coincides with expiration? Expiration wins; the program does not enter PAUSED with zero remaining.
Why does RESET have priority? It provides an unambiguous cancellation rule even when both commands qualify together.
Can this program guarantee exact sound edges? No. Outputs are serviced in the loop, while synchronous transfers and button qualification add latency.
Primary sources
- Arduino — Blink Without Delay
- Arduino — Debounce
- Espressif — ADC API
- Espressif — I2C API
- Adafruit — SSD1306 API
- onsemi — PN2222A
- Vishay — 1N4148
Downloads
Save a table of your checks with the example. Host logic verification does not replace a real ESP32 build and physical testing with the specified components.
