Redesign phase C: claim flow, limited manage page, device protocol

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.
This commit is contained in:
2026-07-21 23:44:22 -04:00
parent 1e8d6803ac
commit 683e3881b1
15 changed files with 779 additions and 53 deletions
+44 -5
View File
@@ -22,12 +22,14 @@ from ..auth import require_device
from ..db import frame_locked, get_db
from ..face_labels import compute_face_labels
from ..firmware import firmware_path
from ..image_pipeline import render_placeholder
from ..models import BatteryLog, Frame
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
immich_client_for,
immich_creds,
list_assets,
render_asset,
require_configured,
@@ -38,10 +40,43 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
built from the request's own base URL: whatever address the device
reached us at is by definition an address that works on this
network."""
base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id:
claim_url = f"{base}/claim?device_id={frame.device_id}"
return render_placeholder(
["This frame isn't claimed yet", "Scan to link it to your account:"],
qr_url=claim_url,
orientation=frame.orientation,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
qr_url=base,
orientation=frame.orientation,
)
def _frame_configured(frame: Frame) -> bool:
url, key = immich_creds(frame)
return bool(url and key and frame.album_id)
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
# /frame/image looks up the frame's mode here. Only photos exists today.
def _render_photos_mode(db: Session, frame: Frame) -> bytes:
require_configured(frame)
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request)
client = immich_client_for(frame)
assets = list_assets(client, frame)
@@ -93,14 +128,18 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
@router.get("/frame/image")
def frame_image(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Returns the frame's current image. For photos mode: idempotent --
only actually advances to the next photo once refresh_interval_s has
elapsed since the current one was set (see app/photo_queue.py) --
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album."""
unplanned reboot, without skipping ahead in the album. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
return Response(content=renderer(db, frame), media_type="application/octet-stream")
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
@router.post("/frame/advance")