No description
  • C++ 99.7%
  • C 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-03 22:10:52 +01:00
.vscode Project creation 2026-07-28 11:44:34 +01:00
include Put in setting for white temperature (FastLED takes it but doesn't use it?) 2026-07-29 07:07:06 +01:00
lib Re-publish to Home Assistant when HA comes back to ensure no loss of device 2026-08-03 22:10:52 +01:00
src Remove the need to pass in the time everywhere 2026-07-29 17:07:10 +01:00
test Project creation 2026-07-28 11:44:34 +01:00
.editorconfig Project creation 2026-07-28 11:44:34 +01:00
.gitignore Project creation 2026-07-28 11:44:34 +01:00
CLAUDE.md Re-publish to Home Assistant when HA comes back to ensure no loss of device 2026-08-03 22:10:52 +01:00
platformio.ini Add OTA updates 2026-07-28 18:16:52 +01:00
README.md Re-publish to Home Assistant when HA comes back to ensure no loss of device 2026-08-03 22:10:52 +01:00

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:

  • TopLed and Clockwise — 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.