Files
espresso_frame/server/app/config.py
T
tfaour 7ea5c9a4fb
Build and push server image / build-and-push (push) Successful in 37s
Consolidate firmware UI; server learns board from the device, not a picker
All firmware-related controls (manual upload, Gitea repo URL,
auto-update checkbox, detected board, Update frame button) now live in
one "Firmware update" card instead of being split across the main
Settings form and a separate card.

The board variant used to pick a Gitea release asset was a dropdown
the user had to set by hand and could get wrong. The device now
reports it itself via a new X-Frame-Board header (CONFIG_FRAME_BOARD_NAME,
"devkit" by default, "xiao" in sdkconfig.xiao) on every /frame/config
poll, stored as device_board_variant -- the server learns it instead.
Update checks/applies are gated on the board being known, since there's
nothing to fetch until a device has checked in at least once.
2026-07-21 21:58:59 -04:00

168 lines
7.7 KiB
Python

"""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-<board_variant>.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