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:
+11
-11
@@ -34,12 +34,12 @@ from __future__ import annotations
|
||||
import random
|
||||
import time
|
||||
|
||||
from .config import FrameConfig
|
||||
from .models import Frame
|
||||
|
||||
HISTORY_MAX_LEN = 20
|
||||
|
||||
|
||||
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
def _top_up(cfg: Frame, assets: list[dict]) -> None:
|
||||
valid_ids = {a["id"] for a in assets}
|
||||
excluded_ids = set(cfg.excluded_asset_ids)
|
||||
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_ids]
|
||||
@@ -85,7 +85,7 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
|
||||
|
||||
|
||||
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
|
||||
"""Unconditionally moves to the next photo, ignoring elapsed time, and
|
||||
resets the interval clock from now. Used by the explicit next-photo
|
||||
action (POST /frame/advance) and by get_current() once the refresh
|
||||
@@ -105,7 +105,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
# only asset is already current) -- keep showing what we have.
|
||||
cfg.current_asset_id = assets[0]["id"]
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats.photos_displayed += 1
|
||||
cfg.stats_photos_displayed += 1
|
||||
# Refill back up to queue_target_len now that current_asset_id has
|
||||
# changed -- otherwise the queue is left one short until the *next*
|
||||
# advance, since the pop above consumes one of the items _top_up just
|
||||
@@ -113,7 +113,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
_top_up(cfg, assets)
|
||||
|
||||
|
||||
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
|
||||
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
|
||||
"""Unconditionally moves to the previously-current photo, the mirror
|
||||
image of advance_forced() -- pops the most recent entry off history,
|
||||
pushes the photo it's replacing onto the front of queue (so pressing
|
||||
@@ -132,12 +132,12 @@ def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
|
||||
cfg.queue.insert(0, cfg.current_asset_id)
|
||||
cfg.current_asset_id = previous_id
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats.photos_displayed += 1
|
||||
cfg.stats_photos_displayed += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) -> bool:
|
||||
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
|
||||
"""Permanently excludes asset_id from this frame's rotation (see the
|
||||
module docstring) -- doesn't touch Immich, just this frame's own
|
||||
selection. Scrubs it out of queue and history too, so it can't
|
||||
@@ -149,7 +149,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
|
||||
changed as a result."""
|
||||
if asset_id not in cfg.excluded_asset_ids:
|
||||
cfg.excluded_asset_ids.append(asset_id)
|
||||
cfg.stats.photos_removed += 1
|
||||
cfg.stats_photos_removed += 1
|
||||
cfg.queue = [a for a in cfg.queue if a != asset_id]
|
||||
cfg.history = [a for a in cfg.history if a != asset_id]
|
||||
|
||||
@@ -167,12 +167,12 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
|
||||
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
|
||||
cfg.current_asset_id = remaining[0] if remaining else ""
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats.photos_displayed += 1
|
||||
cfg.stats_photos_displayed += 1
|
||||
_top_up(cfg, assets)
|
||||
return True
|
||||
|
||||
|
||||
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
|
||||
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
|
||||
otherwise touching current_asset_id. Used by GET /api/queue so a
|
||||
change to the "upcoming photos to show" setting takes effect on page
|
||||
@@ -180,7 +180,7 @@ def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
_top_up(cfg, assets)
|
||||
|
||||
|
||||
def get_current(cfg: FrameConfig, assets: list[dict], in_quiet_hours: bool = False) -> bool:
|
||||
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
|
||||
"""Time-based, idempotent path used by GET /frame/image. Advances only
|
||||
if the current photo is unset/invalid or refresh_interval_s has
|
||||
elapsed since it was set. Returns whether it changed anything, so the
|
||||
|
||||
Reference in New Issue
Block a user