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.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Schema versioning + one-time import of a legacy config.json deployment.
|
||||
|
||||
Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
|
||||
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
|
||||
just create_all. DDL stays dialect-neutral so a future move to Postgres
|
||||
is a DATABASE_URL change, not a rewrite.
|
||||
|
||||
Run at import time from main.py, before any request is served.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from . import config
|
||||
from .db import SessionLocal, engine
|
||||
from .models import Base, BatteryLog, Frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _migration_1(conn) -> None:
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
]
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
||||
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
||||
current = row[0] if row else 0
|
||||
for version, fn in MIGRATIONS:
|
||||
if version > current:
|
||||
logger.info("Applying schema migration %d", version)
|
||||
fn(conn)
|
||||
if row is None:
|
||||
conn.execute(
|
||||
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
|
||||
)
|
||||
row = (version,)
|
||||
else:
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
_ensure_frame_one()
|
||||
|
||||
|
||||
def new_device_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def new_manage_token() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def _ensure_frame_one() -> None:
|
||||
"""First boot only (frames table empty): create frame #1 -- imported
|
||||
verbatim from a legacy config.json if one exists, otherwise fresh
|
||||
defaults. Either way it's the legacy-token frame: the deployed
|
||||
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
|
||||
and require_device resolves those requests here. The frames-nonempty
|
||||
guard makes this idempotent; config.json is left untouched as the
|
||||
rollback path."""
|
||||
with SessionLocal() as db:
|
||||
if db.scalars(select(Frame).limit(1)).first() is not None:
|
||||
return
|
||||
|
||||
cfg = config.load() # all defaults if the file doesn't exist
|
||||
had_file = config.CONFIG_PATH.exists()
|
||||
|
||||
frame = Frame(
|
||||
name="Frame 1",
|
||||
device_id=None,
|
||||
device_token=new_device_token(),
|
||||
manage_token=new_manage_token(),
|
||||
legacy_token_enabled=True,
|
||||
created_at=time.time(),
|
||||
immich_url=cfg.immich_url,
|
||||
immich_api_key=cfg.immich_api_key,
|
||||
album_id=cfg.album_id,
|
||||
order=cfg.order,
|
||||
refresh_interval_s=cfg.refresh_interval_s,
|
||||
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
||||
quiet_hours_start=cfg.quiet_hours_start,
|
||||
quiet_hours_end=cfg.quiet_hours_end,
|
||||
timezone=cfg.timezone,
|
||||
smart_crop_faces=cfg.smart_crop_faces,
|
||||
orientation=cfg.orientation,
|
||||
queue_target_len=cfg.queue_target_len,
|
||||
current_asset_id=cfg.current_asset_id,
|
||||
current_asset_set_at=cfg.current_asset_set_at,
|
||||
queue=list(cfg.queue),
|
||||
queue_cursor=cfg.queue_cursor,
|
||||
history=list(cfg.history),
|
||||
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
||||
battery_percent=cfg.battery_percent,
|
||||
battery_as_of=cfg.battery_as_of,
|
||||
battery_history=[list(pair) for pair in cfg.battery_history],
|
||||
last_seen=cfg.last_seen,
|
||||
device_firmware_version=cfg.device_firmware_version,
|
||||
device_board_variant=cfg.device_board_variant,
|
||||
firmware_available_version=cfg.firmware_available_version,
|
||||
firmware_update_repo_url=cfg.firmware_update_repo_url,
|
||||
firmware_auto_update=cfg.firmware_auto_update,
|
||||
firmware_update_token=cfg.firmware_update_token,
|
||||
firmware_update_checked_at=cfg.firmware_update_checked_at,
|
||||
firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
|
||||
stats_first_seen=cfg.stats.first_seen,
|
||||
stats_device_wakes=cfg.stats.device_wakes,
|
||||
stats_photos_displayed=cfg.stats.photos_displayed,
|
||||
stats_photos_removed=cfg.stats.photos_removed,
|
||||
stats_battery_reports=cfg.stats.battery_reports,
|
||||
stats_recharge_cycles=cfg.stats.recharge_cycles,
|
||||
stats_ota_updates_applied=cfg.stats.ota_updates_applied,
|
||||
stats_config_saves=cfg.stats.config_saves,
|
||||
)
|
||||
db.add(frame)
|
||||
db.flush() # assign frame.id for the battery log rows
|
||||
|
||||
for pair in cfg.battery_log:
|
||||
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
||||
|
||||
db.commit()
|
||||
|
||||
# The single legacy firmware slot becomes frame #1's per-frame slot.
|
||||
legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
|
||||
if legacy_bin.exists():
|
||||
per_frame_dir = config.CONFIG_PATH.parent / "firmware"
|
||||
per_frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")
|
||||
|
||||
if had_file:
|
||||
logger.info(
|
||||
"Imported legacy config.json as frame #%d (%d battery log entries)",
|
||||
frame.id,
|
||||
len(cfg.battery_log),
|
||||
)
|
||||
else:
|
||||
logger.info("Fresh install: created default frame #%d", frame.id)
|
||||
Reference in New Issue
Block a user