- C++ 99.7%
- C 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .vscode | ||
| include | ||
| lib | ||
| src | ||
| test | ||
| .editorconfig | ||
| .gitignore | ||
| CLAUDE.md | ||
| platformio.ini | ||
| README.md | ||
Analogue LED Clock
A wall clock built from a ring of 60 addressable LEDs, where the hands are points of light rather than moving parts. Each LED stands for one position on the dial — one minute, one second — so the time is read exactly as it is on an analogue face: a red dot for the hour, green for the minute, blue for the second, drifting round a dim white ring marked at the twelve hours.
It keeps itself right. The time comes from NTP rather than a battery-backed clock, so it never drifts and never needs setting, including across the summer time changeover. Every colour, the overall brightness and the on/off state are controlled from Home Assistant, which discovers the clock by itself and presents it as a normal device with ordinary colour pickers and a toggle. Firmware updates go over the network.
Nothing about it is hard-wired to one wall. The strip's starting point and direction are configuration, not soldering, so a ring wound backwards or beginning at 6 o'clock reads the right way round. Movement is smoothed throughout — hands cross-fade between LEDs, colour changes ease from the old shade to the new, and switching off dims down rather than cutting out — so a clock that could look like a row of blinking indicators instead behaves like something with weight.
Hardware
| Part | Notes |
|---|---|
| Seeed Studio XIAO ESP32-C3 | RISC-V, single core, 4MB flash |
| SK6812 RGBW LED ring, 60 pixels | The white die matters — see below |
| 5V supply | Sized for the strip, not drawn from the board |
Data goes to D6 (GPIO21). Two things are worth getting right:
- Power the strip separately. Sixty RGBW pixels can pull several amps at full white. The clock only ever lights a handful at once, but give the strip its own 5V supply and tie the grounds together rather than feeding it from the XIAO.
- Consider a level shifter. SK6812s want a logic high near 3.5V and the ESP32-C3 drives 3.3V. Short runs usually work; if the first pixel misbehaves or colours flicker, that is the reason.
The white channel
Under exact-colour RGBW the driver lifts min(r, g, b) into the white channel, so an
equal-valued grey is emitted by the white die alone with the colour dies dark — a clean,
low, even glow that coloured hands read against clearly.
On a warm white strip that glow is warm rather than neutral, and the effect reaches
further than the background. Whatever portion of a colour is shared across all three
channels gets routed to the warm die, so the more desaturated a colour is, the warmer it
comes out: ask for a pastel pink (255,180,180) and roughly (255,128,76) appears.
Fully saturated colours have a channel at zero, send nothing to the white die, and are
unaffected — which covers all three default hands. A true neutral white is simply not
available: every grey becomes warm.
Note that FastLED's Rgbw takes a white colour temperature, but every built-in mode
discards it. Setting it does nothing. Compensating properly needs
kRGBWUserFunction.
Building and flashing
PlatformIO handles the toolchain.
pio run # build
pio run -t upload # flash over USB
pio run -e ota -t upload # flash over the network
pio device monitor # serial log at 115200
The first flash has to be over USB. After that the ota environment pushes the same
firmware to analogue-led-clock.local. The OTA password lives in two places that must
match: OtaPassword in include/Config.h and upload_flags in
platformio.ini. Change it — the committed value is public, and
anyone on your network who knows it can reflash the clock.
First run
With no WiFi credentials stored, the clock raises a setup hotspot called Analogue LED Clock and breathes blue while it waits. Join it, and the captive portal asks for the network plus:
| Field | Default | |
|---|---|---|
| MQTT broker | homeassistant.local |
|
| MQTT port | 1883 |
|
| MQTT username | — | |
| MQTT password | — | |
| NTP server | pool.ntp.org |
Also editable from Home Assistant |
| NTP port | 123 |
Also editable from Home Assistant |
Everything is stored on the device, so this is a one-time exercise. The portal closes itself after five minutes and reboots, so a clock that came up during a router outage retries rather than sitting in setup mode indefinitely.
While it is finding its way, the ring says where it has got to:
| Display | Meaning |
|---|---|
| Blue, slowly breathing | Setup hotspot is open |
| Amber comet | On the network, waiting for the time |
| White arc filling | Firmware update in progress |
Home Assistant
The clock announces itself over MQTT discovery, so it appears as a device with no YAML to write:
| Entity | Type | |
|---|---|---|
| Clock | Light | On/off and overall brightness — the only control |
| Background | Light (config) | The dim ring behind everything |
| Hour markers | Light (config) | The twelve hour positions — off by default |
| Hour hand | Light (config) | |
| Minute hand | Light (config) | |
| Second hand | Light (config) | |
| NTP server | Text (config) | |
| NTP port | Text (config) | |
| Factory reset | Button (config) | Clears every stored setting and reboots |
Only Clock is a control. The palette entities are marked as configuration, which keeps them out of area and device-wide service calls — so switching off the lights in a room dims the clock rather than silently blacking out the second hand for good. They remain individually adjustable whenever you want to change how the clock looks.
The clock re-publishes its discovery whenever Home Assistant announces a restart, so it reappears by itself rather than needing the integration reloaded.
Each colour is independent, and changes are saved on the device, so they survive a power cut. Colours are painted in a fixed order of precedence — background, then hour markers, then hour, minute and second hand — so when two hands land on the same LED the higher one wins outright rather than blending into a third colour.
Switching the hour markers off means no markers — the twelve positions simply show the background like any other LED, rather than going dark. Give them a colour and they become points of light; give them a very dark one and they read as gaps in the glow instead.
If the broker turns the clock away five times running — wrong credentials, rather than a broker that is merely down, which never counts — it clears its settings and returns to the setup portal, on the grounds that configuration it cannot use is worse than none.
Configuring the rest
Anything not exposed over MQTT lives in include/Config.h, including the pieces most likely to differ from this build:
TopLedandClockwise— where the strip physically starts and which way it runs. This ring begins at 6 o'clock and runs anticlockwise; set these to match yours and the dial reads correctly without rewiring.TimeZone— a POSIX TZ string, currently Europe/London. The daylight saving dates are part of the string, so nothing has to run at the changeover.NumLeds— the rendering scales to the ring, so 30 or 120 pixels work too.HandFadeMs— how long every transition takes.
Layout
include/Config.h compile-time settings and stored defaults
lib/ClockFace/ rendering: takes a time, fills a pixel buffer
lib/Settings/ EEPROM persistence
lib/Ntp/ SNTP client
lib/Mqtt/ broker connection and Home Assistant discovery
src/main.cpp hardware setup and the main loop
ClockFace knows nothing about networks or hardware — it draws into a buffer and never
touches the LEDs directly, which keeps the rendering testable on its own. Ntp is
hand-rolled rather than the framework's client because that one cannot be pointed at a
port other than 123.
Notes for anyone working on this with Claude Code are in CLAUDE.md.