Redesign phase A: SQLite storage, per-frame data model, device identity

Replaces the single global config.json (whole-file pydantic model under
one RLock) with SQLite via SQLAlchemy 2.0: users/sessions/frames/links/
pending-claims/battery_log tables (models.py), a per-frame lock registry
(db.frame_locked) succeeding config.locked(), and hand-rolled schema
versioning (migration.py). A pre-database deployment's config.json is
imported verbatim as frame #1 on first boot and left untouched as the
rollback path; the old single firmware.bin slot becomes per-frame
firmware/<id>.bin.

Routes split out of the 900-line main.py into routers/device.py (the
frozen /frame/* protocol) and routers/api.py (web UI, still on the old
single-frame paths for now). Device auth moves to require_device, which
already speaks the full multi-frame protocol: per-frame device tokens
pushed via /frame/config and acknowledged on first use, self-
registration of unknown device ids as unclaimed frames, pending-claim
attachment, and the legacy-token migration window that keeps the
currently-deployed firmware (no id, shared MANAGEMENT_TOKEN) resolving
to frame #1 -- including the one-time binding of its device id when it
first reports one after a future OTA.

Externally identical for existing deployments: same paths, same token
semantics, same response shapes -- verified with a migration fixture,
the legacy-device curl suite, a 20-way concurrent-advance smoke test,
and a mutate-restart-assert persistence check against a fake Immich.

photo_queue.py ports nearly verbatim onto the Frame ORM row (MutableList
JSON columns make its in-place list mutations dirty-track); quiet-hours
math extracted unchanged into quiet_hours.py.
This commit is contained in:
2026-07-21 23:21:38 -04:00
parent 6a0072e383
commit 9fbbb8ed2b
16 changed files with 1650 additions and 1013 deletions
+8 -5
View File
@@ -223,11 +223,14 @@ algorithm itself -- it just streams the response straight to the panel.
## Notes
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
stored in `./data/config.json` on the host via the compose volume
mount. Immich URL/API key are too if set via the web UI, but
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
precedence when present.
- All state (settings, current photo, upcoming queue, battery history,
stats) lives in a SQLite database at `./data/espresso.db` on the host
via the compose volume mount (`DATABASE_URL` env var to override --
any SQLAlchemy URL works, so a future move to Postgres is a config
change). A pre-database deployment's `./data/config.json` is imported
automatically on first boot (it becomes frame #1) and left untouched
afterwards as the rollback path. `IMMICH_URL`/`IMMICH_API_KEY` env
vars (see Setup above) still take precedence when present.
- The upcoming queue is a bounded lookahead, not the whole album --
"Upcoming photos to show" in the config UI (`queue_target_len`, 5-50,
default 20) controls its size and takes effect immediately (the queue
+142
View File
@@ -0,0 +1,142 @@
"""Authentication dependencies.
Phase A scope: browser routes keep the legacy shared-token gate
(MANAGEMENT_TOKEN env var -- empty means open on a trusted LAN, exactly
the old behavior), and device routes move to require_device, which
already implements the full multi-frame resolution: per-frame device
tokens, self-registration by device id, the legacy-token migration
window, and pending-claim attachment. User sessions arrive in Phase B.
"""
from __future__ import annotations
import logging
import os
import time
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from .db import get_db
from .migration import new_device_token, new_manage_token
from .models import Frame, PendingClaim, UserFrame
logger = logging.getLogger(__name__)
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old
server, where the env var overrode anything on disk on every load."""
return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool:
"""No MANAGEMENT_TOKEN configured means the web UI stays open on a
trusted LAN, matching this project's original default. Once one's
set, a request is authorized by either a ?token= query param or the
cookie index() sets after a valid query-param hit (so the web UI's
own fetch()/<img> calls, which carry no query string, stay authorized
for the rest of that browsing visit)."""
token = management_token()
if not token:
return True
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == token
def require_access_token(request: Request) -> None:
"""Dependency for the browser-facing /api/* routes (Phase A only --
replaced by real sessions in Phase B). index() handles the
unauthorized case itself with a friendlier HTML prompt."""
if not browser_token_valid(request):
raise HTTPException(401, "Missing or invalid access token")
def _register_frame(db: Session, device_id: str) -> Frame:
"""A device id we've never seen: self-register it as an unclaimed
frame (this fires from ANY /frame/* route -- the wake cycle hits
/frame/image before /frame/config). If a user already submitted a
claim for this id (they beat the device to the server after
provisioning), attach it now."""
frame = Frame(
name=f"Frame {device_id[-6:]}",
device_id=device_id,
device_token=new_device_token(),
manage_token=new_manage_token(),
created_at=time.time(),
)
db.add(frame)
db.flush()
now = time.time()
# Opportunistically prune expired claims while we're here.
for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
db.delete(stale)
pending = db.get(PendingClaim, device_id)
if pending is not None and pending.expires_at >= now:
frame.owner_user_id = pending.user_id
frame.claimed_at = now
db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
db.delete(pending)
logger.info("Frame %s self-registered and attached pending claim by user %d",
device_id, pending.user_id)
else:
logger.info("Frame %s self-registered (unclaimed)", device_id)
return frame
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request.
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
(or nothing, on an open server) -- those requests resolve to the
unique legacy_token_enabled frame for as long as that migration
window stays open. The first id-bearing request arriving with legacy
credentials while the legacy frame has no device_id yet BINDS that id
to it -- that's the moment the deployed frame comes back up on new
firmware after its OTA, and it must not register as a second frame.
"""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
legacy = management_token()
legacy_ok = not legacy or token == legacy
if device_id:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
legacy_frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
legacy_frame.device_id = device_id
frame = legacy_frame
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
else:
frame = _register_frame(db, device_id)
else:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok and not (frame.legacy_token_enabled and legacy_ok):
raise HTTPException(401, "Missing or invalid access token")
else:
if not legacy_ok:
raise HTTPException(401, "Missing or invalid access token")
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
# Nothing to resolve a no-id request to. migration.py always
# creates frame #1 at startup, so this only happens if it was
# deleted -- treat like an unknown device.
raise HTTPException(401, "No frame accepts legacy credentials")
frame.last_seen = time.time()
db.commit()
return frame
+36 -107
View File
@@ -1,137 +1,85 @@
"""JSON-file-backed config: Immich connection, selected album, and cursor
state (which photo /frame/image serves next)."""
"""LEGACY config.json model -- kept only so migration.py can import an
existing single-frame deployment's state into the database on first
boot. Nothing else should import this module; runtime state lives in
SQLite (see models.py/db.py).
The file at CONFIG_PATH is deliberately never modified or deleted by the
migration: it's the rollback path (redeploying the pre-database server
image picks it right back up).
"""
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
first_seen: float = 0.0
device_wakes: int = 0
photos_displayed: int = 0
photos_removed: int = 0
battery_reports: int = 0
recharge_cycles: int = 0
ota_updates_applied: int = 0
config_saves: int = 0
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
management_token: str = ""
album_id: str = ""
order: str = "sequential" # or "shuffle"
order: str = "sequential"
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)
queue_cursor: int = 0
queue_target_len: int = 20
history: list[str] = []
excluded_asset_ids: list[str] = []
# 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.
device_board_variant: str = ""
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_repo_url: str = ""
firmware_auto_update: bool = False
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
firmware_update_checked_at: float = 0.0
firmware_gitea_latest_version: str = ""
stats: FrameStats = FrameStats()
def load() -> FrameConfig:
with _lock:
if not CONFIG_PATH.exists():
cfg = FrameConfig()
else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
"""Reads the legacy file with the same env-override behavior the old
server applied on every load -- which is exactly how env-configured
IMMICH_URL/IMMICH_API_KEY get baked into the database at migration
time even though they were never written to the file itself."""
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")
@@ -146,22 +94,3 @@ def load() -> FrameConfig:
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
+88
View File
@@ -0,0 +1,88 @@
"""Engine, sessions, and the per-frame lock that replaces the old
whole-config.json RLock.
Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
the threadpool, so this is ordinary multi-threading in one process: the
same regime the old config.locked() RLock handled, now scoped per frame.
"""
from __future__ import annotations
import os
import threading
from contextlib import contextmanager
from typing import Iterator
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from .models import Frame
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
_is_sqlite = DATABASE_URL.startswith("sqlite")
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if _is_sqlite else {},
)
if _is_sqlite:
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_connection, _record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
# expire_on_commit=False so a Frame resolved by the require_device
# dependency (which commits its last_seen touch) stays usable in the
# route handler without a re-select per attribute. Freshness inside
# mutation spans is handled explicitly by frame_locked()'s refresh.
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Iterator[Session]:
"""FastAPI dependency: one session per request (FastAPI caches the
dependency, so require_device and the route handler share it)."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# One lock per frame id, created on demand. Guarded by a module lock so
# two threads can't race to create different Lock objects for the same
# frame (which would defeat the whole point).
_frame_locks: dict[int, threading.Lock] = {}
_frame_locks_guard = threading.Lock()
def _get_lock(frame_id: int) -> threading.Lock:
with _frame_locks_guard:
lock = _frame_locks.get(frame_id)
if lock is None:
lock = threading.Lock()
_frame_locks[frame_id] = lock
return lock
@contextmanager
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
"""Serializes a whole read-modify-write span on one frame -- the
direct successor of the old config.locked(). The refresh() inside the
lock is what makes it correct: without it the session could hold
attribute state read *before* another thread's committed write, and
saving would silently clobber it (the same lost-update race the old
pattern's 're-read inside the lock' comment guarded against)."""
with _get_lock(frame_id):
frame = db.get(Frame, frame_id)
if frame is None:
raise LookupError(f"Frame {frame_id} does not exist")
db.refresh(frame)
yield frame
db.commit()
+7 -6
View File
@@ -1,7 +1,8 @@
"""Local firmware image storage + esp_app_desc_t parsing. Shared by the
manual upload path (POST /api/firmware) and the Gitea auto-update path
(see gitea_releases.py) -- both end up writing the same firmware.bin slot
that GET /frame/firmware streams to the device."""
"""Per-frame firmware image storage + esp_app_desc_t parsing. Shared by
the manual upload path and the Gitea auto-update path -- both end up
writing the same per-frame slot that GET /frame/firmware streams to the
device. The migration moves the old single /data/firmware.bin into frame
#1's slot."""
from __future__ import annotations
@@ -18,8 +19,8 @@ APP_DESC_MAGIC = 0xABCD5432
EXPECTED_PROJECT_NAME = "espresso_frame"
def firmware_path():
return config.CONFIG_PATH.parent / "firmware.bin"
def firmware_path(frame_id: int):
return config.CONFIG_PATH.parent / "firmware" / f"{frame_id}.bin"
def parse_app_version(data: bytes) -> str:
+39 -882
View File
@@ -1,199 +1,36 @@
"""ESPresso Frame server: pulls photos from Immich, pre-processes them for
the panel, and serves the ESP32 a ready-to-display frame."""
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/ (device.py
for the firmware-facing /frame/* protocol, api.py for the web UI's
/api/*), storage in SQLite via models.py/db.py, with migration.py
importing a pre-database config.json deployment on first boot."""
from __future__ import annotations
import io
import logging
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from PIL import Image
from pydantic import BaseModel
from . import config, gitea_releases, photo_queue
from .face_labels import compute_face_labels
from .firmware import firmware_path, parse_app_version
from .image_pipeline import render_frame
from .immich_client import ImmichClient
from . import migration
from .auth import MANAGEMENT_TOKEN_COOKIE, browser_token_valid, management_token
from .db import SessionLocal
from .quiet_hours import ALL_TIMEZONES
from .routers import api, device
from .routers.common import default_frame, immich_creds
logger = logging.getLogger(__name__)
# Schema + legacy-config import, before the first request is served.
migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
# Populated once from the OS's zoneinfo database (installed via the
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
# web UI's "Timezone" field -- see api_config_save/index below.
ALL_TIMEZONES = sorted(available_timezones())
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- see FrameConfig.battery_log
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
OVERDUE_FACTOR = 1.5
def _valid_hhmm(s: str) -> bool:
try:
datetime.strptime(s, "%H:%M")
return True
except ValueError:
return False
def _zoneinfo(name: str) -> ZoneInfo:
"""Falls back to UTC for an unrecognized zone name -- defensive only;
api_config_save already validates against ALL_TIMEZONES before saving,
so this only matters for a config.json hand-edited or written by an
older version of this file."""
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("UTC")
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
(False, None) for a degenerate window (start == end)."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
if start == end:
return False, None
if start < end:
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
# exactly at `end` counts as already outside the window.
if start <= now < end:
return True, end
if now < start:
return False, start
return False, start + timedelta(days=1)
# Wraps midnight, e.g. 22:00-07:00.
if now >= start:
return True, end + timedelta(days=1)
if now < end:
return True, end
return False, start
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
"""Duration of the quiet-hours window in seconds, wrap-aware."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
return span_min * 60
def _effective_refresh_interval_s(cfg: config.FrameConfig) -> int:
"""The refresh interval actually handed to the device: its configured
value, unless quiet hours are enabled, in which case it's clamped so
the device sleeps through the whole window instead of waking inside
it. A device already mid-sleep when quiet hours begin can still land
one wake inside the window (nothing server-side can prevent that
without touching the firmware) -- but from that wake on, it's told to
sleep exactly until the window ends."""
if not cfg.quiet_hours_enabled:
return cfg.refresh_interval_s
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
if boundary is None:
return cfg.refresh_interval_s
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
if in_quiet:
return seconds_to_boundary
return min(cfg.refresh_interval_s, seconds_to_boundary)
def _in_quiet_hours(cfg: config.FrameConfig) -> bool:
"""Whether quiet hours are in effect right now -- separate from
_effective_refresh_interval_s, which only shapes what the *device* is
told to sleep for. This instead gates photo_queue.get_current()'s
time-based advance, since that check runs independent of the device
(also triggered by the web UI's /api/queue, e.g. an open browser tab
polling overnight) and would otherwise happily advance the current
photo mid-quiet-hours on raw elapsed time alone."""
if not cfg.quiet_hours_enabled:
return False
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
return in_quiet
def _max_expected_gap_s(cfg: config.FrameConfig) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
gap much longer, and the "overdue" check (see api_queue) shouldn't
mistake a device quietly sleeping through the night for a dead one."""
gap = cfg.refresh_interval_s
if cfg.quiet_hours_enabled:
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
return gap
def _touch_last_seen() -> None:
"""Records that the device just made contact. Called by every
/frame/* route -- a handful of extra config writes per wake cycle,
which is nothing at hourly wakes."""
with config.locked():
cfg = config.load()
cfg.last_seen = time.time()
config.save(cfg)
def _token_valid(request: Request, cfg: config.FrameConfig) -> bool:
"""No management_token configured (MANAGEMENT_TOKEN env var, see
docker-compose.yml.example) means the whole server stays open on a
trusted LAN, matching this project's original default. Once one's
set, a request is authorized by either a ?token= query param (what
the ESP32 sends on every device request, and what the manage-menu/
share QR codes embed for a human scanning them) or the cookie
index() sets after a valid query-param hit (so the web UI's own
fetch()/<img> calls, which carry no query string, stay authorized
for the rest of that browsing visit)."""
if not cfg.management_token:
return True
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == cfg.management_token
def require_access_token(request: Request) -> None:
"""Dependency for every route except / and /health: the web UI's
/api/* and every device-facing /frame/*. index() handles the
unauthorized case itself (a friendlier HTML prompt, not a bare 401)
since that's the one route a human is actually meant to land on with
no token yet; the ESP32 sends its token as ?token= on every request
it makes (see frame_client.c's build_url()), so device endpoints
just 401 outright on a missing/wrong one. /health stays open -- it
reveals nothing but process liveness, and gating it would break
plain infra/uptime monitoring for no real security benefit."""
if not _token_valid(request, config.load()):
raise HTTPException(401, "Missing or invalid access token")
app.include_router(device.router)
app.include_router(api.router)
@app.get("/health")
@@ -201,53 +38,33 @@ def health() -> dict:
return {"status": "ok"}
@app.get("/frame/config", dependencies=[Depends(require_access_token)])
def frame_config(request: Request):
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings
(defaults if nothing's been saved yet) -- no Immich-configured gate,
since this doubles as the "is the server up" signal. Also captures
the device's running firmware version and board variant (X-Frame-
Version/X-Frame-Board headers -- the latter is how the Gitea
auto-update feature learns which release asset to fetch, instead of
a user picking it in the web UI) and advertises the uploaded OTA
image's version, so the device's update check costs zero extra
round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with config.locked():
cfg = config.load()
cfg.last_seen = time.time()
if cfg.stats.first_seen == 0:
cfg.stats.first_seen = cfg.last_seen
cfg.stats.device_wakes += 1
if reported_version:
if cfg.device_firmware_version and reported_version != cfg.device_firmware_version:
cfg.stats.ota_updates_applied += 1
cfg.device_firmware_version = reported_version
if reported_board:
cfg.device_board_variant = reported_board
config.save(cfg)
return {
"refresh_interval_s": _effective_refresh_interval_s(cfg),
"firmware_version": cfg.firmware_available_version or None,
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
cfg = config.load()
if not _token_valid(request, cfg):
"""The web UI (Phase A: still the single-frame page, bound to the
default frame). Handles the unauthorized case itself with a friendly
token prompt rather than a bare 401, since this is the one route a
human lands on with no token yet."""
if not browser_token_valid(request):
supplied = request.query_params.get("token")
return templates.TemplateResponse(
"token_prompt.html", {"request": request, "wrong": supplied is not None}
)
response = templates.TemplateResponse(
"index.html", {"request": request, "cfg": cfg, "timezones": ALL_TIMEZONES}
)
with SessionLocal() as db:
frame = default_frame(db)
immich_url, _ = immich_creds(frame)
response = templates.TemplateResponse(
"index.html",
{
"request": request,
"cfg": frame,
"immich_url": immich_url,
"timezones": ALL_TIMEZONES,
},
)
supplied = request.query_params.get("token")
if cfg.management_token and supplied == cfg.management_token:
if management_token() and supplied == management_token():
# Query-param access (typically the manage-menu QR code) earns a
# cookie so the rest of this visit's fetch()/<img> calls -- which
# never carry the query string -- stay authorized too.
@@ -255,663 +72,3 @@ def index(request: Request):
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
)
return response
@app.get("/api/albums", dependencies=[Depends(require_access_token)])
def api_albums():
cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
try:
albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@app.post("/api/config", dependencies=[Depends(require_access_token)])
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
quiet_hours_enabled: bool = Form(False),
quiet_hours_start: str = Form("22:00"),
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_auto_update: bool = Form(False),
):
# Immich URL/API key/Gitea token are env-var only (IMMICH_URL/
# IMMICH_API_KEY/GITEA_FIRMWARE_TOKEN, see docker-compose.yml.example)
# -- config.load() already applies them, and this handler doesn't touch
# cfg.immich_url/immich_api_key/firmware_update_token at all, so
# there's nothing here that could overwrite or clear them.
with config.locked():
cfg = config.load()
if album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo and
# queue don't mean anything in the new album's context.
cfg.current_asset_id = ""
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
cfg.quiet_hours_enabled = quiet_hours_enabled
if _valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if _valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone in ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_auto_update = firmware_auto_update
cfg.stats.config_saves += 1
config.save(cfg)
return {"status": "saved"}
@app.get("/api/stats", dependencies=[Depends(require_access_token)])
def api_stats():
return config.load().stats.model_dump()
def _require_configured(cfg: config.FrameConfig) -> None:
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not cfg.album_id:
raise HTTPException(400, "No album configured yet")
def _list_assets(client: ImmichClient, cfg: config.FrameConfig) -> list[dict]:
try:
assets = client.list_album_assets(cfg.album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
if not assets:
raise HTTPException(404, "Album has no photos")
return assets
def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes:
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
faces = None
if cfg.smart_crop_faces:
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
# A faces lookup hiccup shouldn't block showing a photo at
# all -- just fall back to a plain center-crop this cycle.
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
source = Image.open(io.BytesIO(jpeg_bytes))
return render_frame(source, faces=faces, orientation=cfg.orientation)
@app.get("/frame/image", dependencies=[Depends(require_access_token)])
def frame_image():
"""Returns the current photo. Idempotent: only actually advances to
the next photo once refresh_interval_s has elapsed since the current
one was set (see app/photo_queue.py) -- safe to call as often as the
device wants, including after an unplanned reboot, without skipping
ahead in the album."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
config.save(cfg)
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
@app.post("/frame/advance", dependencies=[Depends(require_access_token)])
def frame_advance():
"""Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by
the device's next-photo button."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
photo_queue.advance_forced(cfg, assets)
config.save(cfg)
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
@app.post("/frame/back", dependencies=[Depends(require_access_token)])
def frame_back():
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
photo_queue.back_forced(cfg, assets)
config.save(cfg)
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@app.post("/frame/battery", dependencies=[Depends(require_access_token)])
def frame_battery(body: BatteryReport):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp plus a per-discharge-
cycle history that feeds the Device panel's "on battery for" and
"estimated remaining" numbers."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
now = time.time()
with config.locked():
cfg = config.load()
cfg.stats.battery_reports += 1
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge.
cfg.battery_history = []
cfg.stats.recharge_cycles += 1
cfg.battery_history.append([now, body.percent])
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
cfg.battery_log.append([now, body.percent])
cfg.battery_log = cfg.battery_log[-BATTERY_LOG_MAX:]
cfg.battery_percent = body.percent
cfg.battery_as_of = now
cfg.last_seen = now
config.save(cfg)
return {"status": "saved"}
@app.post("/api/firmware", dependencies=[Depends(require_access_token)])
def api_firmware_upload(file: UploadFile = File(...)):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
data = file.file.read()
version = parse_app_version(data)
firmware_path().write_bytes(data)
with config.locked():
cfg = config.load()
cfg.firmware_available_version = version
config.save(cfg)
return {"status": "saved", "version": version, "size": len(data)}
@app.get("/frame/firmware", dependencies=[Depends(require_access_token)])
def frame_firmware():
"""The uploaded OTA image, streamed to the device (esp_https_ota).
404 until something has been uploaded."""
_touch_last_seen()
path = firmware_path()
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
def _fetch_latest_release(cfg: config.FrameConfig) -> dict | None:
try:
return gitea_releases.fetch_latest_release(cfg.firmware_update_repo_url, cfg.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Gitea at {cfg.firmware_update_repo_url}: {e}") from e
def _apply_gitea_update(cfg: config.FrameConfig) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual
POST /api/firmware upload would. The board comes from the device
itself (device_board_variant, learned from its X-Frame-Board header
on GET /frame/config -- see frame_config()), not a user picker, so
there's nothing to fetch until a device has checked in at least
once. Network I/O happens before the lock is taken, matching the
load/mutate/save concurrency pattern used elsewhere (see
config.locked())."""
if not cfg.device_board_variant:
raise HTTPException(400, "No frame has checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(cfg)
if not release:
raise HTTPException(404, "No releases found in the configured Gitea repo")
asset_name = gitea_releases.asset_name_for_board(cfg.device_board_variant)
asset_url = release["assets"].get(asset_name)
if not asset_url:
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
try:
data = gitea_releases.download_asset(asset_url, cfg.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
version = parse_app_version(data) # same validation the manual upload path applies
firmware_path().write_bytes(data)
with config.locked():
cfg = config.load()
cfg.firmware_available_version = version
cfg.firmware_gitea_latest_version = version
cfg.firmware_update_checked_at = time.time()
config.save(cfg)
return version
@app.get("/api/firmware/check", dependencies=[Depends(require_access_token)])
def api_firmware_check(force: bool = False):
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button.
Applying (auto or manual) needs to know the frame's board, which is
learned from the device's own X-Frame-Board header rather than
picked by the user -- update_available stays false until a device
has checked in at least once, regardless of what Gitea has.
force=true (the "Check now" button) bypasses the throttle and always
hits Gitea -- otherwise a genuinely new release can sit invisible in
the UI for up to the full throttle interval even though it's already
live, since the passive poll won't look again until then."""
cfg = config.load()
if not cfg.firmware_update_repo_url:
return {"enabled": False}
now = time.time()
if force or now - cfg.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
# Deliberately not updated on failure (see below) -- checked_at only
# advances on a successful reach, so a Gitea outage gets retried
# every poll instead of waiting out the full throttle interval.
release = _fetch_latest_release(cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
cfg.firmware_update_checked_at = now
if release:
cfg.firmware_gitea_latest_version = release["version"]
config.save(cfg)
cfg = config.load()
update_available = (
bool(cfg.firmware_gitea_latest_version)
and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
and bool(cfg.device_board_variant)
)
if update_available and cfg.firmware_auto_update:
_apply_gitea_update(cfg)
cfg = config.load()
update_available = False
return {
"enabled": True,
"board": cfg.device_board_variant or None,
"latest_version": cfg.firmware_gitea_latest_version or None,
"staged_version": cfg.firmware_available_version or None,
"update_available": update_available,
}
@app.post("/api/firmware/apply-latest", dependencies=[Depends(require_access_token)])
def api_firmware_apply_latest():
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- this is an explicit user action,
not a background poll."""
cfg = config.load()
if not cfg.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(cfg)
return {"status": "saved", "version": version}
def _battery_estimate_s(cfg: config.FrameConfig) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
about (too little time observed, or too little drop -- a flat line
extrapolates to garbage)."""
hist = cfg.battery_history
if len(hist) < 2:
return None
first_ts, first_pct = hist[0]
last_ts, last_pct = hist[-1]
span = last_ts - first_ts
drop = first_pct - last_pct
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
return None
rate = drop / span # percent per second
return int(last_pct / rate)
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@app.get("/frame/photo-info", dependencies=[Depends(require_access_token)])
def frame_photo_info():
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
config.save(cfg)
if not cfg.current_asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(cfg.current_asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
location = _format_location(exif)
return {
"asset_id": cfg.current_asset_id,
"location_line1": location[0] if location else None,
"location_line2": location[1] if location and location[1] else None,
"taken_at": _format_taken_at(exif),
}
@app.get("/frame/share/{asset_id}", dependencies=[Depends(require_access_token)])
def frame_share(asset_id: str):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to (the firmware bakes ?token= into that QR the same way it
does for the management QR, see frame_client.c's build_url()). The
link is created lazily, when this actually gets hit (i.e. when
someone scans it), not when the manage button was pressed, so the
30-minute window starts when it's actually used. Also scoped to the
photo currently showing or queued -- not any arbitrary Immich asset
id -- as a second layer even a leaked token wouldn't bypass."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
if asset_id != cfg.current_asset_id and asset_id not in cfg.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@app.get("/frame/face-labels", dependencies=[Depends(require_access_token)])
def frame_face_labels():
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
recognition (no detection/recognition happens here, see
app/face_labels.py). Response is a flattened, fixed-slot shape
(name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a JSON array, so
the device's hand-rolled parser can read it with the same flat-
scalar helpers it already has, instead of needing a real array
parser. Empty (count: 0) if no faces are named, or if anything about
fetching them fails -- this is a "nice to have" addition to the
overlay, not worth failing the whole menu over."""
_touch_last_seen()
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
config.save(cfg)
if not cfg.current_asset_id:
return {"count": 0}
try:
faces = client.get_asset_faces(cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch faces for asset %s: %s", cfg.current_asset_id, e)
return {"count": 0}
if not any((face.get("person") or {}).get("name") for face in faces):
return {"count": 0} # skip the extra preview download in the common no-named-faces case
try:
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", cfg.current_asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces, cfg.orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
result[f"name_{i}"] = label["name"]
result[f"x_{i}"] = label["x"]
result[f"y_{i}"] = label["y"]
return result
@app.get("/api/queue", dependencies=[Depends(require_access_token)])
def api_queue():
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
current_changed = photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg))
queue_before = list(cfg.queue)
photo_queue.sync_queue_length(cfg, assets)
if current_changed or cfg.queue != queue_before:
config.save(cfg)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
"device": {
"last_seen": cfg.last_seen or None,
"overdue": bool(cfg.last_seen and now - cfg.last_seen > _max_expected_gap_s(cfg) * OVERDUE_FACTOR),
"firmware_version": cfg.device_firmware_version or None,
"firmware_available": cfg.firmware_available_version or None,
"battery": (
{"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
if cfg.battery_percent >= 0
else None
),
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
"battery_estimate_s": _battery_estimate_s(cfg),
},
}
@app.get("/api/battery-log", dependencies=[Depends(require_access_token)])
def api_battery_log():
cfg = config.load()
return {"log": cfg.battery_log}
class QueueReorderRequest(BaseModel):
queue: list[str]
@app.post("/api/queue/reorder", dependencies=[Depends(require_access_token)])
def api_queue_reorder(body: QueueReorderRequest):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
with config.locked():
cfg = config.load()
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
config.save(cfg)
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@app.post("/api/queue/promote", dependencies=[Depends(require_access_token)])
def api_queue_promote(body: QueuePromoteRequest):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
with config.locked():
cfg = config.load()
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
config.save(cfg)
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@app.post("/api/queue/remove", dependencies=[Depends(require_access_token)])
def api_queue_remove(body: QueueRemoveRequest):
"""Permanently removes a photo from this frame's rotation -- "Remove"
in the web UI, on either an upcoming card or the current photo. Does
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
with config.locked():
cfg = config.load() # re-read: state may have changed since the unlocked read above
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
config.save(cfg)
return {"status": "removed"}
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)])
def api_photo_thumbnail(asset_id: str):
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
try:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
+146
View File
@@ -0,0 +1,146 @@
"""Schema versioning + one-time import of a legacy config.json deployment.
Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
just create_all. DDL stays dialect-neutral so a future move to Postgres
is a DATABASE_URL change, not a rewrite.
Run at import time from main.py, before any request is served.
"""
from __future__ import annotations
import logging
import secrets
import shutil
import time
from sqlalchemy import select, text
from . import config
from .db import SessionLocal, engine
from .models import Base, BatteryLog, Frame
logger = logging.getLogger(__name__)
def _migration_1(conn) -> None:
Base.metadata.create_all(bind=conn)
MIGRATIONS = [
(1, _migration_1),
]
def run_migrations() -> None:
with engine.begin() as conn:
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
current = row[0] if row else 0
for version, fn in MIGRATIONS:
if version > current:
logger.info("Applying schema migration %d", version)
fn(conn)
if row is None:
conn.execute(
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
)
row = (version,)
else:
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
_ensure_frame_one()
def new_device_token() -> str:
return secrets.token_urlsafe(32)
def new_manage_token() -> str:
return secrets.token_urlsafe(16)
def _ensure_frame_one() -> None:
"""First boot only (frames table empty): create frame #1 -- imported
verbatim from a legacy config.json if one exists, otherwise fresh
defaults. Either way it's the legacy-token frame: the deployed
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
and require_device resolves those requests here. The frames-nonempty
guard makes this idempotent; config.json is left untouched as the
rollback path."""
with SessionLocal() as db:
if db.scalars(select(Frame).limit(1)).first() is not None:
return
cfg = config.load() # all defaults if the file doesn't exist
had_file = config.CONFIG_PATH.exists()
frame = Frame(
name="Frame 1",
device_id=None,
device_token=new_device_token(),
manage_token=new_manage_token(),
legacy_token_enabled=True,
created_at=time.time(),
immich_url=cfg.immich_url,
immich_api_key=cfg.immich_api_key,
album_id=cfg.album_id,
order=cfg.order,
refresh_interval_s=cfg.refresh_interval_s,
quiet_hours_enabled=cfg.quiet_hours_enabled,
quiet_hours_start=cfg.quiet_hours_start,
quiet_hours_end=cfg.quiet_hours_end,
timezone=cfg.timezone,
smart_crop_faces=cfg.smart_crop_faces,
orientation=cfg.orientation,
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
battery_percent=cfg.battery_percent,
battery_as_of=cfg.battery_as_of,
battery_history=[list(pair) for pair in cfg.battery_history],
last_seen=cfg.last_seen,
device_firmware_version=cfg.device_firmware_version,
device_board_variant=cfg.device_board_variant,
firmware_available_version=cfg.firmware_available_version,
firmware_update_repo_url=cfg.firmware_update_repo_url,
firmware_auto_update=cfg.firmware_auto_update,
firmware_update_token=cfg.firmware_update_token,
firmware_update_checked_at=cfg.firmware_update_checked_at,
firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
stats_first_seen=cfg.stats.first_seen,
stats_device_wakes=cfg.stats.device_wakes,
stats_photos_displayed=cfg.stats.photos_displayed,
stats_photos_removed=cfg.stats.photos_removed,
stats_battery_reports=cfg.stats.battery_reports,
stats_recharge_cycles=cfg.stats.recharge_cycles,
stats_ota_updates_applied=cfg.stats.ota_updates_applied,
stats_config_saves=cfg.stats.config_saves,
)
db.add(frame)
db.flush() # assign frame.id for the battery log rows
for pair in cfg.battery_log:
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
db.commit()
# The single legacy firmware slot becomes frame #1's per-frame slot.
legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
if legacy_bin.exists():
per_frame_dir = config.CONFIG_PATH.parent / "firmware"
per_frame_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")
if had_file:
logger.info(
"Imported legacy config.json as frame #%d (%d battery log entries)",
frame.id,
len(cfg.battery_log),
)
else:
logger.info("Fresh install: created default frame #%d", frame.id)
+205
View File
@@ -0,0 +1,205 @@
"""SQLAlchemy models: users, sessions, frames, links, claims, battery log.
One deliberately WIDE `frames` row per frame (settings + state + telemetry
+ stats together): every device request touches exactly one row, so the
per-frame lock in db.frame_locked() keeps the old whole-config-lock
semantics trivially correct, and SQLite doesn't care about row width.
The queue/history/excluded/battery_history columns are MutableList-mapped
JSON: photo_queue.py mutates them in place (pop/append/insert), which a
plain JSON column would silently not persist -- MutableList marks the row
dirty on in-place changes.
The ORM attribute for the photo ordering setting is `order` (matching the
old FrameConfig field name so photo_queue.py ports unchanged) but the
column is named photo_order to stay clear of the SQL keyword.
"""
from __future__ import annotations
import time
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
# Normalized to lowercase in code before insert/lookup -- portable
# case-insensitive uniqueness without SQLite-only COLLATE NOCASE.
username: Mapped[str] = mapped_column(String, unique=True)
display_name: Mapped[str] = mapped_column(String, default="")
# Pluggable identity: "local" now; an OIDC provider later would set
# provider_subject and leave password_hash NULL.
identity_provider: Mapped[str] = mapped_column(String, default="local")
provider_subject: Mapped[str] = mapped_column(String, default="")
password_hash: Mapped[str | None] = mapped_column(String, nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index(
"ix_users_provider_subject",
"identity_provider",
"provider_subject",
unique=True,
sqlite_where=provider_subject != "",
),
)
class UserSession(Base):
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(primary_key=True)
token_hash: Mapped[str] = mapped_column(String, unique=True) # sha256 hex of cookie value
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
csrf_token: Mapped[str] = mapped_column(String)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float, index=True)
user: Mapped[User] = relationship()
class Frame(Base):
__tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True)
# 12 lowercase hex chars of the device's full STA MAC. NULL only for
# the migrated legacy frame until its device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam for future calendar/canva modes -- only
# "photos" is registered today (see routers/device.py RENDERERS).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# "Take control" soft lock -- only this user may mutate settings/queue.
controlled_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
device_token: Mapped[str] = mapped_column(String)
# Device has authenticated with device_token at least once -- stop
# pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
manage_token: Mapped[str] = mapped_column(String, unique=True)
# Migration window: this frame also accepts the legacy shared
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
# migrated frame #1; cleared from /admin once the device is on
# per-frame auth.
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Migration staging only: Immich creds imported from the legacy
# config.json/env live here until /setup copies them to admin #1.
# Runtime resolution prefers owner creds, then env, then these (see
# routers/common.py immich_creds()).
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# -- settings (attribute names match the old FrameConfig fields) --
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC")
smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True)
orientation: Mapped[str] = mapped_column(String, default="landscape")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
# -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
# Current discharge cycle only (reset on recharge detection); the
# permanent record is the battery_log table.
battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
last_seen: Mapped[float] = mapped_column(Float, default=0.0)
device_firmware_version: Mapped[str] = mapped_column(String, default="")
device_board_variant: Mapped[str] = mapped_column(String, default="")
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
firmware_available_version: Mapped[str] = mapped_column(String, default="")
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False)
firmware_update_token: Mapped[str] = mapped_column(String, default="")
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
# -- stats (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0)
stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0)
stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0)
stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0)
stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0)
stats_config_saves: Mapped[int] = mapped_column(Integer, default=0)
owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id])
controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id])
class UserFrame(Base):
"""A user linked to a frame: sees it in their sidebar, may view its
pages, and may take control. Ownership (whose Immich creds the frame
renders from) is frames.owner_user_id, separate from linking."""
__tablename__ = "user_frames"
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
frame_id: Mapped[int] = mapped_column(
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
class PendingClaim(Base):
"""A claim submitted before the frame's first check-in (the user beat
the device to the server after provisioning). Attached automatically
when a device with this id self-registers; expired rows are pruned
opportunistically."""
__tablename__ = "pending_claims"
device_id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
created_at: Mapped[float] = mapped_column(Float, default=time.time)
expires_at: Mapped[float] = mapped_column(Float)
class BatteryLog(Base):
"""Every battery report ever, per frame -- the permanent record behind
the battery history chart (was a 20k-entry JSON array in config.json)."""
__tablename__ = "battery_log"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
ts: Mapped[float] = mapped_column(Float)
percent: Mapped[int] = mapped_column(Integer)
__table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)
+11 -11
View File
@@ -34,12 +34,12 @@ from __future__ import annotations
import random
import time
from .config import FrameConfig
from .models import Frame
HISTORY_MAX_LEN = 20
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
def _top_up(cfg: Frame, assets: list[dict]) -> None:
valid_ids = {a["id"] for a in assets}
excluded_ids = set(cfg.excluded_asset_ids)
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_ids]
@@ -85,7 +85,7 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used by the explicit next-photo
action (POST /frame/advance) and by get_current() once the refresh
@@ -105,7 +105,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
cfg.stats_photos_displayed += 1
# Refill back up to queue_target_len now that current_asset_id has
# changed -- otherwise the queue is left one short until the *next*
# advance, since the pop above consumes one of the items _top_up just
@@ -113,7 +113,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
"""Unconditionally moves to the previously-current photo, the mirror
image of advance_forced() -- pops the most recent entry off history,
pushes the photo it's replacing onto the front of queue (so pressing
@@ -132,12 +132,12 @@ def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
cfg.stats_photos_displayed += 1
return True
return False
def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) -> bool:
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
"""Permanently excludes asset_id from this frame's rotation (see the
module docstring) -- doesn't touch Immich, just this frame's own
selection. Scrubs it out of queue and history too, so it can't
@@ -149,7 +149,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
changed as a result."""
if asset_id not in cfg.excluded_asset_ids:
cfg.excluded_asset_ids.append(asset_id)
cfg.stats.photos_removed += 1
cfg.stats_photos_removed += 1
cfg.queue = [a for a in cfg.queue if a != asset_id]
cfg.history = [a for a in cfg.history if a != asset_id]
@@ -167,12 +167,12 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
cfg.current_asset_id = remaining[0] if remaining else ""
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
cfg.stats_photos_displayed += 1
_top_up(cfg, assets)
return True
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
otherwise touching current_asset_id. Used by GET /api/queue so a
change to the "upcoming photos to show" setting takes effect on page
@@ -180,7 +180,7 @@ def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
_top_up(cfg, assets)
def get_current(cfg: FrameConfig, assets: list[dict], in_quiet_hours: bool = False) -> bool:
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
"""Time-based, idempotent path used by GET /frame/image. Advances only
if the current photo is unset/invalid or refresh_interval_s has
elapsed since it was set. Returns whether it changed anything, so the
+117
View File
@@ -0,0 +1,117 @@
"""Quiet-hours math, extracted verbatim from the old main.py. Everything
takes the frame-like object duck-typed on quiet_hours_enabled/start/end,
timezone, and refresh_interval_s -- both the old FrameConfig and the
Frame ORM model satisfy it."""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
# Populated once from the OS's zoneinfo database (installed via the
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
# web UI's "Timezone" field.
ALL_TIMEZONES = sorted(available_timezones())
def valid_hhmm(s: str) -> bool:
try:
datetime.strptime(s, "%H:%M")
return True
except ValueError:
return False
def _zoneinfo(name: str) -> ZoneInfo:
"""Falls back to UTC for an unrecognized zone name -- defensive only;
the config-save route validates against ALL_TIMEZONES before saving,
so this only matters for state hand-edited or written by an older
version of this code."""
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("UTC")
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
(False, None) for a degenerate window (start == end)."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
if start == end:
return False, None
if start < end:
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
# exactly at `end` counts as already outside the window.
if start <= now < end:
return True, end
if now < start:
return False, start
return False, start + timedelta(days=1)
# Wraps midnight, e.g. 22:00-07:00.
if now >= start:
return True, end + timedelta(days=1)
if now < end:
return True, end
return False, start
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
"""Duration of the quiet-hours window in seconds, wrap-aware."""
sh, sm = (int(x) for x in start_str.split(":"))
eh, em = (int(x) for x in end_str.split(":"))
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
return span_min * 60
def effective_refresh_interval_s(cfg) -> int:
"""The refresh interval actually handed to the device: its configured
value, unless quiet hours are enabled, in which case it's clamped so
the device sleeps through the whole window instead of waking inside
it. A device already mid-sleep when quiet hours begin can still land
one wake inside the window (nothing server-side can prevent that
without touching the firmware) -- but from that wake on, it's told to
sleep exactly until the window ends."""
if not cfg.quiet_hours_enabled:
return cfg.refresh_interval_s
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
if boundary is None:
return cfg.refresh_interval_s
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
if in_quiet:
return seconds_to_boundary
return min(cfg.refresh_interval_s, seconds_to_boundary)
def in_quiet_hours(cfg) -> bool:
"""Whether quiet hours are in effect right now -- separate from
effective_refresh_interval_s, which only shapes what the *device* is
told to sleep for. This instead gates photo_queue.get_current()'s
time-based advance, since that check runs independent of the device
(also triggered by the web UI's queue endpoint, e.g. an open browser
tab polling overnight) and would otherwise happily advance the
current photo mid-quiet-hours on raw elapsed time alone."""
if not cfg.quiet_hours_enabled:
return False
now = datetime.now(_zoneinfo(cfg.timezone))
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
return in_quiet
def max_expected_gap_s(cfg) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
gap much longer, and the "overdue" check shouldn't mistake a device
quietly sleeping through the night for a dead one."""
gap = cfg.refresh_interval_s
if cfg.quiet_hours_enabled:
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
return gap
View File
+363
View File
@@ -0,0 +1,363 @@
"""Browser-facing /api/* routes -- Phase A keeps the old single-frame
paths, resolved to the default frame (frame #1), behind the legacy
shared-token gate. Phase B moves auth to sessions; Phase D moves paths
to /api/frames/{id}/... together with the new multi-frame UI."""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from ..auth import require_access_token
from ..db import frame_locked, get_db
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
default_frame,
immich_client_for,
immich_creds,
list_assets,
require_configured,
)
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_access_token)])
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@router.get("/api/albums")
def api_albums(db: Session = Depends(get_db)):
frame = default_frame(db)
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
try:
albums = immich_client_for(frame).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@router.post("/api/config")
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
quiet_hours_enabled: bool = Form(False),
quiet_hours_start: str = Form("22:00"),
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_auto_update: bool = Form(False),
db: Session = Depends(get_db),
):
# Immich creds are per-user (Phase B) / env-fallback -- this handler
# deliberately never touches them, same as the old env-only rule.
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
if album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo and
# queue don't mean anything in the new album's context.
cfg.current_asset_id = ""
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours.valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if quiet_hours.valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone in quiet_hours.ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_auto_update = firmware_auto_update
cfg.stats_config_saves += 1
return {"status": "saved"}
@router.get("/api/stats")
def api_stats(db: Session = Depends(get_db)):
frame = default_frame(db)
return {
"first_seen": frame.stats_first_seen,
"device_wakes": frame.stats_device_wakes,
"photos_displayed": frame.stats_photos_displayed,
"photos_removed": frame.stats_photos_removed,
"battery_reports": frame.stats_battery_reports,
"recharge_cycles": frame.stats_recharge_cycles,
"ota_updates_applied": frame.stats_ota_updates_applied,
"config_saves": frame.stats_config_saves,
}
@router.get("/api/queue")
def api_queue(db: Session = Depends(get_db)):
frame = default_frame(db)
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
snapshot = {
"current_asset_id": cfg.current_asset_id,
"queue": list(cfg.queue),
"last_seen": cfg.last_seen,
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
"firmware_version": cfg.device_firmware_version,
"firmware_available": cfg.firmware_available_version,
"battery_percent": cfg.battery_percent,
"battery_as_of": cfg.battery_as_of,
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
"battery_estimate_s": battery_estimate_s(cfg),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
),
"firmware_version": snapshot["firmware_version"] or None,
"firmware_available": snapshot["firmware_available"] or None,
"battery": (
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
if snapshot["battery_percent"] >= 0
else None
),
"on_battery_since": snapshot["on_battery_since"],
"battery_estimate_s": snapshot["battery_estimate_s"],
},
}
@router.get("/api/battery-log")
def api_battery_log(db: Session = Depends(get_db)):
frame = default_frame(db)
rows = db.execute(
select(BatteryLog.ts, BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
.order_by(BatteryLog.ts)
).all()
return {"log": [[ts, percent] for ts, percent in rows]}
class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/queue/reorder")
def api_queue_reorder(body: QueueReorderRequest, db: Session = Depends(get_db)):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/queue/promote")
def api_queue_promote(body: QueuePromoteRequest, db: Session = Depends(get_db)):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/queue/remove")
def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)):
"""Permanently removes a photo from this frame's rotation -- "Remove"
in the web UI, on either an upcoming card or the current photo. Does
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
frame = default_frame(db)
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
return {"status": "removed"}
@router.get("/api/photo-thumbnail/{asset_id}")
def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)):
frame = default_frame(db)
require_configured(frame)
client = immich_client_for(frame)
try:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
@router.post("/api/firmware")
def api_firmware_upload(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
frame = default_frame(db)
data = file.file.read()
version = parse_app_version(data)
path = firmware_path(frame.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_available_version = version
return {"status": "saved", "version": version, "size": len(data)}
def _fetch_latest_release(frame: Frame) -> dict | None:
try:
return gitea_releases.fetch_latest_release(
frame.firmware_update_repo_url, frame.firmware_update_token
)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e
def _apply_gitea_update(db: Session, frame: Frame) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual upload
would. The board comes from the device itself (device_board_variant,
learned from its X-Frame-Board header), not a user picker, so
there's nothing to fetch until the device has checked in at least
once. Network I/O happens before the lock is taken."""
if not frame.device_board_variant:
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(frame)
if not release:
raise HTTPException(404, "No releases found in the configured Gitea repo")
asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
asset_url = release["assets"].get(asset_name)
if not asset_url:
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
try:
data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
version = parse_app_version(data) # same validation the manual upload path applies
path = firmware_path(frame.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_available_version = version
cfg.firmware_gitea_latest_version = version
cfg.firmware_update_checked_at = time.time()
return version
@router.get("/api/firmware/check")
def api_firmware_check(force: bool = False, db: Session = Depends(get_db)):
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle and always
hits Gitea -- otherwise a genuinely new release can sit invisible in
the UI for up to the full throttle interval."""
frame = default_frame(db)
if not frame.firmware_update_repo_url:
return {"enabled": False}
now = time.time()
if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
# checked_at only advances on a successful reach, so a Gitea
# outage gets retried every poll instead of waiting out the full
# throttle interval.
release = _fetch_latest_release(frame)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_update_checked_at = now
if release:
cfg.firmware_gitea_latest_version = release["version"]
update_available = (
bool(frame.firmware_gitea_latest_version)
and frame.firmware_gitea_latest_version != frame.firmware_available_version
and bool(frame.device_board_variant)
)
if update_available and frame.firmware_auto_update:
_apply_gitea_update(db, frame)
update_available = False
return {
"enabled": True,
"board": frame.device_board_variant or None,
"latest_version": frame.firmware_gitea_latest_version or None,
"staged_version": frame.firmware_available_version or None,
"update_available": update_available,
}
@router.post("/api/firmware/apply-latest")
def api_firmware_apply_latest(db: Session = Depends(get_db)):
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- this is an explicit user action,
not a background poll."""
frame = default_frame(db)
if not frame.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(db, frame)
return {"status": "saved", "version": version}
+120
View File
@@ -0,0 +1,120 @@
"""Helpers shared by the device and browser routers."""
from __future__ import annotations
import io
import logging
import os
import httpx
from fastapi import HTTPException
from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import Frame
logger = logging.getLogger(__name__)
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
OVERDUE_FACTOR = 1.5
def immich_creds(frame: Frame) -> tuple[str, str]:
"""Which Immich this frame renders from. Owner's creds once the frame
is claimed (Phase B+); env vars as the operator-level fallback (the
pre-redesign source of truth); the frame's own staging columns last
(populated by the config.json migration for exactly the case where
the old file held creds but the env no longer does)."""
owner = frame.owner
if owner is not None and owner.immich_url and owner.immich_api_key:
return owner.immich_url, owner.immich_api_key
env_url = os.environ.get("IMMICH_URL", "")
env_key = os.environ.get("IMMICH_API_KEY", "")
if env_url and env_key:
return env_url, env_key
return frame.immich_url, frame.immich_api_key
def immich_client_for(frame: Frame) -> ImmichClient:
url, key = immich_creds(frame)
return ImmichClient(url, key)
def require_configured(frame: Frame) -> None:
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not frame.album_id:
raise HTTPException(400, "No album configured yet")
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
try:
assets = client.list_album_assets(frame.album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
if not assets:
raise HTTPException(404, "Album has no photos")
return assets
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
faces = None
if frame.smart_crop_faces:
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
# A faces lookup hiccup shouldn't block showing a photo at
# all -- just fall back to a plain center-crop this cycle.
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
source = Image.open(io.BytesIO(jpeg_bytes))
return render_frame(source, faces=faces, orientation=frame.orientation)
def battery_estimate_s(frame: Frame) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
about (too little time observed, or too little drop -- a flat line
extrapolates to garbage)."""
hist = frame.battery_history
if len(hist) < 2:
return None
first_ts, first_pct = hist[0]
last_ts, last_pct = hist[-1]
span = last_ts - first_ts
drop = first_pct - last_pct
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
return None
rate = drop / span # percent per second
return int(last_pct / rate)
def default_frame(db: Session) -> Frame:
"""Phase A only: the old single-frame /api/* routes all operate on
"the" frame -- the legacy one if flagged, else the lowest id.
Replaced by explicit /api/frames/{id}/ paths in Phase D."""
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
frame = db.scalars(select(Frame).order_by(Frame.id).limit(1)).first()
if frame is None:
raise HTTPException(404, "No frame exists yet")
return frame
+365
View File
@@ -0,0 +1,365 @@
"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
into deployed firmware -- so multi-frame support changes only how the
calling frame is resolved (see auth.require_device), never the paths or
response key names the deployed flat parser depends on
("refresh_interval_s", "firmware_version")."""
from __future__ import annotations
import logging
import time
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..auth import require_device
from ..db import frame_locked, get_db
from ..face_labels import compute_face_labels
from ..firmware import firmware_path
from ..models import BatteryLog, Frame
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
immich_client_for,
list_assets,
render_asset,
require_configured,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
# /frame/image looks up the frame's mode here. Only photos exists today.
def _render_photos_mode(db: Session, frame: Frame) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id)
RENDERERS = {
"photos": _render_photos_mode,
}
@router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings -- no
Immich-configured gate, since this doubles as the "is the server up"
signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs
zero extra round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked:
if locked.stats_first_seen == 0:
locked.stats_first_seen = time.time()
locked.stats_device_wakes += 1
if reported_version:
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
locked.stats_ota_updates_applied += 1
locked.device_firmware_version = reported_version
if reported_board:
locked.device_board_variant = reported_board
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
# compatible with its 256-byte parse buffer), and only until the
# device has authenticated with the token once (device_token_ack).
if locked.device_id is not None and not locked.device_token_ack:
response["device_token"] = locked.device_token
return response
@router.get("/frame/image")
def frame_image(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Returns the frame's current image. For photos mode: idempotent --
only actually advances to the next photo once refresh_interval_s has
elapsed since the current one was set (see app/photo_queue.py) --
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
return Response(content=renderer(db, frame), media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by
the device's next-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@router.post("/frame/battery")
def frame_battery(
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp plus a per-discharge-
cycle history that feeds the Device panel's "on battery for" and
"estimated remaining" numbers; every report also lands in the
permanent battery_log table behind the history chart."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
now = time.time()
with frame_locked(db, frame.id) as locked:
locked.stats_battery_reports += 1
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge.
locked.battery_history = []
locked.stats_recharge_cycles += 1
locked.battery_history.append([now, body.percent])
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
locked.battery_percent = body.percent
locked.battery_as_of = now
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
# Safety bound, not a real limit at realistic report rates --
# mirrors the old JSON list's cap.
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
if count is not None and count >= BATTERY_LOG_MAX:
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
BatteryLog.ts
).limit(count + 1 - BATTERY_LOG_MAX)
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
return {"status": "saved"}
@router.get("/frame/firmware")
def frame_firmware(frame: Frame = Depends(require_device)):
"""The frame's staged OTA image, streamed to the device
(esp_https_ota). 404 until something has been uploaded/fetched."""
path = firmware_path(frame.id)
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@router.get("/frame/photo-info")
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
location = _format_location(exif)
return {
"asset_id": asset_id,
"location_line1": location[0] if location else None,
"location_line2": location[1] if location and location[1] else None,
"taken_at": _format_taken_at(exif),
}
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame --
not any arbitrary Immich asset id -- as a second layer even a leaked
token wouldn't bypass."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@router.get("/frame/face-labels")
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
recognition (no detection/recognition happens here, see
app/face_labels.py). Response is a flattened, fixed-slot shape
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
hand-rolled parser can read it with the same flat-scalar helpers it
already has. Empty (count: 0) if no faces are named, or if anything
about fetching them fails -- this is a "nice to have" addition to
the overlay, not worth failing the whole menu over."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
smart_crop = locked.smart_crop_faces
orientation = locked.orientation
if not asset_id:
return {"count": 0}
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
return {"count": 0}
if not any((face.get("person") or {}).get("name") for face in faces):
return {"count": 0} # skip the extra preview download in the common no-named-faces case
try:
preview_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, smart_crop, orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
result[f"name_{i}"] = label["name"]
result[f"x_{i}"] = label["x"]
result[f"y_{i}"] = label["y"]
return result
+2 -2
View File
@@ -5,8 +5,8 @@
{% endblock %}
{% block content %}
{% if cfg.immich_url %}
<div class="info-box">Immich: <code>{{ cfg.immich_url }}</code> (API key configured). Set via
{% if immich_url %}
<div class="info-box">Immich: <code>{{ immich_url }}</code> (API key configured). Set via
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
<code>docker-compose.yml.example</code>.</div>
{% else %}
+1
View File
@@ -5,3 +5,4 @@ httpx==0.28.1
pillow==12.3.0
python-multipart==0.0.20
jinja2==3.1.5
sqlalchemy==2.0.51