Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.
Firmware: the captive portal's "Access Token" field and its NVS/
build_url plumbing only ever mattered for pointing new firmware at an
old pre-multi-frame server -- gone along with the server-side fallback
it fed. Version bump to publish the change.
The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.
Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.
The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.
One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
Found by a thorough code review:
- server/app/config.py's load()/save() each locked only their own file
I/O, not the full read-modify-write cycle each route does around
them. Since uvicorn dispatches sync routes to a thread pool, two
concurrent requests (e.g. the device's own poll landing alongside a
web UI edit) could each load() the same on-disk state and the
second's save() silently clobber the first's changes. Added
config.locked() (backed by an RLock, since load()/save() also take
the lock internally) and wrapped every mutating route's
load/mutate/save span in it -- kept outside the lock wherever a route
also does slow Immich network I/O, re-loading fresh state right
before the actual mutation instead. Verified with a new concurrency
stress test (many concurrent /api/queue/promote and /api/config
calls) alongside the existing scratch suite.
- firmware/main/root.html's SSID/password/toolsserver/access-token
inputs had no maxlength, so pasting something longer than the
matching NVS buffer (wifi_provisioning.h's FRAME_CFG_*_MAX_LEN) was
silently truncated with no indication why the device later can't
connect or gets 401s.
- frame_client.c's share_url buffer (256 bytes) could be too small in
the worst case -- toolsserver (128) + "/frame/share/" + asset_id (47)
+ "?token=" + access_token (64) can reach ~266 bytes, silently
dropping the token off a request that would then just 401 with no
obvious cause. Widened to 320.
ESP32 side can now reach the tools server over HTTPS: the Tools Server
field accepts an https:// address for a TLS-terminating reverse proxy
in front of the server (which still only ever speaks plain HTTP
itself), trusting Cloudflare's Origin CA root (embedded at build time)
since that's the common way to get a real cert on a private origin.
Every URL the device builds -- image fetch, config check, manage-menu
data, the QR codes' own links -- goes through one build_url() helper
that picks the scheme from what's configured.
Also adds an optional MANAGEMENT_TOKEN (docker-compose.yml) that gates
the web UI (/, /api/*) behind a shared secret -- unset by default, so
existing trusted-LAN deployments are unaffected. The same token is
entered once during the ESP32's captive-portal setup and gets baked
into the manage-menu's QR code (?token=...), so scanning it just works;
visiting the page without a valid token shows a plain entry prompt
instead of the config UI, and a valid query-param hit sets a cookie so
the page's own fetch()/<img> calls stay authorized for the rest of the
visit. Device-facing /frame/* endpoints are unaffected -- a separate,
already-documented trust boundary.
Moves the existing ESP-IDF captive_portal example into firmware/ to make
room for the new FastAPI server and project docs, ahead of building out the
full ESPresso Frame project (ESP32-C6 + Immich-backed e-ink photo frame).