diff --git a/server/README.md b/server/README.md index c4ac9c0..4022ae9 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/app/auth.py b/server/app/auth.py new file mode 100644 index 0000000..ba41789 --- /dev/null +++ b/server/app/auth.py @@ -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()/ 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=. + Deployed legacy firmware sends only ?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 diff --git a/server/app/config.py b/server/app/config.py index 3efd152..dc4c931 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -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-.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 diff --git a/server/app/db.py b/server/app/db.py new file mode 100644 index 0000000..d57c1a4 --- /dev/null +++ b/server/app/db.py @@ -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() diff --git a/server/app/firmware.py b/server/app/firmware.py index 0035ecc..1e5a5a5 100644 --- a/server/app/firmware.py +++ b/server/app/firmware.py @@ -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: diff --git a/server/app/main.py b/server/app/main.py index b9d8931..6ab6edd 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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 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 diff --git a/server/app/routers/__init__.py b/server/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/routers/api.py b/server/app/routers/api.py new file mode 100644 index 0000000..aa3b5df --- /dev/null +++ b/server/app/routers/api.py @@ -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} diff --git a/server/app/routers/common.py b/server/app/routers/common.py new file mode 100644 index 0000000..c8173f4 --- /dev/null +++ b/server/app/routers/common.py @@ -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 diff --git a/server/app/routers/device.py b/server/app/routers/device.py new file mode 100644 index 0000000..17ac685 --- /dev/null +++ b/server/app/routers/device.py @@ -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 diff --git a/server/app/templates/index.html b/server/app/templates/index.html index d33e99b..8d8b072 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -5,8 +5,8 @@ {% endblock %} {% block content %} - {% if cfg.immich_url %} -
Immich: {{ cfg.immich_url }} (API key configured). Set via + {% if immich_url %} +
Immich: {{ immich_url }} (API key configured). Set via IMMICH_URL/IMMICH_API_KEY in docker-compose.yml -- see docker-compose.yml.example.
{% else %} diff --git a/server/requirements.txt b/server/requirements.txt index 936ae7b..f4b39c8 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -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