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
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.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:
| Pin | Row | Side | What it does |
|---|---|---|---|
IO2 | 25 | f–j | Hour button |
IO3 | 26 | f–j | Minute button |
IO4 | 27 | f–j | Buzzer |
GND | 30 | f–j | Ground 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.
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
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.

In the photos that follow, the board occupies rows 25 to 30.
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:
| From | To | What it does |
|---|---|---|
j30 (GND) | f–j blue − rail | Brings ground off the board |
j1 | f–j blue − rail | Takes ground back onto row 1 |
e1 | f1 | Carries it across the centre channel |
a1 | a–e blue − rail | Feeds it into the other rail |

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.

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.

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:
| From | To | Button | Colour in the photo |
|---|---|---|---|
j25 (IO2) | j17 | Hour | Orange |
j26 (IO3) | j13 | Minute | White |

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

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.

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

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.


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.

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.

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

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:

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

Both are padded to two digits, so seven minutes past shows as 07 and not 7.
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:

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.
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 thescl=Pin(7), sda=Pin(6)in theI2C()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 theCONTRASTtrimpot 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 pastWaiting for time.
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.