Workshop timer: set, pause and signal completion

Set a 10–300 second timer with a potentiometer, display the countdown on an OLED, and add pause, resume, and priority RESET. An active buzzer signals completion, with explicit remaining-time accounting and an explanation of display latency.

Integrated projects and user controlsIntermediate120 min

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

ComponentQuantitySpecification and notes
Development board with a classic ESP32-WROOM-32 module1Match the reference GPIO labels; check physical header positions on the actual board.
USB data and power cable1Use the connector fitted to your board; the pictured kit lists Micro-USB.
Solderless breadboard1The kit lists 830 tie points. Check whether the power rails are split.
0.96-inch OLED — reference I2C SSD1306 128×641VCC/GND/SDA/SCL, 3.3 V supply, onboard pull-ups to 3V3. The image does not confirm the controller, interface or resolution.
10 kΩ potentiometer1Two end terminals and a wiper W; identify their physical positions on the actual component.
Momentary pushbutton2Use two terminals connected only when pressed; two legs on the same side may already be joined.
Active buzzer — 5 V, up to 20 mA reference1The 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 transistor1Required extra — not in kit photoADDITIONAL, 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 diode1Required extra — not in kit photoADDITIONAL, 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Ω resistor1Base resistor; course 14 also uses one across the piezo. The kit quantity at this resistance is unconfirmed.
10 kΩ resistor1R2 is the base pull-down from Q1.B to GND.
Jumper wires21Use 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.

Wiring diagram — electrical connections listed in the table below
Wiring diagramEnlargeSVGPNG
Connections · Wiring diagram
FromToConnection
ESP32.3V3OLED1.VCCReference 128×64 I2C SSD1306, powered at 3.3 V.
ESP32.GNDOLED1.GNDCommon ground.
ESP32.GPIO21OLED1.SDAI2C data.
ESP32.GPIO22OLED1.SCLI2C clock, 100 kHz reference.
ESP32.3V3RV1.END_AOne end of the 10 kΩ potentiometer.
ESP32.GNDRV1.END_BThe other end terminal.
RV1.WESP32.GPIO34Wiper W to GPIO34, an ADC1 input.
ESP32.GPIO26R1.1R1, 1 kΩ, limits base current.
R1.2Q1.BBase of the additional PN2222A; verify physical B/C/E lead order.
Q1.BR2.1R2, 10 kΩ, pulls the base down.
R2.2ESP32.GNDOther end of the base pull-down resistor.
Q1.EESP32.GNDEmitter to common ground.
ESP32.USB5VBZ1.PLUSDocumented 5 V active buzzer, up to 20 mA operating current.
BZ1.MINUSQ1.CBuzzer negative terminal to collector.
D1.ABZ1.MINUSProtective 1N4148 anode at the buzzer negative terminal.
D1.KESP32.USB5VDiode cathode, marked with a band, to +5 V.
ESP32.GPIO27SW1.CONTACT_ASTART/PAUSE button; INPUT_PULLUP input.
SW1.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
ESP32.GPIO32SW2.CONTACT_ARESET button; INPUT_PULLUP input.
SW2.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
  1. Disconnect USB and establish common GND for every branch.
  2. Connect OLED VCC → 3V3, GND → GND, SDA → GPIO21, and SCL → GPIO22. Its I2C pull-up resistors must lead to 3.3 V.
  3. Connect the potentiometer's end terminals to 3V3 and GND, and its wiper to GPIO34.
  4. Connect START/PAUSE/RESUME button SW1 between GPIO27 and GND. RESET button SW2 connects between GPIO32 and GND.
  5. Connect GPIO26 through R1, 1 kΩ, to Q1.B. Place R2, 10 kΩ, between Q1.B and GND; connect Q1.E to GND.
  6. Connect Q1.C to BZ1 negative, and BZ1 positive to the board's verified USB-derived 5 V rail.
  7. Place D1 across the buzzer: anode to BZ1 negative, banded cathode to USB 5 V.
  8. 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 Manager — documented versions and dependencies
Library and releaseVersionDependencies
Adafruit SSD13062.5.17Adafruit GFX Library 1.12.6
Adafruit GFX Library1.12.6Adafruit BusIO 1.17.4
Adafruit BusIO1.17.4None

Wire is included in the ESP32 core; do not install it separately through Library Manager.

Complete program

workshop_timer.ino Arduino / C++
Download .ino

#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.

ActionExpected behaviour
Turn the potentiometer in READYSelection changes in ten-second steps
Start, then turn the potentiometerThe active task retains its captured duration
Pause and wait several secondsRemaining time stays unchanged
Resume after the pauseCountdown continues from the saved remainder
Press RESET during the soundREADY and buzzer off
Let the entire sound window elapseDONE 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

ProblemWhat to check
Display does not initializeSSD1306 type, 128 × 64, 21/22, address, and 3.3 V
A button always appears pressedIts internal connections and GPIO-to-GND wiring
A short tap is sometimes missedHold long enough for sampling and 30 ms stability
No soundActive buzzer type, Q1 B/C/E, R1/R2, 5 V, and polarity
Selection does not follow the knobChanges are accepted only in READY
Paused display shows one second more than expectedRemaining 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

  1. 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.
  2. 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

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.