Files
espresso_frame/server/app/routers/device.py
T
tfaour 683e3881b1 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.
2026-07-21 23:44:22 -04:00

405 lines
17 KiB
Python

"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
into deployed firmware -- so multi-frame support changes only how the
calling frame is resolved (see auth.require_device), never the paths or
response key names the deployed flat parser depends on
("refresh_interval_s", "firmware_version")."""
from __future__ import annotations
import logging
import time
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
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,
)
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, request: Request) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id)
RENDERERS = {
"photos": _render_photos_mode,
}
@router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings -- no
Immich-configured gate, since this doubles as the "is the server up"
signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs
zero extra round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked:
if locked.stats_first_seen == 0:
locked.stats_first_seen = time.time()
locked.stats_device_wakes += 1
if reported_version:
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
locked.stats_ota_updates_applied += 1
locked.device_firmware_version = reported_version
if reported_board:
locked.device_board_variant = reported_board
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
# compatible with its 256-byte parse buffer), and only until the
# device has authenticated with the token once (device_token_ack).
if locked.device_id is not None and not locked.device_token_ack:
response["device_token"] = locked.device_token
return response
@router.get("/frame/image")
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. 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, request), media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by
the device's next-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@router.post("/frame/battery")
def frame_battery(
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp plus a per-discharge-
cycle history that feeds the Device panel's "on battery for" and
"estimated remaining" numbers; every report also lands in the
permanent battery_log table behind the history chart."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
now = time.time()
with frame_locked(db, frame.id) as locked:
locked.stats_battery_reports += 1
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge.
locked.battery_history = []
locked.stats_recharge_cycles += 1
locked.battery_history.append([now, body.percent])
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
locked.battery_percent = body.percent
locked.battery_as_of = now
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
# Safety bound, not a real limit at realistic report rates --
# mirrors the old JSON list's cap.
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
if count is not None and count >= BATTERY_LOG_MAX:
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
BatteryLog.ts
).limit(count + 1 - BATTERY_LOG_MAX)
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
return {"status": "saved"}
@router.get("/frame/firmware")
def frame_firmware(frame: Frame = Depends(require_device)):
"""The frame's staged OTA image, streamed to the device
(esp_https_ota). 404 until something has been uploaded/fetched."""
path = firmware_path(frame.id)
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@router.get("/frame/photo-info")
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
location = _format_location(exif)
return {
"asset_id": asset_id,
"location_line1": location[0] if location else None,
"location_line2": location[1] if location and location[1] else None,
"taken_at": _format_taken_at(exif),
}
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame --
not any arbitrary Immich asset id -- as a second layer even a leaked
token wouldn't bypass."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@router.get("/frame/face-labels")
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
recognition (no detection/recognition happens here, see
app/face_labels.py). Response is a flattened, fixed-slot shape
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
hand-rolled parser can read it with the same flat-scalar helpers it
already has. Empty (count: 0) if no faces are named, or if anything
about fetching them fails -- this is a "nice to have" addition to
the overlay, not worth failing the whole menu over."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
smart_crop = locked.smart_crop_faces
orientation = locked.orientation
if not asset_id:
return {"count": 0}
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
return {"count": 0}
if not any((face.get("person") or {}).get("name") for face in faces):
return {"count": 0} # skip the extra preview download in the common no-named-faces case
try:
preview_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, smart_crop, orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
result[f"name_{i}"] = label["name"]
result[f"x_{i}"] = label["x"]
result[f"y_{i}"] = label["y"]
return result