Files
espresso_frame/server/app/config.py
T
tfaour 9fbbb8ed2b 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.
2026-07-21 23:21:38 -04:00

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