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.
75 lines
2.6 KiB
Python
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
|