Files
espresso_frame/server/app/main.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

75 lines
2.6 KiB
Python

"""ESPresso Frame server: pulls photos from Immich, pre-processes them
for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/ (device.py
for the firmware-facing /frame/* protocol, api.py for the web UI's
/api/*), storage in SQLite via models.py/db.py, with migration.py
importing a pre-database config.json deployment on first boot."""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from . import migration
from .auth import MANAGEMENT_TOKEN_COOKIE, browser_token_valid, management_token
from .db import SessionLocal
from .quiet_hours import ALL_TIMEZONES
from .routers import api, device
from .routers.common import default_frame, immich_creds
logger = logging.getLogger(__name__)
# Schema + legacy-config import, before the first request is served.
migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
app.include_router(device.router)
app.include_router(api.router)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
"""The web UI (Phase A: still the single-frame page, bound to the
default frame). Handles the unauthorized case itself with a friendly
token prompt rather than a bare 401, since this is the one route a
human lands on with no token yet."""
if not browser_token_valid(request):
supplied = request.query_params.get("token")
return templates.TemplateResponse(
"token_prompt.html", {"request": request, "wrong": supplied is not None}
)
with SessionLocal() as db:
frame = default_frame(db)
immich_url, _ = immich_creds(frame)
response = templates.TemplateResponse(
"index.html",
{
"request": request,
"cfg": frame,
"immich_url": immich_url,
"timezones": ALL_TIMEZONES,
},
)
supplied = request.query_params.get("token")
if management_token() and supplied == management_token():
# Query-param access (typically the manage-menu QR code) earns a
# cookie so the rest of this visit's fetch()/<img> calls -- which
# never carry the query string -- stay authorized too.
response.set_cookie(
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
)
return response