Skip to main content

7.7 Alarm Clock

This project is a real alarm clock. The board joins your Wi-Fi network, asks a time server on the internet what the time is, and shows it on the LCD. Two buttons set the alarm, and when the clock reaches it the buzzer sounds.

It is the biggest build in the kit, and almost none of it is new. The LCD is wired exactly as in 4.1 LCD Message Display, the buttons are read exactly as in 2.2 Button Debounce, the buzzer is driven exactly as in 2.4 Buzzer Beep, and the network connection is the one from 6.1 Connecting and getting data. What is new is that all four run at the same time.

In this documentation you will learn:

  • How a board with no clock of its own learns the time, using NTP
  • Why the time on your screen depends on a time zone offset you have to set yourself
  • How to read an hour and a minute out of what time.localtime() gives you
  • How to zero-pad a number with "{:02d}"
  • How the % operator makes a counter wrap round at 24 and at 60
  • How two variables stop an alarm from firing over and over for a whole minute

Hardware required:

  • 1x Soldered NULA MINI board
  • 1x Breadboard
  • 1x Soldered Qwiic 16x2 LCD display
  • 2x Push buttons
  • 1x Passive buzzer
  • 10x Jumper wires
  • 1x Qwiic cable
  • 1x USB-C cable
ℹ️
This example needs no resistors at all. The buttons use the resistors built into the chip, switched on in code with Pin.PULL_UP, and the buzzer pin only ever swings between 0 V and 3.3 V so there is nothing to protect it from. If you have seen an alarm clock circuit drawn with two 10 kΩ resistors, that is a different way of wiring buttons and it is not what this script expects.
⚠️
Make sure you are holding the passive buzzer and not an active one. A passive buzzer plays whatever frequency it is sent, which is what the beeping relies on. An active buzzer contains its own oscillator and beeps at one fixed pitch no matter what the code asks for.

Putting the components together

This build uses three pins plus ground, and all four of them are on the f–j side of the board:

PinRowSideWhat it does
IO225f–jHour button
IO326f–jMinute button
IO427f–jBuzzer
GND30f–jGround for everything

The LCD needs no pins at all. It connects over the Qwiic socket of the board, which is wired internally to IO6 and IO7.

⚠️
Row 27 exists on both sides of the board and the two are different pins. On the f–j side row 27 is IO4, the buzzer. On the a–e side row 27 is IO19, which this project does not use. They sit at opposite ends of the same row number and are not connected to each other. Read the name printed along the edge of the board, never the row number on its own.

The board body covers the middle columns, so beside it only column a on the a–e side and column j on the f–j side are free. Every wire that touches the board therefore goes into an a… or a j… hole.

Follow the eleven steps below. Every photo is taken from the same position, so you can compare each one with the one before it and see exactly what changed.

1. Insert the NULA MINI board on the breadboard

ℹ️
This step assumes you know how a breadboard is wired inside and what its power rails are. For an introduction, see Breadboard Fundamentals documentation page.

Push the board into one end of the breadboard so that its two rows of pins sit on either side of the centre channel, with the chip facing down. Press it in evenly until all the pins are seated.

NULA MINI board seated on one end of the breadboard
Step 1: the board seated on the breadboard, occupying rows 25 to 30

In the photos that follow, the board occupies rows 25 to 30.

ℹ️
Your board may sit in different rows than the one in the photos. What matters is the row each pin lands in. Read the names printed along the edges of the board rather than copying the numbers.

2. Build one shared ground

Ground is the busiest connection in this project: both buttons and the buzzer need it. So the first job is to turn the two blue rails into a single ground line that either side of the board can reach.

That takes four short jumpers:

FromToWhat it does
j30 (GND)f–j blue railBrings ground off the board
j1f–j blue railTakes ground back onto row 1
e1f1Carries it across the centre channel
a1a–e blue railFeeds it into the other rail
Four blue jumpers joining the board ground to both blue negative rails through row 1
Step 2: ground brought off the board and carried around to the far rail through row 1
ℹ️
The rails along the two edges of a breadboard are not connected inside it: each strip runs the length of one edge and stops there. Joining them is what lets the buttons take their ground from the nearer edge instead of running long wires back across the board. Row 1 is used as a stepping stone here only because the jumpers in the kit are short; one long wire around the end of the breadboard does exactly the same job.
ℹ️
Every rail wire has to go into the hole row running alongside the blue line. Each edge of the breadboard carries two rail rows, and the one beside the red line is a separate strip that is not connected to it.

3. Place the two push buttons

Push the two buttons into the middle of the breadboard so that each one straddles the centre channel, a few rows clear of the board. Each button spans two rows.

In the photos they sit in rows 17 and 19 for the hour button, nearest the board, and rows 13 and 15 for the minute button.

Two push buttons placed across the centre channel at rows 17 to 19 and 13 to 15
Step 3: the two buttons straddling the centre channel
⚠️
A push button has four legs, but only two of them matter. The two legs on the same side of the button are permanently joined together inside it. Pressing the button connects one side to the other. So for each button always use one leg from the a–e side and one from the f–j side. If you take both wires from the same side, the two are already connected and the board will behave as if that button is held down forever.

4. Ground both buttons

Two short jumpers, one per button, from the a–e side of the button out to the blue rail on that edge: a19 for the hour button and a15 for the minute button.

Two jumpers connecting each button to the blue negative rail on the a-e edge
Step 4: both buttons grounded to the blue − rail on the a–e edge
ℹ️
This is where the bridge from step 2 earns its keep. The buttons ground to the a–e rail, but GND came off the board on the f–j side. Without those wires through row 1 this rail would be connected to nothing at all, and both buttons would be dead.

5. Connect each button to its own pin

Now the two signal wires, both on the f–j side. Each runs from a board pin to the lower row of the pair of one button:

FromToButtonColour in the photo
j25 (IO2)j17HourOrange
j26 (IO3)j13MinuteWhite
Orange and white jumpers running from IO2 and IO3 out to the two buttons
Step 5: each button wired to its own pin, IO2 to the button nearest the board

Notice that the two wires run in parallel and never cross. IO2, the pin nearest the buttons, goes to the nearest button. Swapping them swaps what the buttons do: the hour button would set minutes and the minute button hours.

ℹ️
Each button now has ground on one side of the centre channel and its own pin on the other. That is the same diagonal pair used for the single button in 2.1 Button Counter, just done twice.

6. Place the buzzer

The buzzer goes on the f–j side at the far end of the breadboard, with its legs in two neighbouring rows, rows 7 and 8 in the photo.

Look at the top of its case before you push it in. Next to the moulded lettering there is a small + inside a circle, and the leg on that side of the case is the positive one. That leg goes into row 8, the row nearer the buttons.

Passive buzzer pushed into rows 7 and 8 on the f-j side of the breadboard
Step 6: the buzzer in rows 7 and 8, its ⊕ marking facing the row 8 side
⚠️
The two legs sit in neighbouring rows, so take a moment to check that they really did land in two different rows. If both legs end up in the same row the buzzer is short-circuited and stays silent no matter what the code does.

7. Connect the buzzer to IO4

One jumper from j27, which is IO4, out to j8, the row holding the + leg of the buzzer. This is the wire that carries the square wave.

Green jumper running from IO4 at j27 to the positive leg of the buzzer at j8
Step 7: IO4 wired to the + leg of the buzzer

8. Ground the buzzer

One more jumper, from j7 (the other leg of the buzzer) down to the blue rail on the same edge.

Blue jumper connecting the other leg of the buzzer at j7 to the blue negative rail
Step 8: the buzzer grounded, closing the loop IO4 → buzzer → GND

The loop is now closed: IO4 → buzzer → GND. The 12 mm case hides the legs once the buzzer is pushed in, so if you ever need to check this wiring again, read the two jumpers instead.

The breadboard is finished. Everything from here on simply plugs in.

9. Connect the Qwiic cable to the board

The LCD does not touch the breadboard at all. It connects over a single Qwiic cable, which carries power and both I2C signals in one plug.

Find the white Qwiic socket on the NULA MINI and push one end of the cable into it. It only fits one way round.

The Qwiic connector highlighted on the NULA MINI board
The Qwiic connector on the NULA MINI
Qwiic cable plugged into the NULA MINI with the other end still loose
Step 9: the Qwiic cable in the board, the other end still loose

10. Connect the other end to the LCD

Turn the display over. The purple board on its back is the I2C LCD ADAPTER, and it carries two Qwiic sockets, one at each end. Either one works: they are the same junction with two plugs.

The other end of the Qwiic cable plugged into the purple I2C LCD adapter board
Step 10: the cable plugged into the LCD adapter, silkscreened I2C ADDR 0X20
ℹ️
The I2C ADDR 0X20 printed on the adapter is the address of the display on the bus. The script never mentions it because the driver already knows it: LCD_I2C(i2c) fills in 0x20 for you. The A0, A1 and A2 pads beside it can shift that address, which is what you would use if you ever wanted two displays on one board.

11. Connect the board to your computer

Plug the USB-C cable into the board. The PWR LED lights up, and the backlight of the display comes on with it.

The finished alarm clock powered over USB-C, the LCD lit and showing a row of solid blocks
Step 11: the finished build, powered up before any script has run
⚠️
A row of solid blocks on the top line is normal at this point, and so is a plain blue screen. Both mean the display has power but has not been initialized, which is exactly right, because no script has run yet. A lit backlight proves only that the display is receiving power; it says nothing about whether the board can talk to it. The text appears once you run the code.

How the board knows the time

The NULA MINI has no clock of its own. Unplug it and it forgets what time it is. There is no battery keeping a clock ticking the way there is in a wristwatch or a PC.

So it asks. Once it is on the network it sends a short request to a time server and gets the current time back. The protocol for that is NTP, Network Time Protocol, and it is how nearly every connected device on earth keeps its clock right.

In MicroPython that is one line:

ntptime.settime()

ntptime is built into the firmware, and settime() contacts a time server, waits for the answer, and sets the internal clock of the board from it. Unlike the Arduino configTime(), which starts the request and returns immediately, settime() does not come back until it has an answer or has given up, so once it returns successfully the clock is already correct.

ℹ️
You do not pass a server address. ntptime has one built in, and it points at the same public pool of time servers the Arduino sketch names explicitly as pool.ntp.org. If you ever need a different one, it is ntptime.host = "my.server" before calling settime().

The script wraps it in a function of its own so that a failure cannot bring the whole clock down:

def sync_time():
try:
ntptime.settime()
return True
except Exception as e:
print("Failed to obtain time from NTP:", e)
return False
ℹ️
A network call in MicroPython raises an exception when it fails rather than returning an error code, so try and except are not optional here. Returning True or False turns that into something the rest of the program can simply ask about.

Time zones are your job

An NTP server always answers in UTC, the world reference time. It has no idea where you are, so it cannot know what time it is for you. Turning UTC into local time is left to two numbers at the top of the script:

GMT_OFFSET_SEC = 0
DAYLIGHT_OFFSET_SEC = 0

Both are in seconds, so one hour is 3600. The first is how far your time zone sits from UTC. The second is the extra hour for daylight saving, if your country uses it.

ℹ️
Croatia is one hour ahead of UTC in winter and two in summer, so GMT_OFFSET_SEC = 3600 all year and DAYLIGHT_OFFSET_SEC = 3600 from late March to late October. The script ships with both set to 0, which is why the display in the photos further down reads 10:29 when the clock on the wall said 12:29. If your clock is out by a whole number of hours, this is why. Nothing is broken.

Getting an hour and a minute out of the clock

MicroPython does not hand you an hour directly. time.time() gives you a count of seconds, and time.localtime() splits such a count into its parts:

def get_current_time():
local = time.localtime(time.time() + GMT_OFFSET_SEC + DAYLIGHT_OFFSET_SEC)
return local[3], local[4]

What comes back is a tuple of eight values, and you pick the ones you want by position: position 3 is the hour and position 4 is the minute. Adding the two offsets before splitting is what moves the answer from UTC into local time.

ℹ️
The full order is year, month, day, hour, minute, second, weekday, day of the year. Counting from zero, that puts the hour at 3 and the minute at 4. return local[3], local[4] hands back both at once, which is why the caller can write hour, minute = get_current_time() on one line.

Padding the digits

Seven minutes past ten should read 10:07, not 10:7. Python does that with a format specifier:

lcd.print("Time: {:02d}:{:02d}".format(hour, minute))

{:02d} means a whole number, at least two digits wide, padded with a zero if it is shorter.

ℹ️
This is neater than the Arduino version of the same project, which needs sprintf() with %02d on one row and a hand-written "print a 0 first if the number is under ten" on the other. Here both rows use the same one specifier.

Wrapping round at 24 and 60

Each button press counts its number up, and both have to roll over:

alarm_hour = (alarm_hour + 1) % 24
alarm_minute = (alarm_minute + 1) % 60

% is the remainder operator. 24 % 24 is 0, so the hour goes 22, 23, 0, 1, exactly what a clock does. The same trick with 60 keeps the minutes in range.

Why the alarm needs a memory

The board runs through the loop about ten times a second, and the alarm fires when the clock matches the alarm time. But a minute lasts sixty seconds, so a plain comparison would match roughly six hundred times in a row and the buzzer would never stop.

Two variables prevent that. alarm_triggered is set the moment the alarm goes off and blocks any further firing until you press a button. last_alarm_minute remembers which minute it fired in, so that pressing a button to clear the display cannot immediately set it off again while that same minute is still running. It starts at -1, a value no real minute can have, so the first alarm is never blocked.

And why it checks whether the time is real

There is one more guard, and it has no equivalent in the Arduino sketch:

if not time_synced:
lcd.setCursor(0, 0)
lcd.print("Waiting for time")
# ...try the sync again...
continue

Until a sync has succeeded, the internal clock of the board is not set to anything meaningful. Showing it would be misleading, and comparing the alarm against it could set the alarm off at a random moment. So the script says Waiting for time on the display, retries the sync once a minute, and continue skips the rest of the loop until it works.


Code

Here is the full script. Put your own network name and password into WIFI_SSID and WIFI_PASS before running it, and make sure the lib folder is on the board.

# I2C is what the Qwiic connector carries, PWM drives the buzzer, and Pin reads the two buttons.
from machine import I2C, Pin, PWM

# The Soldered driver for the LCD display, found in the lib folder of the examples repository.
from LCD import LCD_I2C

# The network module joins a Wi-Fi network.
import network

# The ntptime module asks an NTP server on the internet what the time is and sets the clock of the board from the
# answer. NTP stands for Network Time Protocol, and it is how nearly every device on the internet keeps its clock
# correct.
import ntptime
import time

# These two variables hold the name of your Wi-Fi network (the SSID) and its password.
WIFI_SSID = "your ssid"
WIFI_PASS = "your password"

# NTP servers always answer in UTC, the world reference time, so we have to tell the board how far our own time zone
# sits from it. The offset is given in seconds, so one hour is 3600. Croatia in winter is one hour ahead of UTC,
# which would be 3600 here.
# The second value is the extra offset for daylight saving time. Set it to 3600 during summer time in a country that
# uses it, and leave it at 0 otherwise.
GMT_OFFSET_SEC = 0
DAYLIGHT_OFFSET_SEC = 0

# These are the variables to which we pass the numbers of pins that we had connected the two BUTTONS and the buzzer
# to. One button counts the alarm hour up, the other counts the alarm minute up.
BUTTON_HOUR = 2
BUTTON_MIN = 3
BUZZER_PIN = 4

# These variables hold the alarm time we are counting towards, starting at 07:00, and remember whether the alarm has
# already gone off, so that it sounds once and not over and over during the same minute.
alarm_hour = 7
alarm_minute = 0
alarm_triggered = False

# This variable remembers which minute the alarm last went off in. Without it, stopping the alarm with a button press
# inside the very minute it fired would let it fire again immediately. We start it at -1, a value no real minute can
# have, so that the first alarm is never blocked.
last_alarm_minute = -1

# This variable remembers whether we ever managed to reach the time server. Until we have, the clock of the board is
# not set to anything meaningful, so we must not compare the alarm against it.
time_synced = False

# These are the variables used for button debouncing, the same technique explained in example 2.2. Because we have
# two buttons here, each one needs its own pair of variables.
last_hour_state = 1
last_min_state = 1
last_hour_change_ms = 0
last_min_change_ms = 0
DEBOUNCE_MS = 25

# These two variables control how often we ask the NTP server for the time again. The clock of a board drifts slowly,
# so checking in every once in a while keeps it accurate.
last_sync = 0
SYNC_INTERVAL_MS = 60000

# Here we set up the I2C connection and create our display object. On the NULA MINI, I2C uses IO6 for the data line
# (SDA) and IO7 for the clock line (SCL), which are the pins the Qwiic connector is wired to.
i2c = I2C(0, scl=Pin(7), sda=Pin(6))
lcd = LCD_I2C(i2c)

# Here we create our Pin objects for the two buttons. Pin.PULL_UP switches on a resistor inside the chip that ties
# each pin to 3.3V while its button is released, so a pin reads high (1) when its button is up and low (0) while it
# is pressed.
hour_btn = Pin(BUTTON_HOUR, Pin.IN, Pin.PULL_UP)
min_btn = Pin(BUTTON_MIN, Pin.IN, Pin.PULL_UP)

# Here we create our PWM object for the buzzer. duty_u16() sets what fraction of the time the pin stays on, and half
# of the full range gives a buzzer its clearest tone.
buzzer = PWM(Pin(BUZZER_PIN))
SOUND_ON = 32768
SOUND_OFF = 0
buzzer.duty_u16(SOUND_OFF)


def get_current_time():
# This is a function we wrote ourselves. It returns the current hour and minute in our own time zone.
# time.time() gives us the time the board knows, counted in seconds, and adding our two offsets to it moves that
# from UTC into local time. time.localtime() then splits the result into separate pieces, of which position 3 is
# the hour and position 4 is the minute.
local = time.localtime(time.time() + GMT_OFFSET_SEC + DAYLIGHT_OFFSET_SEC)
return local[3], local[4]


def sync_time():
# This function asks the NTP server for the time and sets the clock of the board from the answer.
# We wrap it in a try block because anything that goes over a network can fail, and a failed sync should not stop
# the whole clock.
try:
ntptime.settime()
return True
except Exception as e:
print("Failed to obtain time from NTP:", e)
return False


def beep_alarm():
# This function sounds the alarm. freq() sets the pitch, 1000 Hz in this case, and switching the sound on and off
# five times in a row is what turns one long tone into a series of beeps.
buzzer.freq(1000)
for _ in range(5):
buzzer.duty_u16(SOUND_ON)
time.sleep_ms(300)
buzzer.duty_u16(SOUND_OFF)
time.sleep_ms(100)


# Here we prepare the display. begin() starts the communication and has to come first. backlight() then turns on the
# light behind the screen, and it has to come after begin(), which would otherwise switch it back off.
lcd.begin()
lcd.backlight()

# clear() wipes anything left on the screen from before, and setCursor() chooses where the next text appears: the
# first number is the column and the second is the row, both counting from zero.
lcd.clear()
lcd.setCursor(0, 0)
lcd.print("Connecting WiFi")

# Here we create our network object, switch the Wi-Fi hardware on and start the connection attempt. connect() does
# not wait for the connection to finish, so we wait for it ourselves in the loop below.
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(WIFI_SSID, WIFI_PASS)
while not wlan.isconnected():
time.sleep(0.5)
print(".", end="")

print()
print("WiFi connected!")
lcd.clear()
lcd.print("WiFi connected")
time.sleep(1)

# Now that we are online we can ask for the time. settime() waits for the answer to come back, so once it returns
# successfully the clock of the board is already correct.
if sync_time():
time_synced = True
last_sync = time.ticks_ms()
lcd.clear()
lcd.print("Time synced!")
else:
lcd.clear()
lcd.print("Sync failed!")
time.sleep(1)
lcd.clear()

while True:

# If we never reached the time server, the clock of the board is not set to anything meaningful. Showing it would
# be misleading and comparing the alarm against it could set the alarm off at the wrong moment, so we say what is
# going on and try the sync again on the next pass.
# continue jumps straight back to the top of the loop.
if not time_synced:
lcd.setCursor(0, 0)
lcd.print("Waiting for time")
if time.ticks_diff(time.ticks_ms(), last_sync) > SYNC_INTERVAL_MS:
if sync_time():
time_synced = True
lcd.clear()
last_sync = time.ticks_ms()
time.sleep_ms(100)
continue

# Here we read the current time through our own function above.
hour, minute = get_current_time()

# Here we build the text for the display. "{:02d}" means "a whole number written with at least two digits,
# padded with a zero if needed", which is what turns 7 minutes past into "07" instead of "7".
lcd.setCursor(0, 0)
lcd.print("Time: {:02d}:{:02d}".format(hour, minute))

# On the second line we show the alarm time, padded the same way.
lcd.setCursor(0, 1)
lcd.print("Alarm {:02d}:{:02d}".format(alarm_hour, alarm_minute))

# This is the debouncing logic for the hour button, the same one explained in example 2.2.
# Because the buttons read high when released, a press is the moment the reading goes from 1 to 0, and that is
# exactly the moment we count the hour up. After 23 we start over at 0, since there is no hour 24.
hour_reading = hour_btn.value()
now = time.ticks_ms()
if hour_reading != last_hour_state and time.ticks_diff(now, last_hour_change_ms) > DEBOUNCE_MS:
last_hour_change_ms = now
if last_hour_state == 1 and hour_reading == 0:
alarm_hour = (alarm_hour + 1) % 24
last_hour_state = hour_reading

# The very same logic for the minute button, counting up to 59 before starting over.
min_reading = min_btn.value()
if min_reading != last_min_state and time.ticks_diff(now, last_min_change_ms) > DEBOUNCE_MS:
last_min_change_ms = now
if last_min_state == 1 and min_reading == 0:
alarm_minute = (alarm_minute + 1) % 60
last_min_state = min_reading

# Here we check whether it is time for the alarm. All the conditions have to be true at once: the hour has to
# match, the minute has to match, and the alarm must not have gone off already.
if hour == alarm_hour and minute == alarm_minute and not alarm_triggered and minute != last_alarm_minute:
alarm_triggered = True
last_alarm_minute = minute
lcd.clear()
lcd.setCursor(0, 0)
lcd.print("ALARM!")
print("Alarm Triggered!")
beep_alarm()

# Pressing either button after the alarm has gone off clears the warning from the display and arms the alarm
# again for the next day.
if alarm_triggered and (hour_reading == 0 or min_reading == 0):
alarm_triggered = False
lcd.clear()

# And here we ask the NTP server for the time again every so often, so that the clock does not drift.
if time.ticks_diff(time.ticks_ms(), last_sync) > SYNC_INTERVAL_MS:
sync_time()
last_sync = time.ticks_ms()

# A short pause so the display is not rewritten thousands of times per second. Keeping it short also keeps the
# buttons feeling responsive.
time.sleep_ms(100)

What you should see

Starting up

The script narrates its own start-up on the display, one screen at a time. First it announces that it is joining the network:

The LCD showing Connecting WiFi
The board is trying to join your network. This screen stays until it succeeds
⚠️
This screen has no time limit. The waiting loop has no timeout at all, so if the network name or password is wrong the board sits here forever without ever telling you. If it has not moved on after ten seconds or so, check WIFI_SSID and WIFI_PASS for typos rather than the wiring. The dots in the console are the only sign it is still trying.

Once it is on, you get a second of confirmation:

The LCD showing WiFi connected
Connected. This screen lasts one second

Then the board asks the time server for the time. Because ntptime.settime() waits for its answer, there is no separate "syncing" screen here. The next thing you see is the result. If the answer arrives you get Time synced! for a second and the clock starts. If it does not, Sync failed! appears instead, and the loop falls through to Waiting for time, retrying once a minute until it succeeds.

ℹ️
That is a small difference from the Arduino version of this project, which shows a Syncing time... screen while it polls for an answer that arrives in the background. Here the wait happens inside one blocking call, so there is nothing to display during it.

In the console

Reset the board and the whole start-up fits in a few lines:

......
WiFi connected!

Each dot is one turn of the waiting loop, and each turn takes 500 ms, so the row of dots is a stopwatch. Six dots here means about three seconds to join the network. Expect somewhere between four and eight; the exact number changes from run to run, because it depends on how quickly your router answers. If you ever want to know whether your network is slow to hand out addresses, that row of dots is the measurement.

ℹ️
If the sync fails you also get Failed to obtain time from NTP: followed by the reason, printed once a minute for as long as it keeps failing. Some guest and corporate networks block time servers outright.

The clock running

After the start-up screens clear, the display settles into the two lines it will show from then on: the current time on top, the alarm time underneath:

The LCD showing Time: 10:29 on the top row and Alarm 10:29 on the bottom row
The clock running, with the alarm set to the minute that is about to arrive

Both are padded to two digits, so seven minutes past shows as 07 and not 7.

ℹ️
Remember the time zone. The photo reads 10:29 because the script ships with GMT_OFFSET_SEC = 0 and is therefore showing UTC. It was 12:29 in the room. Set the offset and the top row becomes your local time.

Setting and hearing the alarm

The alarm starts at 07:00. Press the button on IO2 to count the hours up and the one on IO3 to count the minutes, and watch the bottom row change. Both wrap round: 23 goes back to 0, and 59 back to 0.

To hear the alarm without waiting all day, set it to the minute that is about to arrive. When the clock catches up, the top row is replaced:

The LCD showing ALARM! while the buzzer sounds
The alarm firing: five beeps, and ALARM! on the display
ℹ️
That screen is only up for about two seconds. beep_alarm() plays five beeps of 300 ms with 100 ms of silence between them, and the display is not touched again until it finishes. The moment the beeping stops, the loop runs once more and writes the time back over the top of ALARM!. If you want to photograph it, you have one shot as the beeping starts.

The console prints Alarm Triggered! at the same moment, which is useful if you missed the screen.

⚠️
The alarm beeps once and stops by itself. It does not keep sounding until you press a button. Five beeps and it is over. Pressing a button afterwards clears ALARM! from the display and arms the alarm again for next time.
ℹ️

There is a quirk worth knowing about that button press. The script reads the buttons before it checks whether to clear the alarm, so the same press that clears the display also counts the alarm time up by one. Silence it with the hour button and the alarm moves from 10:29 to 11:29.

Which button you use matters more than it looks. The minute button pushes the alarm on to the next minute, so it goes off again sixty seconds later. Silence that one with the minute button too and it moves on again, and you have an alarm that fires every minute for as long as you keep answering it that way. Use the hour button to silence it, which buys you an hour, or reset the board.

That is the script working as written rather than a fault, and it is worth understanding before trying to fix it: the button handling and the alarm check are two separate blocks that both look at the same press.

If something is not right

The display is lit but blank. This one has three different causes, and the console tells them apart in seconds, so look there before you touch the wiring.

  • Nothing at all, and the script stops with OSError: [Errno 19] ENODEV. The board is not reaching the display. Check the scl=Pin(7), sda=Pin(6) in the I2C() line, then that the Qwiic cable is properly home at both ends.
  • The console shows the dots and WiFi connected!, but the screen stays dark. The board is talking to the display and the display is simply too faint to read. Turn the CONTRAST trimpot on the purple adapter board. See 4.1.
  • The console repeats Failed to obtain time from NTP. The board is on the network but cannot get the time, so the loop never gets past Waiting for time.
ℹ️
That first case is worth dwelling on. A lit backlight is not evidence of anything: turning it on is itself a message sent over the Qwiic cable, but the outputs of the expander sit high on power-up, so the light comes on whether or not the message ever arrives. This is the same trap as step 11.

The clock is wrong by a whole number of hours. That is the time zone offset, not a fault. See above.

One button does nothing. Check it is using one leg from each side of the centre channel. A button wired with both wires on the same side reads as permanently pressed, and one wired with the ground wire in the wrong row does nothing at all.

The buzzer is silent but ALARM! appears. The code is fine and the fault is in the last two wires. Check that the legs of the buzzer really are in two different rows, and that IO4 is the pin at j27. On the other side of the board, a27 is IO19.

ImportError: no module named 'LCD'. The lib folder is not on the board. See Setting up MicroPython.


Full example

Check out the full example code on the link below:

7.7_Alarm_Clock.py

Wi-Fi connected alarm clock: the board gets the time from an NTP server, shows it on a Qwiic LCD, and sounds a buzzer at an alarm time set with two buttons.