Build and push server image / build-and-push (push) Successful in 39s
New Gitea Actions workflow builds both board variants and publishes them as release assets whenever firmware/version.txt is bumped. The server can now poll that repo's releases (next to the existing manual upload) and either surface an "Update frame" button or, with "Automatically apply updates" checked, stage the new build itself -- the frame still only updates on its own next wake either way.
165 lines
7.5 KiB
Python
165 lines
7.5 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 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 = ""
|
|
|
|
# Gitea-hosted firmware auto-update (see app/gitea_releases.py).
|
|
# repo_url empty = feature off, no Gitea calls made at all.
|
|
# board_variant picks which release asset to pull -- 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_board_variant: str = "xiao" # "xiao" or "devkit"
|
|
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
|