Skip to content

Project: Alarm Clock

The capstone project brings together nearly everything you've learned: buttons, a buzzer, the OLED screen, Wi-Fi, and a program with several "modes". You'll build a clock that sets itself from the internet, with an alarm, a stopwatch and a countdown timer.

The finished alarm clock

Photo from the first edition. Follow the wiring table for pin numbers.

Objective

The clock has four pages. Buttons A and C flip between them, and Button B does something different on each one:

Page What it shows What Button B does
Clock Date, time, and whether the alarm is set Turns the alarm on or off
Set Alarm The alarm time Edit → hours → minutes → save (A/C change the number)
Stopwatch Minutes, seconds and tenths Start → stop → reset
Timer A countdown Edit → minutes → seconds → start (press again to cancel)

When the alarm or timer goes off, the buzzer beeps until you press any button.

You'll need:

  • 1 × SSD1306 OLED screen (128 × 64, I2C)
  • 3 × push buttons
  • 1 × passive piezo buzzer
  • A 2.4 GHz Wi-Fi network
  • Jumper wires

Before you start

This project builds on OLED Screens, Buttons, Piezo Buzzers and Wi-Fi & APIs. Make sure button.py, ssd1306.py, wifi.py and your secrets.py are saved on your ESP32 (see Saving Programs to the Board).

Wiring

Part Pin Connects to
OLED GND GND
VCC 3V3
SCL GPIO 22
SDA GPIO 21
Button A (previous) one leg GPIO 14, other leg to GND
Button B (select) one leg GPIO 25, other leg to GND
Button C (next) one leg GPIO 32, other leg to GND
Buzzer + leg GPIO 26
– leg GND

Line the buttons up A – B – C from left to right, so the outside two act like arrows and the middle one like "OK".

Set your time zone

The ESP32 gets the time from an internet time server using NTP (Network Time Protocol). Time servers always give UTC, the time in London in winter, so you need to tell the clock how far your time zone is from UTC. Add this line to your secrets.py:

secrets.py
UTC_OFFSET_HOURS = -5   # New York in winter. Use -4 in summer.

Some examples: Los Angeles -8 (summer -7), Chicago -6 (-5), London 0 (1), Berlin 1 (2), India 5.5, Sydney 10 (11 in their summer).

About daylight saving time

This clock doesn't know about daylight saving time. The rules differ in every country and change over the years. When your clocks go forward or back, update UTC_OFFSET_HOURS in secrets.py. (Making the clock handle DST automatically is a great challenge if you want one!)

Plan it out

This is the biggest program in the guide, so planning really pays off.

  1. Set up the components (three buttons, buzzer and screen), then connect to Wi-Fi and set the clock.
  2. Keep track of the state. Instead of lots of separate loops, the program has one main loop and a handful of variables that describe what's going on right now: which page you're on, what you're editing, whether the stopwatch or timer is running, and whether the buzzer is ringing.
  3. Break the job into functions.
    • press_select() and press_arrow() decide what a button press means on the current page.
    • check_alarm() and check_timer() start the buzzer at the right time.
    • update_buzzer() beeps on and off while ringing.
    • draw() draws the current page.
  4. Main loop: check buttons → check alarm and timer → update buzzer → redraw the screen, about 200 times a second.

Code

Save this to your ESP32 as main.py so your clock starts whenever it's plugged in.

alarm_clock.py
# Alarm Clock: a clock, alarm, stopwatch and countdown timer in one.
#   Button A = previous page   Button B = select   Button C = next page
# The time comes from the internet (NTP), so no setting the clock!
import time
import ntptime
from machine import Pin, PWM, I2C
import ssd1306
import wifi
import secrets
from button import Button

BUTTON_A_PIN = 14
BUTTON_B_PIN = 25
BUTTON_C_PIN = 32
BUZZER_PIN = 26
SDA_PIN = 21
SCL_PIN = 22

# ---------------- set up the parts ----------------
button_a = Button(BUTTON_A_PIN)
button_b = Button(BUTTON_B_PIN)
button_c = Button(BUTTON_C_PIN)
buzzer = PWM(Pin(BUZZER_PIN), freq=1000, duty_u16=0)
i2c = I2C(0, scl=Pin(SCL_PIN), sda=Pin(SDA_PIN))
display = ssd1306.SSD1306_I2C(128, 64, i2c)

PAGES = ["Clock", "Set Alarm", "Stopwatch", "Timer"]
page = 0
editing = None      # which number is being changed, e.g. "hour", or None

alarm_on = False
alarm_hour = 7
alarm_minute = 0
alarm_done = None   # the (day, hour, minute) the alarm last went off

stopwatch_running = False
stopwatch_start = 0     # ticks_ms when it was started
stopwatch_total = 0     # ms counted before the last stop

timer_minutes = 5
timer_seconds = 0
timer_running = False
timer_end = 0           # time.time() when the countdown finishes

ringing = False
last_sync = 0


# ---------------- time helpers ----------------
def sync_clock():
    # set the ESP32's clock from the internet (in UTC)
    global last_sync
    try:
        ntptime.settime()
        print("Clock synced")
    except OSError as error:
        print("Couldn't sync the clock:", error)
    # remember when we last tried, so a failed sync isn't retried every loop
    last_sync = time.time()


def local_time():
    # (year, month, day, hour, minute, second, weekday, yearday)
    # int() so half-hour offsets like 5.5 (India) work too
    return time.localtime(time.time() + int(secrets.UTC_OFFSET_HOURS * 3600))


def two_digits(number):
    return "{:02d}".format(number)


# ---------------- buzzer ----------------
def update_buzzer():
    # while ringing, beep on and off every 200 ms
    if ringing and (time.ticks_ms() // 200) % 2 == 0:
        buzzer.duty_u16(32768)
    else:
        buzzer.duty_u16(0)


# ---------------- what each button does ----------------
def press_select():
    global alarm_on, editing, alarm_done
    global stopwatch_running, stopwatch_start, stopwatch_total
    global timer_running, timer_end
    name = PAGES[page]
    if name == "Clock":
        alarm_on = not alarm_on
    elif name == "Set Alarm":
        # B steps through: not editing -> hour -> minute -> saved
        if editing is None:
            editing = "hour"
        elif editing == "hour":
            editing = "minute"
        else:
            editing = None
            alarm_on = True
            alarm_done = None
    elif name == "Stopwatch":
        # B steps through: start -> stop -> reset
        if stopwatch_running:
            stopwatch_total += time.ticks_diff(time.ticks_ms(), stopwatch_start)
            stopwatch_running = False
        elif stopwatch_total > 0:
            stopwatch_total = 0
        else:
            stopwatch_start = time.ticks_ms()
            stopwatch_running = True
    elif name == "Timer":
        # B steps through: minutes -> seconds -> start -> cancel
        if timer_running:
            timer_running = False
        elif editing is None:
            editing = "minutes"
        elif editing == "minutes":
            editing = "seconds"
        else:
            editing = None
            timer_end = time.time() + timer_minutes * 60 + timer_seconds
            timer_running = True


def press_arrow(change):
    # A and C: change the number being edited, or switch page
    global page, alarm_hour, alarm_minute, timer_minutes, timer_seconds
    if editing == "hour":
        alarm_hour = (alarm_hour + change) % 24
    elif editing == "minute":
        alarm_minute = (alarm_minute + change) % 60
    elif editing == "minutes":
        timer_minutes = (timer_minutes + change) % 100
    elif editing == "seconds":
        timer_seconds = (timer_seconds + change) % 60
    else:
        page = (page + change) % len(PAGES)


def check_buttons():
    global ringing
    a = button_a.was_pressed()
    b = button_b.was_pressed()
    c = button_c.was_pressed()
    if ringing:
        if a or b or c:          # any button silences the buzzer
            ringing = False
        return
    if b:
        press_select()
    if a:
        press_arrow(-1)
    if c:
        press_arrow(+1)


# ---------------- alarm and timer ----------------
def check_alarm(now):
    global ringing, alarm_done
    today_now = (now[2], now[3], now[4])   # (day, hour, minute)
    if (alarm_on and now[3] == alarm_hour and now[4] == alarm_minute
            and alarm_done != today_now):
        alarm_done = today_now   # so it only goes off once
        ringing = True


def check_timer():
    global ringing, timer_running
    if timer_running and time.time() >= timer_end:
        timer_running = False
        ringing = True


# ---------------- drawing the screen ----------------
def underline(x, characters):
    display.hline(x, 44, characters * 8, 1)


def draw(now):
    display.fill(0)
    display.text("< " + PAGES[page] + " >", 0, 0, 1)
    display.hline(0, 11, 128, 1)
    name = PAGES[page]

    if name == "Clock":
        date = "{}-{}-{}".format(now[0], two_digits(now[1]), two_digits(now[2]))
        clock = "{}:{}:{}".format(two_digits(now[3]), two_digits(now[4]),
                                  two_digits(now[5]))
        display.text(date, 0, 20, 1)
        display.text(clock, 0, 34, 1)
        alarm_text = "Alarm " + two_digits(alarm_hour) + ":" + two_digits(alarm_minute)
        display.text(alarm_text if alarm_on else "Alarm off", 0, 52, 1)

    elif name == "Set Alarm":
        display.text(two_digits(alarm_hour) + ":" + two_digits(alarm_minute), 0, 34, 1)
        if editing == "hour":
            underline(0, 2)
        elif editing == "minute":
            underline(24, 2)
        display.text("B: edit/save", 0, 52, 1)

    elif name == "Stopwatch":
        total = stopwatch_total
        if stopwatch_running:
            total += time.ticks_diff(time.ticks_ms(), stopwatch_start)
        text = "{}:{}.{}".format(two_digits(total // 60000),
                                 two_digits(total // 1000 % 60), total // 100 % 10)
        display.text(text, 0, 34, 1)
        display.text("B: go/stop/reset", 0, 52, 1)

    elif name == "Timer":
        if timer_running:
            left = max(0, timer_end - time.time())
            minutes, seconds = left // 60, left % 60
        else:
            minutes, seconds = timer_minutes, timer_seconds
        display.text(two_digits(minutes) + ":" + two_digits(seconds), 0, 34, 1)
        if editing == "minutes":
            underline(0, 2)
        elif editing == "seconds":
            underline(24, 2)
        display.text("B: set/start", 0, 52, 1)

    if ringing:
        display.fill_rect(0, 16, 128, 48, 1)
        display.text("RING RING!", 24, 28, 0)
        display.text("Press a button", 8, 44, 0)
    display.show()


# ---------------- main program ----------------
display.fill(0)
display.text("Connecting...", 0, 0, 1)
display.show()
wifi.connect()
sync_clock()

last_draw = 0
while True:
    check_buttons()
    now = local_time()
    check_alarm(now)
    check_timer()
    update_buzzer()

    if time.time() - last_sync > 3600:   # re-sync once an hour
        sync_clock()

    # redraw 10 times a second; drawing every loop would slow the buttons
    if time.ticks_diff(time.ticks_ms(), last_draw) > 100:
        last_draw = time.ticks_ms()
        draw(now)
    time.sleep_ms(5)

How it works

Getting the time

ntptime.settime() asks a time server for the current time and sets the ESP32's internal clock (its RTC) to UTC. From then on, time.time() counts the seconds by itself, even without Wi-Fi. The clock drifts by a few seconds a day, so the main loop re-syncs once an hour.

local_time() adds your offset and splits the result into parts with time.localtime():

(year, month, day, hour, minute, second, weekday, yearday)

so now[3] is the hour, now[4] the minute, and so on.

One loop, many modes

The first edition used a separate while True loop for every mode. That got complicated, and while one loop ran, nothing else could happen: the alarm couldn't ring while you were using the stopwatch.

This version uses a state machine. There's only one loop, and variables like page, editing and timer_running remember what mode the clock is in. Each button press just changes those variables. Look at press_select() on the Set Alarm page:

if editing is None:
    editing = "hour"        # 1st press: start editing the hour
elif editing == "hour":
    editing = "minute"      # 2nd press: move on to the minutes
else:
    editing = None          # 3rd press: save

Because the loop never gets stuck, the alarm, timer and stopwatch all keep working no matter which page you're looking at.

Wrapping numbers with %

(alarm_hour + change) % 24 keeps the hour between 0 and 23: going up from 23 gives 24 % 24 = 0, and going down from 0 gives -1 % 24 = 23. Minutes and seconds wrap the same way with % 60.

Making the alarm ring only once

The alarm checks whether the current hour and minute match. But that stays true for a whole minute, so after you silence it, it would start ringing again straight away! alarm_done remembers the (day, hour, minute) the alarm last rang, and it won't ring twice for the same one.

Result

Photos from the first edition. Follow the wiring table for pin numbers.

Make it your own

  • Snooze: make Button B silence the alarm for 5 minutes, and only A or C turn it off for good.
  • Big digits: the built-in font is only 8 pixels tall. Draw your own large numbers with display.fill_rect() so the time is readable from across the room.
  • Weather page: add a fifth page that shows the temperature, fetched from a free weather API such as Open-Meteo (see Wi-Fi & APIs for how to make the request).
  • Sunrise light: add an LED that fades up over the 10 minutes before the alarm, using PWM.