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.
89 lines
2.9 KiB
Python
89 lines
2.9 KiB
Python
"""Engine, sessions, and the per-frame lock that replaces the old
|
|
whole-config.json RLock.
|
|
|
|
Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
|
|
the threadpool, so this is ordinary multi-threading in one process: the
|
|
same regime the old config.locked() RLock handled, now scoped per frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from typing import Iterator
|
|
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from .models import Frame
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
|
|
|
|
_is_sqlite = DATABASE_URL.startswith("sqlite")
|
|
|
|
engine = create_engine(
|
|
DATABASE_URL,
|
|
connect_args={"check_same_thread": False} if _is_sqlite else {},
|
|
)
|
|
|
|
if _is_sqlite:
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _sqlite_pragmas(dbapi_connection, _record):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute("PRAGMA journal_mode=WAL")
|
|
cursor.execute("PRAGMA foreign_keys=ON")
|
|
cursor.execute("PRAGMA busy_timeout=5000")
|
|
cursor.close()
|
|
|
|
|
|
# expire_on_commit=False so a Frame resolved by the require_device
|
|
# dependency (which commits its last_seen touch) stays usable in the
|
|
# route handler without a re-select per attribute. Freshness inside
|
|
# mutation spans is handled explicitly by frame_locked()'s refresh.
|
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
|
|
|
|
|
def get_db() -> Iterator[Session]:
|
|
"""FastAPI dependency: one session per request (FastAPI caches the
|
|
dependency, so require_device and the route handler share it)."""
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# One lock per frame id, created on demand. Guarded by a module lock so
|
|
# two threads can't race to create different Lock objects for the same
|
|
# frame (which would defeat the whole point).
|
|
_frame_locks: dict[int, threading.Lock] = {}
|
|
_frame_locks_guard = threading.Lock()
|
|
|
|
|
|
def _get_lock(frame_id: int) -> threading.Lock:
|
|
with _frame_locks_guard:
|
|
lock = _frame_locks.get(frame_id)
|
|
if lock is None:
|
|
lock = threading.Lock()
|
|
_frame_locks[frame_id] = lock
|
|
return lock
|
|
|
|
|
|
@contextmanager
|
|
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
|
"""Serializes a whole read-modify-write span on one frame -- the
|
|
direct successor of the old config.locked(). The refresh() inside the
|
|
lock is what makes it correct: without it the session could hold
|
|
attribute state read *before* another thread's committed write, and
|
|
saving would silently clobber it (the same lost-update race the old
|
|
pattern's 're-read inside the lock' comment guarded against)."""
|
|
with _get_lock(frame_id):
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None:
|
|
raise LookupError(f"Frame {frame_id} does not exist")
|
|
db.refresh(frame)
|
|
yield frame
|
|
db.commit()
|