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 Print Message, 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 run a Qwiic display and a breadboard circuit on the same board
  • 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 INPUT_PULLUP, 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 sketch expects.
⚠️
Make sure you are holding the passive buzzer and not an active one. tone() only works properly on a passive buzzer, which plays whatever frequency it is sent. 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 board's Qwiic socket, 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 kit's jumpers 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 button's a–e side 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 one button's pair:

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 buzzer's + leg. This is the wire that carries the square wave.

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

8. Ground the buzzer

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

Blue jumper connecting the buzzer's other leg 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 display's address on the bus. The sketch never mentions it because the library already knows it: LCD lcd(16, 2) 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 display's backlight 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 sketch 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 sketch 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 upload 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.

The sketch uses pool.ntp.org, a free worldwide service that passes your request to whichever server is nearest you. configTime() sets it going, and from that moment the board keeps its own count of the seconds, checking in again every minute so that it never drifts far.

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

const long gmtOffset_sec = 0;
const int daylightOffset_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 gmtOffset_sec = 3600 all year and daylightOffset_sec = 3600 from late March to late October. The sketch 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.

Why the alarm needs a memory

The board runs through 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. alarmTriggered is set the moment the alarm goes off and blocks any further firing until you press a button. lastAlarmMinute 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.


Code

Here is the full sketch. Set the board to Soldered NULA Mini ESP32C6 before uploading, and put your own network name and password into WIFI_SSID and WIFI_PASS.

/**
**************************************************
*
* @file 7.7_Alarm_Clock.ino
* @brief Project that builds a Wi-Fi connected alarm clock. The board joins your network, asks an NTP server on
* the internet what the time is, and shows the current time on the LCD display. Two buttons set the alarm
* hour and minute, and when the alarm time is reached the buzzer sounds a series of beeps.
* It brings together the LCD from section 4, the button debouncing from section 2.2, the buzzer from
* section 2.4 and the Wi-Fi connection from section 6.
* For details, connection diagram and more, check out the example documentation at: <link placeholder>
* @author Soldered
***************************************************
*/

/*
The Soldered library for the LCD display.
*/
#include "LCD-SOLDERED.h"

/*
The WiFi library contains everything needed to join a Wi-Fi network.
*/
#include <WiFi.h>

/*
The time library gives us the functions for working with dates and times, and for asking an NTP server what the time is.
NTP stands for Network Time Protocol, and it is how nearly every device on the internet keeps its clock correct.
*/
#include <time.h>

/*
These two variables hold the name of your Wi-Fi network (the SSID) and its password. Replace the text between the
quotation marks with your own network details.
*/
const char* WIFI_SSID = "your ssid";
const char* WIFI_PASS = "your password";

/*
This is the address of the NTP server we ask for the time. pool.ntp.org is a free, worldwide service that hands your
request to whichever time server is nearest to you, so it is a good default to leave as it is.
*/
const char* ntpServer = "pool.ntp.org";

/*
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.
*/
const long gmtOffset_sec = 0;
const int daylightOffset_sec = 0;

/*
Here we create our display object, which we named "lcd". An object is our way of talking to the display: every function
we call on it, we call through this name. It needs no pin numbers because it uses easyC, which is Soldered's name for an
I2C connection over a single cable, and I2C always uses the same two pins on the board (IO6 and IO7 on the NULA board).
*/
LCD lcd(16, 2); // Qwiic 16x2 LCD

/*
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.
The NULA board has a pin naming logic as follows: IO2, where 2 is the number that we give to the variable.
*/
const int BUTTON_HOUR = 2;
const int BUTTON_MIN = 3;
const int 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.
*/
int alarmHour = 7;
int alarmMinute = 0;
bool alarmTriggered = 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, over and over until the minute was out. We start it
at -1, a value no real minute can have, so that the first alarm is never blocked.
*/
int lastAlarmMinute = -1;

/*
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: one remembering its previous state and one remembering when
that state last changed.
*/
bool lastHourState = HIGH;
bool lastMinState = HIGH;
unsigned long lastHourChangeMs = 0;
unsigned long lastMinChangeMs = 0;
const unsigned long debounceMs = 25;

/*
These two variables control how often we ask the NTP server for the time again. A board's own clock drifts slowly, so
checking in every once in a while keeps it accurate.
*/
unsigned long lastSync = 0;
const unsigned long syncInterval = 60000;

/*
This is a function we wrote ourselves. It asks for the current time and reports whether it succeeded.
getLocalTime() fills in the structure we hand it and returns true on success or false on failure. On a failure the
structure is left exactly as it was, which would be full of meaningless values, so we pass that answer back to the
caller and let it decide what to do rather than using numbers we cannot trust.
The "*" in the parameter means we are handing over the location of the structure rather than a copy of it, which is what
allows the function to fill in our own variable instead of one of its own.
*/
bool getLocalTimeData(struct tm *timeinfo) {
if (!getLocalTime(timeinfo)) {
Serial.println("Failed to obtain time from NTP");
return false;
}
return true;
}

/*
This function sounds the alarm. tone() makes the buzzer produce a sound of the given frequency, 1000 Hz in this case,
and noTone() stops it again. Repeating that five times in a row is what turns one long sound into a series of beeps.
Feel free to experiment with the frequency, the number of repeats and the two delays.
*/
void beepAlarm() {
for (int i = 0; i < 5; i++) {
tone(BUZZER_PIN, 1000);
delay(300);
noTone(BUZZER_PIN);
delay(100);
}
}

void setup() {

/*
Serial.begin() establishes serial communication between your board and your computer via a USB cable. We use it here
to follow the connection to the network and to the time server.
*/
Serial.begin(115200);

/*
Here we prepare the display. begin() starts the communication with it and backlight() turns on its light so the text
is readable.
*/
lcd.begin();
lcd.backlight();

/*
pinMode() is a function that configures the specified pin to behave either as an input or as an output.
The buzzer is something we write to, so it goes into OUTPUT mode, while the two buttons are things we read, so they
go into INPUT_PULLUP mode. INPUT_PULLUP switches on a small resistor inside the chip that ties each pin to 3.3V,
which means a pin sits at HIGH while its button is released and goes LOW while it is pressed.
*/
pinMode(BUZZER_PIN, OUTPUT);
pinMode(BUTTON_HOUR, INPUT_PULLUP);
pinMode(BUTTON_MIN, INPUT_PULLUP);

/*
clear() wipes anything left on the screen from before, and setCursor() chooses where the next text will appear: 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");

/*
WiFi.begin() starts the connection attempt. The function only starts the process, it does not wait for it to finish,
so we wait for it ourselves in the loop below. WiFi.status() tells us the current state and WL_CONNECTED is the value
it reports once we are online.
*/
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}

Serial.println("\nWiFi connected!");
lcd.clear();
lcd.print("WiFi connected");
delay(1000);

/*
configTime() tells the board which NTP server to use and how far our time zone is from UTC, and starts the first
request for the time in the background. It returns straight away, before any answer has arrived.
*/
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);

/*
So here we wait for that first answer, because until it arrives the board has no idea what time it is and would show
nonsense on the display. We give it up to ten seconds and then carry on either way, so that a missing internet
connection cannot leave the clock stuck here forever.
*/
lcd.clear();
lcd.print("Syncing time...");
struct tm startupTime;
unsigned long syncStart = millis();
bool timeReady = false;
while (!timeReady && millis() - syncStart < 10000) {
timeReady = getLocalTime(&startupTime);
delay(200);
}

lcd.clear();
if (timeReady) {
Serial.println("Time synced!");
lcd.print("Time synced!");
lastSync = millis();
} else {
Serial.println("Time sync failed, the clock will keep trying.");
lcd.print("Sync failed!");
}
delay(1000);
lcd.clear();
}

void loop() {

/*
Here we ask for the current time through our own function above. A struct tm holds a full date and time split into
separate fields, of which we only use two: tm_hour and tm_min.
*/
struct tm timeinfo;

/*
If we could not read the time, there is nothing sensible to show and certainly nothing to compare the alarm against,
so we wait a moment and try again on the next pass. return ends this pass through loop() early, and because the board
calls loop() again immediately we are straight back at the next attempt.
*/
if (!getLocalTimeData(&timeinfo)) {
delay(1000);
return;
}

/*
sprintf() builds a piece of text out of several values, following a pattern we give it. "%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". The result is stored in the timeStr array and then printed to the display.
*/
char timeStr[6];
sprintf(timeStr, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
lcd.setCursor(0, 0);
lcd.print("Time: ");
lcd.print(timeStr);

/*
On the second line we show the alarm time. Here we pad the numbers by hand instead of using sprintf(): if the value is
smaller than 10 we print a zero in front of it first.
*/
lcd.setCursor(0, 1);
lcd.print("Alarm ");
if (alarmHour < 10) lcd.print("0");
lcd.print(alarmHour);
lcd.print(":");
if (alarmMinute < 10) lcd.print("0");
lcd.print(alarmMinute);

/*
This is the debouncing logic for the hour button, the same one explained in example 2.2. digitalRead() reads the value
from the pin, millis() returns the number of milliseconds passed since the board began running the current program,
and together they let us ignore any change that comes too soon after the previous one to be a real press.
Because the buttons use pull-up resistors, the readings are the other way around from what you might expect: a press
is the moment the reading goes from HIGH to LOW, and that is exactly the moment we count the hour up. After 23 we
start over at 0, since there is no hour 24.
*/
bool hourReading = digitalRead(BUTTON_HOUR);
unsigned long now = millis();
if (hourReading != lastHourState && (now - lastHourChangeMs) > debounceMs) {
lastHourChangeMs = now;
if (lastHourState == HIGH && hourReading == LOW) {
alarmHour++;
if (alarmHour > 23) alarmHour = 0;
}
lastHourState = hourReading;
}

/*
The very same logic for the minute button, counting up to 59 before starting over.
*/
bool minReading = digitalRead(BUTTON_MIN);
if (minReading != lastMinState && (now - lastMinChangeMs) > debounceMs) {
lastMinChangeMs = now;
if (lastMinState == HIGH && minReading == LOW) {
alarmMinute++;
if (alarmMinute > 59) alarmMinute = 0;
}
lastMinState = minReading;
}

/*
Here we check whether it is time for the alarm. All three 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. That last check is what the alarmTriggered
variable is for, since without it the alarm would sound again and again for the whole minute.
*/
if (timeinfo.tm_hour == alarmHour && timeinfo.tm_min == alarmMinute && !alarmTriggered
&& timeinfo.tm_min != lastAlarmMinute) {
alarmTriggered = true;
lastAlarmMinute = timeinfo.tm_min;
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("ALARM!");
Serial.println("Alarm Triggered!");
beepAlarm();
}

/*
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 (alarmTriggered && (hourReading == LOW || minReading == LOW)) {
alarmTriggered = 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 (millis() - lastSync > syncInterval) {
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
lastSync = millis();
}

/*
A short pause so the display is not rewritten thousands of times per second. Keeping it short also keeps the buttons
feeling responsive.
*/
delay(100);
}

What you should see

Starting up

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

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. configTime() returns immediately, before any answer has arrived, so the sketch waits here for up to ten seconds:

The LCD showing Syncing time...
Waiting for the first answer from the NTP server

If the answer arrives you see Time synced! for a second and the clock starts. If it does not, you get Sync failed! instead, and the clock carries on anyway, retrying every minute, so a slow network delays the first reading but does not hang the project.

On the Serial Monitor

Open the Serial Monitor at 115200 baud and reset the board. The whole start-up fits in a few lines:

Serial Monitor showing the ROM boot messages, three fours, a row of dots, WiFi connected and Time synced
A complete start-up: boot messages, the LCD library's own output, twelve dots, and both confirmations
ℹ️
The first nine lines are not from this sketch at all. ESP-ROM:esp32c6-..., SPIWP, load: and entry are printed by the chip's built-in bootloader on every reset, before your program starts. They look alarming and mean nothing is wrong.
ℹ️
The three lonely 4s are not from the sketch either. They come from inside the LCD library: lcd.begin() sends the display three setup messages and prints the result of each one. You never saw them in 4.1 or 4.2 because those sketches never call Serial.begin(), so there was nowhere for the numbers to go. This one does, so they show up. Ignore them. The display works, as the photos on this page show.

Each dot is one turn of the waiting loop, and each turn takes 500 ms. Counting them tells you exactly how long the board took to join the network: twelve dots here, so six seconds. If you ever want to know whether your network is slow to hand out addresses, that row of dots is the measurement.

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. The top row does that with sprintf() and %02d; the bottom row does the same job by hand, printing a 0 first whenever the number is below ten. Two ways to reach the same result, side by side on one screen.

ℹ️
Remember the time zone. The photo reads 10:29 because the sketch ships with gmtOffset_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. beepAlarm() 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, 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 Serial Monitor 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 sketch 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. That is the sketch working as written, not a fault, but if you want it to stay where it was, reset the board instead.

If something is not right

The display is lit but blank. This one has three different causes, and the Serial Monitor tells them apart in seconds, so open it before you touch the wiring.

  • Nothing at all after the boot messages, or a row of solid blocks. The sketch is not reaching the display. Check the Qwiic cable is properly home at both ends, and that the board selected in the IDE is Soldered NULA Mini ESP32C6. A different ESP32 variant puts I2C on different pins, and the sketch will compile and upload perfectly while never once reaching the display.
  • The Monitor shows the dots and Time synced!, 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.
  • The Monitor repeats Failed to obtain time from NTP. The board is on the network but cannot get the time, so loop() gives up before it ever writes anything. The screen is blank because there is nothing to show. Some guest and corporate networks block time servers.
ℹ️
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 expander's outputs 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, and it is why the Serial Monitor is the faster place to look.

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 buzzer's legs really are in two different rows, and that IO4 is the pin at j27. On the other side of the board, a27 is IO19.


Full example

Check out the full example code on the link below:

7.7_Alarm_Clock.ino

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.