"""JSON-file-backed config: Immich connection, selected album, and cursor state (which photo /frame/image serves next).""" from __future__ import annotations import json import os from contextlib import contextmanager from pathlib import Path from threading import RLock from typing import Iterator from pydantic import BaseModel CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json")) # Reentrant so load()/save() can each take it internally for their own I/O # while a caller also holds it for a whole locked() span (see below). _lock = RLock() class FrameConfig(BaseModel): immich_url: str = "" immich_api_key: str = "" management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN album_id: str = "" order: str = "sequential" # or "shuffle" refresh_interval_s: int = 3600 smart_crop_faces: bool = True # How the physical frame is hung: landscape (native), portrait, # landscape_flipped, portrait_flipped. Purely a server-side render # decision -- the device always receives native 800x480 bytes. orientation: str = "landscape" # Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at # is what lets the server decide "has it been long enough to advance" on its # own clock, independent of how/why the device asked for a photo. current_asset_id: str = "" current_asset_set_at: float = 0.0 queue: list[str] = [] queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI history: list[str] = [] # bounded stack of previously-current asset ids, most recent last excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich) # Last battery report from the device (POST /frame/battery); -1 = never # reported / not battery-powered. battery_as_of mirrors the # current_asset_set_at timestamp pattern. battery_percent: int = -1 battery_as_of: float = 0.0 # [timestamp, percent] pairs for the CURRENT discharge cycle only -- # reset whenever a report jumps up enough to indicate a recharge (see # main.py). Feeds the "on battery for" and "estimated remaining" # numbers in the web UI's Device panel. battery_history: list = [] # Device liveness/telemetry: last_seen is touched by every /frame/* # request; device_firmware_version comes from the X-Frame-Version # header the device sends with its config poll. last_seen: float = 0.0 device_firmware_version: str = "" # Version parsed out of the most recently uploaded OTA image # (POST /api/firmware); "" = none uploaded yet. firmware_available_version: str = "" def load() -> FrameConfig: with _lock: if not CONFIG_PATH.exists(): cfg = FrameConfig() else: cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text())) # IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN set in the environment # (e.g. docker-compose.yml, see docker-compose.yml.example) take # precedence over whatever's saved in CONFIG_PATH, so credentials never # need to go through the web UI. env_url = os.environ.get("IMMICH_URL") env_key = os.environ.get("IMMICH_API_KEY") env_token = os.environ.get("MANAGEMENT_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 return cfg def save(cfg: FrameConfig) -> None: with _lock: CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.write_text(cfg.model_dump_json(indent=2)) @contextmanager def locked() -> Iterator[None]: """Serializes an entire load-mutate-save cycle. load()/save() each only lock their own I/O, which isn't enough by itself: uvicorn dispatches sync routes to a thread pool, so two concurrent requests (e.g. the device's own poll landing alongside a web UI edit) can each load() the same on-disk state and the second save() silently clobber the first's changes. Route handlers that mutate config should wrap their whole load/mutate/save span in this.""" with _lock: yield