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.
97 lines
2.8 KiB
Python
97 lines
2.8 KiB
Python
"""LEGACY config.json model -- kept only so migration.py can import an
|
|
existing single-frame deployment's state into the database on first
|
|
boot. Nothing else should import this module; runtime state lives in
|
|
SQLite (see models.py/db.py).
|
|
|
|
The file at CONFIG_PATH is deliberately never modified or deleted by the
|
|
migration: it's the rollback path (redeploying the pre-database server
|
|
image picks it right back up).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel
|
|
|
|
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
|
|
|
|
|
|
class FrameStats(BaseModel):
|
|
first_seen: float = 0.0
|
|
device_wakes: int = 0
|
|
photos_displayed: int = 0
|
|
photos_removed: int = 0
|
|
battery_reports: int = 0
|
|
recharge_cycles: int = 0
|
|
ota_updates_applied: int = 0
|
|
config_saves: int = 0
|
|
|
|
|
|
class FrameConfig(BaseModel):
|
|
immich_url: str = ""
|
|
immich_api_key: str = ""
|
|
management_token: str = ""
|
|
album_id: str = ""
|
|
order: str = "sequential"
|
|
refresh_interval_s: int = 3600
|
|
quiet_hours_enabled: bool = False
|
|
quiet_hours_start: str = "22:00"
|
|
quiet_hours_end: str = "07:00"
|
|
timezone: str = "UTC"
|
|
smart_crop_faces: bool = True
|
|
orientation: str = "landscape"
|
|
|
|
current_asset_id: str = ""
|
|
current_asset_set_at: float = 0.0
|
|
queue: list[str] = []
|
|
queue_cursor: int = 0
|
|
queue_target_len: int = 20
|
|
history: list[str] = []
|
|
excluded_asset_ids: list[str] = []
|
|
|
|
battery_percent: int = -1
|
|
battery_as_of: float = 0.0
|
|
battery_history: list = []
|
|
battery_log: list = []
|
|
|
|
last_seen: float = 0.0
|
|
device_firmware_version: str = ""
|
|
device_board_variant: str = ""
|
|
firmware_available_version: str = ""
|
|
firmware_update_repo_url: str = ""
|
|
firmware_auto_update: bool = False
|
|
firmware_update_token: str = ""
|
|
firmware_update_checked_at: float = 0.0
|
|
firmware_gitea_latest_version: str = ""
|
|
|
|
stats: FrameStats = FrameStats()
|
|
|
|
|
|
def load() -> FrameConfig:
|
|
"""Reads the legacy file with the same env-override behavior the old
|
|
server applied on every load -- which is exactly how env-configured
|
|
IMMICH_URL/IMMICH_API_KEY get baked into the database at migration
|
|
time even though they were never written to the file itself."""
|
|
if not CONFIG_PATH.exists():
|
|
cfg = FrameConfig()
|
|
else:
|
|
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
|
|
|
|
env_url = os.environ.get("IMMICH_URL")
|
|
env_key = os.environ.get("IMMICH_API_KEY")
|
|
env_token = os.environ.get("MANAGEMENT_TOKEN")
|
|
env_gitea_token = os.environ.get("GITEA_FIRMWARE_TOKEN")
|
|
if env_url:
|
|
cfg.immich_url = env_url
|
|
if env_key:
|
|
cfg.immich_api_key = env_key
|
|
if env_token:
|
|
cfg.management_token = env_token
|
|
if env_gitea_token:
|
|
cfg.firmware_update_token = env_gitea_token
|
|
|
|
return cfg
|