Files
espresso_frame/server/app/firmware.py
T
tfaour 9fbbb8ed2b Redesign phase A: SQLite storage, per-frame data model, device identity
Replaces the single global config.json (whole-file pydantic model under
one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/
pending-claims/battery_log tables (models.py), a per-frame lock registry
(db.frame_locked) succeeding config.locked(), and hand-rolled schema
versioning (migration.py). A pre-database deployment's config.json is
imported verbatim as frame #1 on first boot and left untouched as the
rollback path; the old single firmware.bin slot becomes per-frame
firmware/<id>.bin.

Routes split out of the 900-line main.py into routers/device.py (the
frozen /frame/* protocol) and routers/api.py (web UI, still on the old
single-frame paths for now). Device auth moves to require_device, which
already speaks the full multi-frame protocol: per-frame device tokens
pushed via /frame/config and acknowledged on first use, self-
registration of unknown device ids as unclaimed frames, pending-claim
attachment, and the legacy-token migration window that keeps the
currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving
to frame #1 -- including the one-time binding of its device id when it
first reports one after a future OTA.

Externally identical for existing deployments: same paths, same token
semantics, same response shapes -- verified with a migration fixture,
the legacy-device curl suite, a 20-way concurrent-advance smoke test,
and a mutate-restart-assert persistence check against a fake Immich.

photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList
JSON columns make its in-place list mutations dirty-track); quiet-hours
math extracted unchanged into quiet_hours.py.
2026-07-21 23:21:38 -04:00

41 lines
1.8 KiB
Python

"""Per-frame firmware image storage + esp_app_desc_t parsing. Shared by
the manual upload path and the Gitea auto-update path -- both end up
writing the same per-frame slot that GET /frame/firmware streams to the
device. The migration moves the old single /data/firmware.bin into frame
#1's slot."""
from __future__ import annotations
from fastapi import HTTPException
from . import config
# ESP-IDF app images embed an esp_app_desc_t at byte offset 32 (24-byte
# image header + 8-byte first-segment header): magic word, then version
# (32 bytes, NUL-padded) at +16 and project name (32 bytes) at +48 --
# verified against this project's real build artifact.
APP_DESC_OFFSET = 32
APP_DESC_MAGIC = 0xABCD5432
EXPECTED_PROJECT_NAME = "espresso_frame"
def firmware_path(frame_id: int):
return config.CONFIG_PATH.parent / "firmware" / f"{frame_id}.bin"
def parse_app_version(data: bytes) -> str:
"""Extracts the embedded version from an ESP-IDF app image, raising
HTTPException(400) for anything that isn't this project's firmware."""
if len(data) < APP_DESC_OFFSET + 80:
raise HTTPException(400, "File is too small to be a firmware image")
magic = int.from_bytes(data[APP_DESC_OFFSET : APP_DESC_OFFSET + 4], "little")
if magic != APP_DESC_MAGIC:
raise HTTPException(400, "Not an ESP-IDF application image")
version = data[APP_DESC_OFFSET + 16 : APP_DESC_OFFSET + 48].split(b"\x00")[0].decode(errors="replace")
project = data[APP_DESC_OFFSET + 48 : APP_DESC_OFFSET + 80].split(b"\x00")[0].decode(errors="replace")
if project != EXPECTED_PROJECT_NAME:
raise HTTPException(400, f"Image is for project '{project}', not '{EXPECTED_PROJECT_NAME}'")
if not version:
raise HTTPException(400, "Image has no embedded version")
return version