Replaces the ad-hoc inline styling with a shared base template driven by
CSS custom properties (light/dark palettes), a card-based two-column
layout, and a persistent dark-mode toggle. Also moves quiet-hours'
timezone from the container's TZ env var into a proper web UI setting
(zoneinfo-backed), so it no longer needs a docker-compose.yml edit and
restart to change.
Purely a server-side decision: GET /frame/config hands back a longer
refresh_interval_s while quiet hours are in effect (exactly the seconds
until they end), and clamps the normal interval so the device's next
wake lands at the boundary instead of wandering into the window, when
outside it but approaching. A device already mid-sleep when quiet hours
begin can still land one wake inside the window -- unavoidable without
touching the firmware, since it has no wall-clock awareness -- but from
that wake on it sleeps straight through to the end.
Window is "HH:MM"-"HH:MM", wrap-past-midnight aware (e.g. 22:00-07:00),
in the server's local timezone -- added tzdata to the Dockerfile since
python:3.12-slim doesn't include it and TZ would otherwise silently
resolve to nothing and fall back to UTC.
Also fixed the "overdue" device-status check to account for quiet hours:
without this it would falsely flag a device sleeping through a long
quiet window as unreachable.
Implements the server side of the architecture decided on: the ESP32-C6
has no PSRAM and a tight RAM budget, so all the heavy lifting (JPEG
decode, resize, Floyd-Steinberg dithering, 6-color quantization, 4bpp
packing) happens here instead of on-device. The frame just does a single
GET and streams the response straight to SPI.
- GET /frame/image: looks up the current cursor's asset in the configured
Immich album, downloads its preview thumbnail, and returns it packed
into the panel's exact 800x480/4bpp/2px-per-byte format
(application/octet-stream, always exactly 192,000 bytes).
- GET / + POST /api/config + GET /api/albums: a small web UI for entering
the Immich URL/API key and picking an album, rather than cramming that
into the ESP32's captive portal form.
- Config (Immich creds, selected album, cursor) persists to a JSON file
via a docker-compose volume mount.
Verified locally with a venv (Docker isn't available in this environment):
unit-tested image_pipeline against a synthetic image (exact byte count,
valid panel color codes only), and ran a full end-to-end pass against a
mock Immich HTTP server exercising the real /frame/image path.
Pinned dependency versions in requirements.txt after hitting a real bug
with unpinned floors: the latest starlette (1.3.1) resolved by `pip
install fastapi` breaks Jinja2Templates outright.
Not yet wired to the ESP32 side (task 6) or authenticated -- /frame/image
is unauthenticated for now, fine on a trusted LAN but worth revisiting
once the firmware sends a shared device token.