A digital clock with "pseudo-vacuum-tube" display based on WS2812 LED strips and edge-lit acrylic segments (Lixie-style). Powered by ESP32, it synchronizes time via NTP over Wi-Fi, and is written in C for ESP-IDF v6.x. All settings can be changed at runtime over the board's USB port — no re-flashing required.
- Displays time (hours:minutes:seconds) on 6 digits (runtime-configurable).
- Each digit uses 10 LEDs (0–9) – only the required digit lights up per position.
- 6 colour palettes (
color_mode0–5): Garland (original per-pair rotation), Mono (one fixed hue), Triad (fixed per-pair colours), Spectrum (rainbow sweep), Prism (static colour per digit), Chronos (colour encodes the time) — plus an optional breathing brightness pulse. - Boot animation (digits 0–9 chase) shown until the first successful time sync.
- Cross-fade between digit changes — configurable duration in milliseconds (0 = instant).
- Slot-machine effect — a periodic 0–9 roll across all digits (interval in minutes, 0 = off).
- Night mode — automatic brightness reduction in a configurable night window.
- Palette phase shift and secondary hue — rotate every palette (
hue_shift) and add a two-tone Mono (hue_2). - Wi-Fi power save — switch the radio off after time sync and re-sync on an interval while the display keeps running.
- Automatic time synchronisation via NTP with a runtime-configurable interval.
- Runtime configuration over USB: NTP server, sync interval, timezone, Wi-Fi SSID/password, brightness, digit count, LED GPIO, colour mode — all changeable from a PC via the
configure_clock.pytool, persisted to NVS. - Builds for all major ESP32 variants (
esp32,esp32-s2,esp32-s3,esp32-c3,esp32-c6,esp32-h2).
- Architecture — modules, data flow, the stateful renderer, tasks and Wi-Fi power save.
- Tests — the three test tiers, environment setup and how to run them on Windows and Linux/WSL.
- Any ESP32 board (e.g., ESP32-DevKitC).
- Addressable LED strip WS2812 (or compatible, e.g., SK6812) – number of LEDs =
digits × 10. - 3D-printed or laser-cut edge-lit segments – 10 LEDs per digit.
| ESP32 GPIO | Connection |
|---|---|
| GPIO14 (default) | WS2812 DIN |
| 3.3V / 5V | LED strip power (mind the current!) |
| GND | Common ground |
⚠️ Ensure your power supply can deliver enough current (approx. 60 mA per LED at full brightness). For 60 LEDs, this is ~3.6 A at white. A 5 V / 2 A external supply is recommended.
There are two levels of configuration:
- Runtime (recommended) – all operating parameters are stored in NVS and can be changed from a PC over the board's USB port, without re-flashing.
- Build-time defaults – the initial values (via
menuconfig) that apply on first boot.
The firmware runs an interactive console (ESP Console) on UART0 (the board's USB-serial bridge, 115200 baud) with the prompt clock> . You can talk to it manually with any serial terminal, or use the bundled tool:
# Read the current configuration
python tools/configure_clock.py get
# Set several parameters (applies immediately; SSID/password needed for Wi-Fi)
python tools/configure_clock.py set ssid=MyWiFi password=secret ntp_server=pool.ntp.org
# Persist to flash (NVS) and reboot (reboot applies gpio; digits applies immediately)
python tools/configure_clock.py save
python tools/configure_clock.py reboot
# Show Wi-Fi / time-sync / display status
python tools/configure_clock.py statusRequires pyserial and pyyaml (pip install -r tools/requirements.txt; or use the project .venv, see below). The tool auto-detects the serial port; use --port COM5 if needed.
Available parameters (set key=value):
| Key | Description | Default |
|---|---|---|
ntp_server |
NTP hostname | pool.ntp.org |
sync_interval |
NTP sync period, seconds (30..604800) | 3600 |
tz_offset |
UTC offset in minutes, e.g. 180 = UTC+3 (Moscow) |
180 |
ssid |
Wi-Fi network name | (empty — must be set) |
password |
Wi-Fi password | (empty) |
brightness |
LED brightness, percent (0..100) | 100 |
digits |
Number of digits to display (1..max) | 6 |
gpio |
LED strip GPIO (applies after reboot) | 14 |
color_mode |
Palette 0..5: 0 Garland, 1 Mono, 2 Triad, 3 Spectrum, 4 Prism, 5 Chronos | 0 |
hue |
Base hue 0..359 (Mono: all digits; Triad: first pair) | 200 |
hue_shift |
Global base-hue offset 0..359 (rotates every palette; 0 = classic) | 0 |
hue_2 |
Secondary hue 0..359 for Mono two-tone (alternate digits when != hue) |
200 |
sync_method |
0 = immediate, 1 = smooth (adjtime) |
0 |
breathing |
0 = off, 1 = pulsing brightness (any palette) |
0 |
night_mode |
0 = off, 1 = automatic night dimming |
0 |
night_low_brightness |
Brightness during night hours, percent (0..100) | 5 |
night_start |
Night window start hour (0..23, inclusive) | 23 |
night_end |
Night window end hour (0..23, exclusive) | 7 |
cross_fade |
Fade duration for digit changes, ms (0 = off, instant) | 150 |
slot_machine_interval |
Minutes between automatic 0–9 "slot machine" rolls (0 = off) | 30 |
wifi_power_save |
0 = Wi-Fi stays on, 1 = radio off after sync (wakes every sync_interval to resync) |
0 |
Console commands: get, set, save, reset, reboot, status, help.
The project ships with a config.yaml file in the root. It is the single source of truth for provisioning the device and is also read by build_target.py (the build: section). The top-level keys map 1:1 to the set keys above. An optional serial: section sets the USB connection used by configure_clock.py — port (empty = auto-detect), baud, and timeout (response wait in seconds); the --port/--baud CLI options override it, and apply ignores this section when building the set command.
# Apply everything from config.yaml, persist to NVS and reboot
python tools/configure_clock.py apply --save --reboot
# Use a different file
python tools/configure_clock.py --config my_settings.yaml applyEdit config.yaml before flashing/running. Because it can contain real Wi-Fi credentials, keep it out of version control if needed (or store the credentials in a separate, ignored file).
⚠️ Wi-Fi SSID and password are now configured here, not inmenuconfig. Untilssidis set, the clock keeps showing the boot animation and retries the Wi-Fi connection automatically.
If you only use the runtime tool, no menuconfig is needed. The following defaults live under Clock Configuration:
| Parameter | Description |
|---|---|
GPIO number for LED strip (default) |
Initial LED pin (default 14). |
Number of digit displays (default) |
Initial active digits (default 6). |
Maximum number of digit displays |
Compile-time pixel buffer size; runtime digits is clamped to it. |
SNTP server name (default) |
Initial NTP server. |
Time synchronization method (default) |
immediate or smooth. |
Time synchronization period (default) |
Initial interval in seconds. |
Timezone offset (minutes from UTC, default) |
Initial timezone, e.g. 180 = UTC+3. |
Default LED brightness (percent) |
Initial brightness. |
Default color mode / Default fixed hue |
Initial colour settings. |
idf.py menuconfig- At startup the firmware loads the runtime config, connects to Wi-Fi (using the configured SSID/password), and queries the NTP server.
- After the first successful response, the system time is set and
time_is_synced()becomestrue. - The display switches from the boot animation to the current time.
- Every configured interval (default 1 hour) the ESP32 re-queries the NTP server and adjusts the time — Wi-Fi stays connected, no re-connect needed.
- If Wi-Fi/NTP is unavailable, the clock keeps showing the animation and retries every 10 s until it succeeds.
If the Internet is temporarily unavailable after a successful sync, the clock continues showing time using its internal RTC (ESP32 has no battery-backed RTC, so it will drift until the next successful sync).
After flashing, monitor the logs (idf.py monitor) and you should see:
I (xxxx) wifi_mgr: Got IP: 192.168.1.xx
I (xxxx) sntp_sync: Time synchronized successfully
I (xxxx) main: Time sync completed, sync task will exit
You can also connect to the serial console (115200 baud) and run status or get.
If synchronisation fails, check:
ssid/passwordare set and saved (configure_clock.py set ssid=... password=... save);- NTP server reachability (ping from your PC);
- Router not blocking NTP traffic (UDP port 123).
-
Install ESP-IDF (recommended v6.0.2; the code also builds on v5.3).
-
Clone or copy the project files into a folder.
-
Open a terminal and activate the IDF environment:
. $HOME/esp/esp-idf/export.sh # Linux/macOS .\export.ps1 # Windows (PowerShell)
-
Configure defaults via
idf.py menuconfig(optional). -
Build (optionally for a specific chip) and flash:
# Single command for any supported target: python tools/build_target.py --target esp32s3 --flash # or manually: idf.py set-target esp32s3 idf.py build idf.py flash monitor
build_target.pyalso reads thebuild:section ofconfig.yamlwhen no flags are given:python tools/build_target.py --config config.yaml
-
Configure Wi-Fi and preferences over USB:
python tools/configure_clock.py set ssid=MyWiFi password=secret save reboot # or from the YAML file: python tools/configure_clock.py apply --save --reboot
Create a .venv in the project root (it is already ignored in .gitignore):
python -m venv .venv
.venv\Scripts\activate # Windows
source .venv/bin/activate # Linux/macOS
pip install -r tools/requirements.txtThen run the tools as python tools/configure_clock.py ... and python tools/build_target.py ... with the venv active.
Full setup instructions for all three test tiers (Windows + Linux/WSL) are in docs/tests.md.
configure_clock.py and build_target.py are covered by a pytest suite in tests/ that mocks the serial port and YAML files — no ESP32 board is needed. Run it from the project root:
.venv\Scripts\python -m pytest tests -v # Windows
.venv/bin/python -m pytest tests -v # Linux/macOSpytest is declared in tools/requirements.txt — install it with pip install -r tools/requirements.txt if it isn't present.
The pure firmware logic lives in core/ (config parsing/validation, time-zone math, digit rendering) with no ESP-IDF dependencies, so it can be unit-tested natively on a Linux host with Unity. Because host tests are Linux-only, run them from WSL2:
- Open a WSL terminal (Windows Terminal, or
wsl -d Ubuntu-24.04). - Activate the ESP-IDF environment of the WSL IDF installation (v6.0 here):
. /home/grand/esp/esp-idf-v6.0/export.sh - Go to the project — from WSL the Windows folder is mounted under
/mnt/d:cd /mnt/d/Projects/Espressif/Clock/clock - Build and run the host tests:
bash tests/c/run_tests.sh
The script compiles core/src/*.c + Unity (from $IDF_PATH) + tests/c/test_core.c into build_host/ and runs it. Expected output:
15 Tests 0 Failures 0 Ignored
OK
These tests exercise the exact functions the firmware uses (core_config_set_value, core_time_format_tz, core_display_set_digit, ...).
The repo ships a GitHub Actions workflow (.github/workflows/ci.yml) that runs on every push / pull request on Ubuntu (real Linux — no board or local setup needed):
- Python tools (Tier 1) —
pytestforconfigure_clock.py/build_target.py(48 tests). - Host C unit tests (Tier 2) — Unity tests for the
core/logic (15 tests). - Firmware builds (Tier 3) — builds all supported targets in parallel:
esp32,esp32s2,esp32s3,esp32c3,esp32c6,esp32h2.
A green ✅ on a commit means everything passes; a red ❌ catches compile regressions for any chip, tool regressions, or core-logic bugs automatically. Just push and open the Actions tab on GitHub (enable Actions under Settings → Actions if the repo disallows them).
The firmware is chip-agnostic — only the LED GPIO needs to match your board's wiring.
The project is modular for easier maintenance and extension. Full details: docs/architect.md.
| Module | Purpose |
|---|---|
app_config |
NVS-backed runtime configuration (Wi-Fi, NTP, timezone, brightness, digits, GPIO, colours). Provides get/set/save/reset and getters used by the other modules. |
config_console |
ESP Console REPL on UART0 (USB). Registers get, set, save, reset, reboot, status commands. |
wifi_manager |
Wi-Fi STA connection using the runtime SSID/password, with automatic reconnect and IP-wait. |
led_display |
RMT channel + encoder init and led_display_send() to push pixel buffers. |
core |
Pure, host-testable logic (no ESP-IDF): core_config (config parsing/validation), core_time (UTC→local, night window), core_display (digit placement, HSV→RGB). |
clock_ui |
Stateful per-frame renderer (clock_ui_frame()): boot animation, time, cross-fade, slot machine, palettes, night mode, breathing. |
sntp_sync |
One-shot / background SNTP sync with runtime settings; time_is_synced() flag; Wi-Fi power-save support. |
led_strip_encoder |
WS2812-specific RMT encoder. |
main |
Startup orchestration and FreeRTOS tasks: sync_task and display_task. |
sync_task(priority 5) – connects Wi-Fi and callsinit_sntp(); on success it exits (or, with Wi-Fi power save, switches the radio off and re-syncs everysync_interval); on failure retries every 10 s.display_task(priority 4) – driven by a 30 msesp_timerhardware timer (notvTaskDelay); renders one frame per tick viaclock_ui_frame()and owns the pixel buffer. Before sync: boot animation; after: current time.
| Function | Description |
|---|---|
app_config_init() |
Loads config from NVS (or defaults) and applies the timezone. |
app_config_set/save/reset() |
Change, persist or restore the runtime configuration. |
config_console_init() |
Starts the serial console REPL with the configuration commands. |
wifi_manager_connect() |
Connects to the configured Wi-Fi and waits for an IP (retries in background). |
led_display_init() |
Initialises RMT + encoder using the runtime LED GPIO. |
led_display_send(pixels, size) |
Sends a pixel array to the strip (blocking). |
clock_ui_frame(pixels, synced) |
Renders one stateful frame (boot / time / cross-fade / slot / night). |
core_time_is_night_hour() |
Checks whether an hour falls inside the configured night window. |
init_sntp() |
Connects, starts SNTP and waits for the first sync; returns success. |
time_is_synced() |
true only after a successful time sync. |
console– ESP-IDF component providing the interactive REPL (esp_console).- All other components (RMT driver, SNTP, NVS, Wi-Fi, FreeRTOS) are part of ESP-IDF.
The previous dependency on protocol_examples_common was removed — Wi-Fi is now handled by the project's own wifi_manager so that the SSID/password can be configured at runtime.
The code is distributed under Apache-2.0 (original Espressif files) and Unlicense / CC0-1.0 for custom additions (see file headers).
Inspired by the Lixie and Edge-Lit clock concepts. Uses examples from Espressif (SNTP, RMT encoder for WS2812).
- Add date or temperature display (with a sensor).
- Switch colour schemes via a button (in addition to the console).
- Build a web interface for configuration.
- More palette modes / custom colour gradients.