Build and push server image / build-and-push (push) Successful in 36s
Purely a server-side decision: GET /frame/config hands back a longer refresh_interval_s while quiet hours are in effect (exactly the seconds until they end), and clamps the normal interval so the device's next wake lands at the boundary instead of wandering into the window, when outside it but approaching. A device already mid-sleep when quiet hours begin can still land one wake inside the window -- unavoidable without touching the firmware, since it has no wall-clock awareness -- but from that wake on it sleeps straight through to the end. Window is "HH:MM"-"HH:MM", wrap-past-midnight aware (e.g. 22:00-07:00), in the server's local timezone -- added tzdata to the Dockerfile since python:3.12-slim doesn't include it and TZ would otherwise silently resolve to nothing and fall back to UTC. Also fixed the "overdue" device-status check to account for quiet hours: without this it would falsely flag a device sleeping through a long quiet window as unreachable.
124 lines
5.1 KiB
Python
124 lines
5.1 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 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" in the server's local
|
|
# timezone (see docker-compose.yml.example's TZ note) 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"
|
|
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 = ""
|
|
|
|
|
|
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
|