This is a DigitalMonk bench project, not a client engagement. It is a desk clock with a settable alarm, built on an Arduino Uno. A DS3231 real-time clock keeps the time, a 16x2 I2C LCD shows it, and three button modules set the time and the alarm. When the alarm time arrives, a buzzer beeps until any button is pressed or 60 seconds pass, and the alarm settings survive a power cut. It is small, but it uses the habits we rely on in production firmware: a shared bus, code that never blocks and settings that persist.



The goal was a simple bedside or desk clock built from common modules: show the time, let the user set the time and an alarm with three buttons, and ring at the right moment.
The details are what make it hard. A clock based on millis() drifts and forgets the time on every reset. Code that uses delay() for beeping or debouncing misses button presses. And an alarm that goes back to a default after a power cut is not much use to anyone.
The clock was built in five stages, each tested on the bench before moving on. Three small throwaway sketches helped along the way: an I2C scanner, a button test and an LCD character test.

PRESSED constant switches between button modules (HIGH) and bare switches to GND (LOW)We avoided delay() because a blocked loop is a loop that misses presses, and it would also freeze the alarm while someone is trying to silence it.
main.cpp — reading the buttons
int readButton() { // newly pressed button, or -1
for (uint8_t i = 0; i < 3; i++) {
bool s = digitalRead(pins[i]);
if (s != lastState[i] && millis() - lastT[i] > 40) {
lastT[i] = millis();
lastState[i] = s;
if (s == PRESSED) return i;
}
}
return -1;
}| Screen | MODE | UP | DOWN |
|---|---|---|---|
| Clock | Set hour | Alarm on/off | — |
| Set hour / minute | Next | +1 | -1 |
| Alarm hour / minute | Next (last one saves) | +1 | -1 |
| Alarm ringing | Silence | Silence | Silence |
main.cpp — the alarm check
// Alarm fires once per matching minute
int key = now.hour() * 60 + now.minute();
if (key != almH * 60 + almM) firedKey = -1;
else if (almOn && firedKey != key) {
ringing = true;
firedKey = key;
ringStart = millis();
}
if (ringing && millis() - ringStart > 60000UL)
ringing = false;
digitalWrite(BUZZER, ringing && (millis() / 250) % 2 == 0);EEPROM.update() writes only when a value changes, which saves EEPROM wearWe kept the stored data to three bytes because that is all the clock needs. The same reasoning applies to Arduino firmware written for a device people rely on: store what the user chose, validate it on the way back in, and never assume the memory is clean.
EEPROMRTClibLiquidCrystal I2C

The firmware is a single sketch built with PlatformIO in VS Code. It also runs in the Arduino IDE when saved as a .ino file.
| Layer | What we used | Why |
|---|---|---|
| Controller | Arduino Uno | Common, well understood, and tight enough on RAM to force clean code |
| Timekeeping | DS3231 RTC (0x68) with RTClib | Temperature-compensated, so it does not drift the way millis() does |
| Display | 16x2 LCD with PCF8574 backpack (0x27 or 0x3F), LiquidCrystal I2C | Two wires to the Uno, sharing the bus with the RTC |
| Input | Three button modules on D5, D3, D6 | Edge detection with a 40 ms debounce window |
| Alarm output | Active buzzer on D8 | Beep pattern generated from millis() |
| Persistence | Internal EEPROM, three bytes | Alarm settings that outlast a power cut |
An I2C scanner confirms each device and its address in seconds. A blank LCD is often just 0x3F instead of 0x27, and solid blocks with no text point to a bad backpack joint or the contrast setting.
Debouncing, beeping and redrawing all run from millis(). With no delay(), a button press is never missed, even while the alarm is ringing.
Button modules drive their output HIGH, while bare switches pull LOW. Making that one constant, and powering the modules from 5 V, removed a whole class of "buttons act strangely" bugs.
Saving the alarm costs three bytes and a few lines, and it is the difference between a demo and a device people can rely on.
We want to be precise about what this is. It is a breadboard prototype that we built and tested on the bench, for our own learning and as a reference build. There was no client, no custom PCB, no enclosure and no batch. The coin cell that would keep the DS3231 running through power-off is supported by the module, but it was not fitted on the test unit, so on this build the time has to be set again after the power is removed.
Moving a design like this to production means replacing the breadboard with a board, adding the backup cell, choosing a case and defining the test steps for each unit. The firmware patterns carry over unchanged.
The same small set of habits sits inside most timers, loggers and button-and-display controllers: an RTC for time, a loop that never blocks and settings that persist. We apply them in embedded software beyond the breadboard and, when a prototype is ready to leave the bench, on a custom board to replace the breadboard.
millis() only counts time since the last reset, so it forgets the time on every restart and drifts as it runs. The DS3231 is a dedicated, temperature-compensated real-time clock that keeps accurate time on its own. With a backup coin cell fitted it also keeps the time through a power cut.
delay() stops the whole loop, so button presses, display updates and the buzzer all freeze while it waits. In this clock, debouncing, beeping and redrawing all run from millis() timestamps instead, which lets a button silence the alarm at any moment.
Run an I2C scanner sketch with only the LCD connected. It prints the address the backpack answers on, usually 0x27 or 0x3F. If the screen lights up but shows nothing, a wrong address is the most common cause, followed by the contrast trimmer on the backpack.
Yes. I2C is a shared bus, so on an Arduino Uno both connect to A4 (SDA) and A5 (SCL). They only need different addresses, and here the DS3231 is at 0x68 and the LCD backpack at 0x27, so they never clash.
The firmware patterns can, but the prototype itself is not a product. Moving to production means a custom PCB in place of the breadboard, a backup cell for the RTC, an enclosure and a test routine for each unit. We built this clock as a bench project, so those steps were not part of it.
We can turn it into reliable firmware and a custom board, ready for small-batch or volume production.