Imported from code-a-saurus/PiClock (
AGENTS.md). Install upstream withnpx skills add code-a-saurus/PiClock. Copyright stays with the author.
AGENTS.md — PiClock
This document is LLM-generated.
Guidance for AI agents (and humans) working in this repo. Read this before making changes.
What this is
An always-on digital bedroom clock built from a Raspberry Pi Zero W (or Zero 2 W) driving an Adafruit 1.2" 4-Digit 7-Segment Display with I2C Backpack (HT16K33). It lives in a 3D-printed housing. Design goals, in priority order:
- Never needs to be reset. It boots, finds the network, and shows the correct local time with no human interaction — ever.
- Always correct across DST. Spring-forward / fall-back transitions happen automatically with no code change and no manual adjustment.
- Survives power loss. Unplug it, plug it back in; it recovers on its own.
- Quiet and legible in a dark bedroom (sensible default brightness, non-blinking colon).
How "never reset / always-correct DST" actually works
The Pi has no battery-backed RTC. We do not add one. Correctness comes from:
- NTP over Wi-Fi keeps the system clock accurate (
systemd-timesyncd, on by default in Raspberry Pi OS). No network at boot → time is wrong until Wi-Fi associates, then it self-corrects. The clock software should tolerate the brief "time not yet synced" window rather than displaying a bogus time (see Software design). - The IANA tz database handles DST. We set the timezone once on the Pi
(
sudo timedatectl set-timezone America/New_Yorkor wherever it lives) and read local time in Python. The system timezone database supplies DST rules automatically; OS package updates keep the tz rules current. Never hardcode a UTC offset. Never compute DST ourselves.
So: NTP for the instant, tzdata for the offset. That combination is the whole reliability story — keep it intact.
Hardware
- Board: Raspberry Pi Zero W or Zero 2 W, running Raspberry Pi OS Lite (headless, no desktop). One code path serves both; see the architecture note under Software design re: the arch-selected zeroconf pin. The Zero W is armv6 (32-bit OS only); the Zero 2 W is armv7l/aarch64 (32- or 64-bit OS).
- Display: Adafruit 1.2" 4-digit 7-segment + I2C backpack, HT16K33 controller.
- I2C default address
0x70(solderable to 0x71–0x77 if changed — check the board). - We drive the HT16K33 directly over I2C with
smbus2— no Blinka/Adafruit stack. The segment font and the digit/colon RAM offsets live in piclock/display.py, which is the canonical reference; that mapping is the thing to get right.
- I2C default address
- Bus: I2C is enabled on the Pi (
sudo raspi-config nonint do_i2c 0); it exposes/dev/i2c-1. Verify the display address withi2cdetect -y 1(expect0x70). - Detailed wiring/build instructions live in HARDWARE_RUNBOOK.md.
Software design
Default to 24-hour time. The code is split so the logic is testable off-Pi and the only hardware-touching part is isolated:
- piclock/clock.py — pure time →
DisplayState(4-char text + colon + sync dot). No I/O. Shows----until the clock's first-ever sync; after that, losing NTP keeps the (free-running, still approximately right) time on the display and only the sync dot goes dark — the dashes never come back mid-run. The colon is steady by default (this is a bedside clock — no movement at night); an optional 1 Hz blink is config-gated and derived from the wall clock, not a tick counter, so it can't drift. - piclock/display.py — the HT16K33
smbus2driver, plus a purebuild_buffer()(font/colon/indicator dots → 16-byte RAM; the NTP sync indicator is the backpack's lower-left dot and the Wi-Fi indicator the upper-left dot, both sharing the colon's RAM byte) and aNullDisplayfor off-Pi runs. The driver skips redundant I2C writes when nothing changed. The hardware is wrapped inResilientDisplay: if the display is absent or a connector is bumped,show()never raises — it logs once and retries the connection every ~10 s, so the service keeps running (no crash-loop) and the correct time appears the instant the display reconnects. - piclock/timesync.py —
is_ntp_synced()viatimedatectl show -p NTPSynchronized. Any error → treated as not-synced (pre-first-sync that means dashes — never a bad time). The loop polls it every 5 s until the first sync, then every 60 s. Because NTPSynchronized is the kernel's maxerror estimate (it lags an NTP outage by ~9 h), the sync dot additionally requires freshness:last_sync_age()stats/run/systemd/timesync/synchronized(touched by timesyncd on every successful sync) and the dot goes dark once the last success is older thansync_fresh_seconds(default 4500 s ≈ 2× timesyncd's max poll interval). No stamp file (dev machine, non-timesyncd system) → freshness is assumed, falling back to NTPSynchronized alone. Sync loss never brings the dashes back — that stays keyed onis_ntp_synced()/ever_synced. - piclock/wifi.py —
is_wifi_up()reads/sys/class/net/<iface>/operstate(an in-process ~140 µs file read, no subprocess, so the loop polls it every tick). It feeds the upper-left indicator dot: lit while the link is up, dark when the link drops or the interface disappears (e.g. firmware crash). Any read error → down.PICLOCK_WIFI_DOT/PICLOCK_WIFI_IFACEconfigure it. Both indicator dots share a polarity switch,PICLOCK_TROUBLE_DOTS(Config.trouble_dots, default off): off = status lights (lit while nominal); on = trouble lights (lit only when off-nominal, so a healthy clock shows just the time). Applied inapp.pyvia_dot_lit(). - piclock/config.py —
Configdataclass withPICLOCK_*env overrides (brightness, schedule on/off + times + wake/night brightness, 24h, blink, sync dot + freshness window, wifi dot + interface, I2C bus/address, tick, andPICLOCK_DISPLAY=nullfor dev). Validates its inputs at construction, including that the schedule edges are in daily order. - piclock/schedule.py — pure day/night schedule logic: wall-clock time →
phase (
off/wake/night). No I/O, unit-tested likeclock.py. - piclock/ctl.py — optional CLI control: a Unix datagram socket
(
PICLOCK_CTL_SOCKET; the unit sets/run/piclock/ctl.sockviaRuntimeDirectory=) receives one-line commands from scripts/piclockctl (on,off,brightness 0-15; the client'sbright/dimare aliases for levels 15/0). A pureparse()validates them; a daemon thread queues them for the loop, which applies them toDisplayControland mirrors them to HomeKit — same override semantics as Siri. The socket is mode 0660, groupclockctl(install.sh creates the group and addsclockerto it so the app can chgrp its own socket; humans join the group to usepiclockctl). Startup is fail-soft like HomeKit. - piclock/app.py — the loop, wiring the above together; SIGTERM/SIGINT blank the
display and exit cleanly. When the schedule is enabled (
PICLOCK_SCHEDULE=on) the loop evaluatesschedule.phase()each tick and applies each phase change viaschedule.edge()— that edge-triggering is what lets a HomeKit override stick until the schedule's next on/off edge (the dim edge only adjusts brightness, so an evening "off" stays off overnight), and since the first tick counts as an edge, a reboot or deploy mid-window comes up in the correct state with no external reconciliation. - piclock/control.py + piclock/homekit.py — display on/off +
brightness control.
DisplayControlis a tiny lock-guarded object the loop reads each tick (blank when "off"); it's always present. It is driven by the optional in-app day/night schedule (default edges: 08:00 blanks, 17:00 shows at the wake brightness — default full — and 20:00 dims to the night brightness — default the dimmest lit level; times tunable viaPICLOCK_OFF_AT/PICLOCK_ON_AT/PICLOCK_DIM_AT, levels viaPICLOCK_WAKE_BRIGHTNESS/PICLOCK_NIGHT_BRIGHTNESS). The schedule is switched on by a systemd drop-in (systemd/piclock-schedule.conf, installed byinstall.sh --schedule) — there are no schedule timers or helper units. Also optionally, by HomeKit. HomeKit (PICLOCK_HOMEKIT=on) runs a HAP-python Lightbulb accessory in a daemon thread that mutates the sameDisplayControl, and schedule-driven changes are pushed back to the Home app viaHomeKit.notify_stateso the tile stays in sync (scheduled changes also force brightness to the wake level at the on edge and the night level at the dim edge).pyhapis imported lazily so the core runs without the extra deps (separaterequirements-homekit.txt; the zeroconf pin is selected by CPU architecture via PEP 508 markers — 0.39.4 on armv6/Zero W, last pure-Python release with an armv6 wheel; the current release on Zero 2 W and up — so pip picks the right one with no install flag). Both features are opt-in viascripts/install.shflags. See SOFTWARE_RUNBOOK.md §4–5.
Guarantees worth preserving: the display layer must fail soft on I2C errors (a missing or
flaky display logs + reconnects, never crashes the service); the clock must keep showing
---- until is_ntp_synced() is first true — but once it has synced, a later sync loss
must not bring the dashes back (show the free-running time, sync dot dark); and
HomeKit and control-socket startup are
non-fatal — if either can't start (missing deps, unwritable state dir, missing socket
dir, …) the app logs and runs the clock without it, since the 7-seg's job must never depend
on an optional feature. Config validates its inputs
at construction (positive tick, known backend, 0-15 brightness levels, ordered schedule
edges) so misconfiguration fails fast instead of busy-looping or silently running the
wrong backend.
Tests live in tests/ and cover the pure parts (render, build_buffer) with no
hardware. Run with pytest.
Running on the Pi
Managed by systemd (unit at systemd/piclock.service) so it starts at boot and restarts on failure.
- The service runs as a dedicated
clockersystem account — no login shell, no sudo, member of thei2cgroup only (its sole privilege, for/dev/i2c-1). The app needs no elevated privileges:timedatectl showis readable by any user, and I2C access comes from the group. If something ever does need privilege, add a narrow allowlisted entry insudoersforclocker— never grant it blanket sudo. - Deploy target is
/opt/piclock, owned byclocker(kept out of/homeso the service account can read it). scripts/install.sh (run withsudo) creates the account, rsyncs the code there, builds the venv asclocker, and installs + enables the unit. Idempotent; re-run after pulling code. sudo systemctl {start,stop,restart,status} piclock·journalctl -u piclock -f
Dev environment
- Runtime deps are pinned in requirements.txt — just
smbus2. Dev/test deps in requirements-dev.txt (pytest). Install them however you prefer (a virtualenv is the usual choice, but that's your call). - Logic runs on any dev machine (macOS/Linux); use
PICLOCK_DISPLAY=nullto run the loop without hardware. Treat the Pi as the source of truth for anything touching I2C. A reference Pi is Debian 13 / Python 3.13; the architecture is armv6 on a Zero W, armv7l/aarch64 on a Zero 2 W. On 32-bit Raspberry Pi OSpipresolves prebuilt wheels via piwheels (configured in/etc/pip.confon the stock image); a 64-bit image resolves manylinux wheels from PyPI.
Deployment / updating
There is no built-in remote-deploy pipeline — deployment is up to you. The supported path is
to get the code onto the Pi (clone, git pull, scp, etc.) and run
scripts/install.sh with sudo. It is idempotent: re-running it after
pulling new code re-syncs /opt/piclock, refreshes the clocker venv, and restarts the
service. If you want CI push-to-deploy, an adaptable Gitea Actions template (tag-triggered,
least-privilege: a forced-command receiver plus a single allowed sudo apply step) lives in
deploy/ — see deploy/README.md. Whatever you build, have it run
as an unprivileged account — never give a deploy key blanket root.
Conventions & guardrails
- Don't reinvent time/DST. Use
zoneinfo+ system NTP. No manual offset math, no DST tables, no scraping time from the internet in app code. - Don't commit secrets (SSH keys, tokens) or local build artifacts (virtualenvs, caches).
- Keep new runtime deps out of
requirements.txtunless genuinely needed — boot time and SD-card space on a Zero W (the most constrained target) are scarce. - Match the existing minimal style; this is a small appliance, favor clarity over abstraction.
- When changing display code, state how you verified it (real hardware vs. logic-only), since most of it can't be exercised off-Pi.