"""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 FrameStats(BaseModel): """Cumulative, lifetime counters -- purely informational, never read back to drive any behavior, so there's no harm in them being a little approximate at the edges. Shown in a collapsed "Stats" section in the web UI (GET /api/stats). Never reset except by deleting config.json.""" first_seen: float = 0.0 # first time this frame ever checked in device_wakes: int = 0 # wake cycles, counted once each via GET /frame/config photos_displayed: int = 0 # times the current photo actually changed (any cause) photos_removed: int = 0 # times a photo was permanently excluded from rotation battery_reports: int = 0 # POST /frame/battery calls recharge_cycles: int = 0 # times a battery recharge was detected ota_updates_applied: int = 0 # times the device's reported firmware version changed config_saves: int = 0 # POST /api/config calls 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 # Quiet hours: no point waking the device overnight just to swap a # photo nobody's looking at. Times are "HH:MM" interpreted in # `timezone` below and may wrap past midnight (e.g. start=22:00, # end=07:00). Purely a server-side decision -- the device is unaware, # it just gets told a longer refresh_interval_s by GET /frame/config # while quiet hours are in effect (see main.py's # _effective_refresh_interval_s). quiet_hours_enabled: bool = False quiet_hours_start: str = "22:00" quiet_hours_end: str = "07:00" # IANA zone name (e.g. "America/New_York") quiet_hours_start/end are # interpreted in. Set from the web UI rather than the container's TZ # environment variable, so it survives container recreation and # doesn't need a docker-compose.yml edit to change. timezone: str = "UTC" 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 = [] # Every report ever received, never reset by a recharge -- the # permanent record behind the web UI's battery history graph. Capped # generously (not a real limit at realistic report rates, just a # safety bound), unlike battery_history above which is deliberately # scoped to one cycle. battery_log: list = [] # Device liveness/telemetry: last_seen is touched by every /frame/* # request; device_firmware_version/device_board_variant come from the # X-Frame-Version/X-Frame-Board headers the device sends with its # config poll (CONFIG_FRAME_BOARD_NAME on the firmware side). last_seen: float = 0.0 device_firmware_version: str = "" device_board_variant: str = "" # "" until a device has ever checked in # Version parsed out of the most recently uploaded OTA image # (POST /api/firmware); "" = none uploaded yet. firmware_available_version: str = "" # Gitea-hosted firmware auto-update (see app/gitea_releases.py). # repo_url empty = feature off, no Gitea calls made at all. Which # release asset to pull is learned from the device itself # (device_board_variant below, from its X-Frame-Board header) rather # than picked by the user -- must match one of the names # .gitea/workflows/firmware-release-build.yml publishes # (firmware-.bin). firmware_update_repo_url: str = "" # e.g. "https://git.example.com/owner/repo" firmware_auto_update: bool = False # pull+stage a newer release with no button click # Optional Gitea PAT (read-only access is enough) for a private repo's # releases; blank is fine for a public repo. GITEA_FIRMWARE_TOKEN env # var overrides, mirroring MANAGEMENT_TOKEN below -- never exposed to # the web UI template or any JSON response. firmware_update_token: str = "" firmware_update_checked_at: float = 0.0 # throttle bookkeeping, see gitea_releases.UPDATE_CHECK_INTERVAL_S firmware_gitea_latest_version: str = "" # latest release's version, from its tag name stats: FrameStats = FrameStats() 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/GITEA_FIRMWARE_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") 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 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