# Changelog All notable changes to LitClock are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/) — dates are ISO 8601. ## [v0.222.0] - 2026-07-23 ### Fixed - **The settings QR now has its ISO 18004 quiet zone** (#25). The top-strip QR rendered flush against the divider line (no border — 2px of white above, 9px to the right), while the spec wants 4 modules (12px) of clear space on all sides; a Reddit report showed the divider running straight into the QR's bottom modules. The 78px strip can't hold a conventionally bordered QR, so the quiet zone is carved from the surroundings instead: the composite white-outs the strip's top-right corner and notches the divider under the QR before pasting, guaranteeing 12px left, right, and below by construction (the top edge rides the panel's physical inactive margin). Clearance verified against all 4,809 corpus images, with a new test that fails loudly if a future corpus regen inks the notch region. README hero photo refreshed to show the current layout. ## [v0.221.0] - 2026-07-18 ### Security - **Gift mode now forces SSH off before the device powers down** (#21). "Prepare for Gifting" wipes WiFi and resets config, but never touched SSH — so an owner who had ever enabled SSH would hand the recipient a device with SSH and the well-known default credentials listening the moment it joined the recipient's network. The gift flow now disables SSH across every vector (the `ssh.socket` unit that Bookworm socket-activates — disabling `ssh.service` alone left port 22 open, caught by on-hardware A/B testing — plus `ssh.service`, `raspi-config`, and the `/boot` re-enable flags) and verifies port 22 is actually closed before powering off, refusing to ship if it isn't. Recipients re-enable SSH from the console if they need it, same as a fresh flash. ### Fixed - **A first-boot that times out now powers the device off instead of idling** (#22). When setup wasn't completed in the window, the e-ink painted "Setup Incomplete" and the Pi then sat powered on indefinitely — drawing current, holding the hotspot, reading as "stuck" on a shelf. It now paints the recovery instructions ("Unplug, then plug back in") and powers off immediately, with the message persisting on the bistable e-ink while off so the on-screen instruction and the device's actual state agree. A power cycle is the recovery. - **iOS captive-portal probe handling hardened** (#20). Fixed a real hole where a probe to the bare `apple.com` host fell through to the full 20 KB setup form instead of the small bridge page. Apple detection probes now get a redirect off the Apple hostname (the pattern commercial portals use), the full documented Apple probe-host set is covered, and iCloud Private Relay / APNs traffic on the isolated hotspot is answered cleanly instead of being spoofed-then-refused. Note: extensive on-hardware investigation (including packet captures) established that current iOS (26.x) does **not** auto-open the captive sheet for offline setup hotspots regardless of the server's response — this is a client-side iOS change, not something the clock can fix. The documented "open your browser" fallback works every time and the e-ink instructs it; Android auto-opens as before. ### Changed - **Release tooling: build-once, promote** (#23). The image workflow gained a promote path so a release can ship the exact image that was flash-tested, instead of rebuilding an untested image at tag time. Internal/CI only — no device-facing change. ## [v0.220.0] - 2026-07-15 ### Fixed - **The Control PWA can no longer crash-loop itself into a brick over the port-80 floor** (#15, #16). control_server binds port 80 as the non-root `pi` user, which needs `net.ipv4.ip_unprivileged_port_start<=80`. A field incident showed the persistent `/etc/sysctl.d` drop-in can silently fail to apply during an OTA (the install error was swallowed, and the update's only warning checked the live sysctl value, not the persisted file) — so the update reported success, the next reboot reverted the floor to 1024, and the service crash-looped on `EACCES` with no recovery path for a keyboard-less owner. Now `litclock-control.service` self-heals the floor with an `ExecStartPre` that re-asserts it as root on every start (idempotent, ~1ms), and `update.sh` verifies the drop-in by content and warns loudly (with the exact re-install command) if it is missing or stale. Reviewed by two independent adversarial passes. - **Personalized gift welcome messages no longer get their tail cut off** (#17). The "Prepare for Gifting" welcome splash rendered the message at a fixed 48pt capped at two lines and ellipsis-truncated anything longer — "May it always be a good ti…" on a real gift. The splash now auto-fits: it shrinks the font down a `(size, lines)` ladder (48→44→38→32→28pt) and picks the largest size that shows the whole message with no ellipsis, only truncating a message far past the 280-character input cap. Short greetings still render at 48pt exactly as before. - **DIY installs no longer abort on a never-copied systemd unit** (#14). `scripts/install.sh` enabled `litclock-reresolve-location.service` (and expected `litclock-prepare-for-gift.service`) without ever copying them to `/etc/systemd/system`; under `set -e` the `systemctl enable` aborted the whole install. Both units are now copied, with a drift guard requiring every unit in `systemd/` to be wired into the installer. Flashed images were unaffected. ### Added - **Captive-portal probe logging now records the response side** (#11). Each `CAPTIVE-PROBE` line logs which branch answered (CNA bridge / redirect), the HTTP status, byte count, and the full user-agent, so a "the setup page didn't auto-open" report can be diagnosed from the journal instead of guessing. Diagnostic groundwork for the iOS captive-sheet investigation. ### Changed - **README rewritten around the builder's journey** (#12, #13): a real product photo as the hero, a numbered Build → Living-with-it → Give-one-away → Under-the-hood structure, phone screenshots of the control app, and the first-boot walkthrough — so someone deciding to build one can follow the order they'd actually do it in. ## [v0.219.0] - 2026-07-13 ### Security - **pillow 12.2.0 → 12.3.0** — picks up the fixes for PYSEC-2026-2253 through PYSEC-2026-2257 (five advisories published 2026-07-13 against 12.2.0; caught by the pip-audit CI gate). pillow renders every frame on the device, so this rides the next release. ### Added - **"Factory reset" button in the Control PWA's System tab** (#510, #511). Erases everything — all settings and saved WiFi — and reboots into the first-time setup experience, distinct from "Reset WiFi" (which keeps settings). Guarded by the same confirm-sheet + CSRF flow as the other System actions; the env wipe is fail-closed (`--strict-env-wipe`), so a partial wipe aborts rather than leaving a half-reset clock. - **Print-ready case STLs in `3d-models/`** (#521). The maintainer's lightly modified Time Teller v3 case parts (notches added to the top-back and bottom pieces; top-front unmodified) — Arthur Gassner's design, CC BY per his Printables listing, attributed in NOTICE.md. Builders no longer need to fetch the case from a separate site. - **Owner recovery guide** (`docs/recovery.md`, #509). The SSH-off posture's companion: console access with default credentials, enabling SSH from the SD card's boot partition, reset-to-first-boot, and the read-only Diagnostics tab. ### Changed - **The appliance image no longer auto-updates OS packages** (#508). A fielded/gift LitClock must never apt-upgrade behind the owner's back — a surprise kernel or driver bump could break the e-ink stack with nobody at the keyboard. The pi-gen image zeroes the apt periodic knobs and masks `apt-daily{,-upgrade}.timer`. LitClock's own weekly self-updater (`litclock-update.timer`) is unaffected. - **README rewritten to describe the current product** (#516–#520): the real EPIC-#383 setup flow (WiFi-only hotspot form, IP-geo auto-config, handoff splash), a Control App section with phone screenshots, a first-boot visual walkthrough, the quick-start booklet links, the exact Waveshare panel purchase link, and an auto-playing one-minute tour GIF (`docs/media/litclock-intro.gif`) rendered from the real device code paths. - **PII sweep ahead of the public release** (#82). The gift-message placeholder in the System tab no longer carries the maintainer's real first name ("Love, Alexis" is the new example — same 32-char length, so the e-ink wrap docs/tests it anchors are unchanged). Sample weather coordinates in `README.md` / `env.sh.sample` are rounded to city-block precision (`30.27, -97.74`), and the full-precision latitude that appeared in redaction test fixtures, a code comment, and an earlier CHANGELOG entry is replaced with an arbitrary example value — the repo's own diagnostics-redaction standard (2-decimal rounding) now applies to the repo itself. Also: `docs/building-image.md` examples updated from stale CalVer tags to the current SemVer scheme, and `pi-gen/config` now documents the intentional default-creds + SSH-off posture (#387, closed). - **Example city decoupled from the maintainer's location** (#82 PII sweep, part 2). Every "Frisco, TX" / zip-75033 reference — README and `env.sh.sample` samples, QA docs, code comments, and ~100 test fixtures including the `is_daytime` solar regression suite — swapped to Austin, TX (30.27, -97.74; same timezone and similar latitude, so the pinned day/night assertions hold unchanged). A test comment identifying "the founder's Pi's location" reworded. The internal planning docs (`DESIGN.md`, `PRD-`/`PLAN-LitClock-Control-PWA.md`) are now gitignored ahead of their exclusion from the public repo. ## [v0.218.0] - 2026-07-11 ### Changed - **BREAKING: the Control PWA is now at `http://litclock.local` / `http://` — no port to type** (#343). control_server moved from port 8443 to **port 80**, so the URL a recipient scans from the e-ink QR, taps from the mDNS bookmark, or types by hand no longer carries a `:port` — a smaller, less technical-looking address for non-technical gift users. (This supersedes #343's original 8080 proposal: 8080 would still have shown a port; only 80 removes it.) The port is bound by the non-root `pi` service account via a one-line sysctl drop-in (`/etc/sysctl.d/30-litclock-unprivileged-ports.conf` → `net.ipv4.ip_unprivileged_port_start=80`), deliberately chosen over `AmbientCapabilities=CAP_NET_BIND_SERVICE`: the `litclock-control` unit runs `NoNewPrivileges=no` + setuid `sudo` for the reboot/poweroff actions, and adding a capability directive to a `User=pi` unit has repeatedly flipped the kernel `NoNewPrivs` bit and broken setuid sudo on this hardware — a sysctl never touches that wiring (and stays safe once #82 drops the `010` blanket sudo, when setuid `020` sudo becomes the only reboot path). `setup_server.py` stays HTTPS on 8443 (the iOS captive trust dance needs TLS) — the two are now different ports for different phases, no longer sharing 8443. The port + URL are a single source of truth (`src/control_url.py::control_base_url`, which omits `:80`), shared by control_server (bind) and the e-ink clock (QR); the mDNS probe in `status.js` derives the port from the live origin — so the QR target, the bookmark switch, and the actual listen port can never drift. Installed on fresh flash (pi-gen), manual install (`install.sh`), and OTA (`update.sh` applies the sysctl live before restarting control_server). - **Migration (one-time, recoverable):** on OTA update the e-ink QR refreshes to the port-less URL within one paint cycle. A PWA already pinned to a phone home screen, or a saved `:8443` bookmark, will break (the origin includes the port) — **re-scan the QR (it's right there on the clock) and re-pin / re-bookmark.** No data is lost. ## [v0.217.0] - 2026-07-10 ### Added - **Diagnostics: a "Download full logs" export for shell-less support** (#416 follow-up). The Control PWA's diagnostics "Copy support payload" already hands a helper the system state + a **3-line** journal preview per unit — deliberately shallow so it never blocks first paint. That's often too thin to actually debug a failing unit. New `GET /api/diagnostics/support-logs` assembles a single downloadable `text/plain` bundle: the same redacted system payload plus a **~50-line** journal tail per unit across the `DIAG_UNITS` allowlist, so a non-technical owner can hand over one file (or paste) that carries enough context — no SSH. Off the page/poll critical path; deep reads use a **distinct, line-count-scoped cache key** so they never serve or poison the 3-line page-preview cache; a wall-clock budget bounds the serial `journalctl` loop and appends an explicit *named-units* truncation note rather than silently dropping units; `no-store` + redacted (secrets in journal lines are stripped). The existing per-unit `GET /api/diagnostics/journal` also gained a capped `?lines=N` param (default 3, max 200) for pulling a deeper tail for a single unit. Surfaced as a plain `` link beside the Copy button, so it works with no JavaScript. - **LKG auto-revert: the clock now self-heals a bad over-the-air update without SSH or an SD reflash** (#209 follow-up). #209 shipped the *writer* (`litclock-lkg.service` records the last SHA that actually painted to `/var/lib/litclock/lkg-sha`, heartbeat-gated); this ships the *consumer*, `litclock-bootcheck.service`. It answers one question per boot — "did the clock paint a frame since this boot?" — via the tmpfs render heartbeat (network-independent, so a merely-offline clock is never touched). On a persistent failure it self-heals: fail 1 and 2 auto-reboot to retry (a single failed render may be transient), and on fail 3 it pins the last-known-good SHA and routes recovery **back through `update.sh`** in a new rollback mode, which does the *complete* install (git + submodules + venv hash-gate + units→`/etc` + sudoers + dispatcher + smoke) rather than a partial `git reset` — so a dependency- or unit-level bad update is actually recoverable, not just a code-only one. If the recovered code also fails it gives up (a persistent `bootcheck-gave-up` marker + best-effort "please re-flash" splash) instead of looping, bounding the whole sequence to ~4 reboots. Two supporting fixes make the recovery target reliable: `update.sh` **no longer clears `lkg-sha`** at the start of an update (the heartbeat-gated writer replaces it only once new code paints, so `lkg-sha` always points at the last code that actually rendered — a dead-on-arrival update can never blank the recovery target, closing the DOA gap), and a `blocked-sha` suppression stops the weekly timer from re-installing the exact release bootcheck just reverted from until a newer release supersedes it. Runs as `pi` using only the `systemctl reboot` + `systemctl start --no-block litclock-update.service` grants already in `sudoers/020_litclock-control` — no new privilege, works after #387 drops `010`. Hard prerequisite for #82 (public release: SSH is off, so a recipient's bricked clock would otherwise need a physical reflash). Design reshaped in `/plan-eng-review` + Codex outside-voice (plan: `docs/plans/lkg-bootcheck-plan.md`). ### Security - **Closed the pi→root escalation paths so dropping the blanket `010_pi-nopasswd` sudo is safe** (#387, prerequisite for #82). Each was harmless while `010` grants `pi` NOPASSWD:ALL, but becomes a live hole the moment the public image ships with only the scoped `020_litclock-control` allowlist. **(tz-wrapper)** `geocoding.set_system_timezone` (the IP-geo + A18 browser-tz fallback) ran `sudo timedatectl set-timezone `, which `020` could only authorize via a `set-timezone *` glob — a hole letting `pi` set the clock to any string reaching the resolver. It now calls a new root-owned wrapper `/usr/local/lib/litclock/litclock-set-timezone` that re-validates the timezone against the kernel zoneinfo list *in root-owned code* (a metachar pre-filter + `grep -Fxq`, and `timedatectl` resolved via PATH — never an env-var binary override that could cross `sudo`); `020` authorizes just that fixed path. **(C1)** the root NetworkManager dispatcher invoked the **pi-writable** `/home/pi/litclock/scripts/litclock-mark-collected.sh` — pi→root on the next DHCP event once `010` drops. It now runs a root-owned copy in `/usr/local/lib/litclock/` (no fallback to the repo copy). **(prepare-for-gift)** `litclock-prepare-for-gift.service` (root, startable by `pi` via `020`) exec'd the **pi-writable** `reset-setup.sh` — pi could edit it and get arbitrary root code. It now execs a root-owned copy (with its sourced `lib/state.sh` shipped root-owned alongside), and the gift-message step runs the root-owned system `/usr/bin/python3` instead of the pi-writable venv interpreter. **(NTP grant)** `first-boot.sh`/`install.sh` run `sudo timedatectl set-ntp true`, which `020` didn't authorize — a fresh clock would silently never sync time once `010` drops; added as a fixed-argv grant. **(C2)** the dispatcher + mark-collected writer run as root but write into the `0755 pi pi` `/run/litclock` and `/var/lib/litclock` dirs; both now refuse to follow a symlink at the target and stage via `mktemp` (O_EXCL) instead of a guessable `$MARKER.tmp.$$` path. (A residual sub-millisecond check-then-open race remains — closing it fully needs an `O_NOFOLLOW` open POSIX `sh` can't express — accepted as an explicit risk: it requires an already-compromised `pi` shell winning a race for a limited-content root write.) All root-owned helpers install root:root across pi-gen, `install.sh`, and `update.sh`; the gift-mode `timedatectl set-timezone UTC` fixed-argv path is unchanged. - **Diagnostics redaction now rounds compound-key and odd-shaped coordinates in the support-logs / journal export** (#497, #498). The diagnostics redaction rounds coordinates to ~city-block precision so the copy / support-logs / journal surfaces stay safe to paste into a public GitHub issue — but `_COORD_KEYED_RE` led with `\b`, which cannot match inside a compound key (the `_` before `LATITUDE` is a word char, so there is no boundary). systemd logs `env.sh` verbatim when a line uses `export` (`Ignoring invalid environment assignment 'export WEATHER_LATITUDE=33.1234'`), so the device's **full-precision, street-level home coordinates** rode un-rounded into the exported bundle while the structured field correctly showed the rounded `33.12`. The lead-in is now a zero-width negative lookbehind (`(?` endpoint (validated against the `DIAG_UNITS` allowlist, redacted, `no-store`) **after** first paint, so the page is interactive immediately and one slow unit can't stall another's tail (the multi-failure case that previously blew the client's 10 s budget). The tail cache TTL is decoupled to 45 s (> the 30 s poll) so a stuck-failed unit doesn't re-fork `journalctl` every poll; the copy support payload appends the client-hydrated logs **inside** its fenced block; a no-JS browser sees status chips + a `