"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/... Per-widget settings (album, calendar view/inclusion, whiteboard source, etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/ {widget_id}/... -- split out once a frame could hold more than one widget of the same type. Auth: session-only (require_frame_view for reads, require_frame_control for mutations -- the "take control" soft lock). The limited manage-QR surface lives separately under /api/m/ (routers/manage.py), and device traffic under /frame/* (routers/device.py). Config saves are PARTIAL updates: only provided fields are touched. Checkboxes are sent explicitly as "true"/"false" strings by the page JS -- an absent field means "not this form's field", never "unchecked". """ from __future__ import annotations import logging import time import httpx from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response from sqlalchemy import select from sqlalchemy.orm import Session from .. import gitea_releases, grid, quiet_hours from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db from ..global_actions import GLOBAL_ACTIONS from ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, Frame, Widget from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url from .device import render_frame_preview_png logger = logging.getLogger(__name__) router = APIRouter() MIN_REFRESH_INTERVAL_S = 60 MAX_REFRESH_INTERVAL_S = 86400 ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped") # See app/global_actions.py -- how long NEXT/BACK must be held before the # device treats it as a hold instead of a short press. MIN_HOLD_DURATION_MS = 3000 MAX_HOLD_DURATION_MS = 10000 def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None: """A widget's x/y/w/h are grid cells relative to the OLD orientation's cols x rows (see grid.grid_dims) -- landscape and portrait use a transposed grid (8x5 vs 5x8), so an existing placement is often literally out of bounds on the new grid, not just visually wrong. There's no sensible coordinate remap between two differently-shaped grids, so instead: keep whichever widget was first by placement order, resized to fill the new full panel, and delete the rest -- cascading to their own config rows and any FrameButtonAction bindings via ondelete="CASCADE" (see models.py). The frontend is expected to confirm this with the user before submitting an orientation change (see frame_config.js) -- this always executes unconditionally once called, same posture as every other confirm-on-the-client / act-unconditionally-on-the-server action in this codebase.""" widgets = db.scalars( select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order) ).all() if not widgets: return keep, *rest = widgets for widget in rest: db.delete(widget) keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation) @router.get("/api/frames/{frame_id}/albums") def api_albums(frame: Frame = Depends(require_frame_view)): url, key = immich_creds(frame) if not url or not key: raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)") 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/frames/{frame_id}/config") def api_config_save( name: str | None = Form(None), refresh_interval_s: int | None = Form(None), orientation: str | None = Form(None), quiet_hours_enabled: bool | None = Form(None), quiet_hours_start: str | None = Form(None), quiet_hours_end: str | None = Form(None), timezone: str | None = Form(None), firmware_update_repo_url: str | None = Form(None), firmware_auto_update: bool | None = Form(None), battery_alert_threshold_pct: int | None = Form(None), palette: list[str] | None = Form(None), palette_reset: bool | None = Form(None), color_boost: float | None = Form(None), contrast_boost: float | None = Form(None), dither_strength: float | None = Form(None), hold_duration_ms: int | None = Form(None), next_hold_action: str | None = Form(None), back_hold_action: str | None = Form(None), frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Partial update of frame-wide settings only -- per-widget settings (album, calendar view/inclusion, whiteboard source, etc.) live on routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead, since a frame can hold more than one widget of the same type and "the frame's calendar settings" stopped being unambiguous the moment that became possible. `mode` and `calendar_photo_inlay` are no longer accepted here either: mode no longer governs anything (a frame's widgets do), and photo inlay has no widget-system equivalent (place an independent photo widget alongside instead). All three are harmless no-ops if an old cached page still POSTs them -- FastAPI silently ignores form fields with no matching parameter. An actual orientation *change* resets the frame's widget layout (see _reset_widget_layout_for_new_orientation) -- widget placement is grid-cell-relative to the panel's long/short axis, which swaps on a landscape<->portrait change, so an old placement is usually not just visually wrong but literally out of bounds on the new grid. hold_duration_ms/next_hold_action/back_hold_action configure hold- for-global-action (see app/global_actions.py) -- a frame-wide setting, not per-widget, hence living here rather than on api_widgets.py's per-widget button-actions endpoint. An unrecognized action value clears the binding rather than erroring, same posture as this endpoint's other enum-ish fields (orientation, timezone).""" with frame_locked(db, frame.id) as cfg: if name is not None: cfg.name = name.strip()[:64] or cfg.name if refresh_interval_s is not None: cfg.refresh_interval_s = max( MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s) ) if orientation is not None: new_orientation = orientation if orientation in ORIENTATIONS else "landscape" if new_orientation != cfg.orientation: _reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation) cfg.orientation = new_orientation if quiet_hours_enabled is not None: cfg.quiet_hours_enabled = quiet_hours_enabled if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start): cfg.quiet_hours_start = quiet_hours_start if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end): cfg.quiet_hours_end = quiet_hours_end if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES: cfg.timezone = timezone if firmware_update_repo_url is not None: stripped = firmware_update_repo_url.strip() if stripped and not valid_http_url(stripped): raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL") cfg.firmware_update_repo_url = stripped if firmware_auto_update is not None: cfg.firmware_auto_update = firmware_auto_update if battery_alert_threshold_pct is not None: cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct)) # A changed threshold should be able to fire again immediately, # not stay suppressed by a flag set under the old value. cfg.battery_alert_sent = False if palette_reset: cfg.palette_rgb = None elif palette is not None: if len(palette) != len(PALETTE_LABELS): raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}") parsed = [hex_to_rgb(h) for h in palette] if any(rgb is None for rgb in parsed): raise HTTPException(400, "Palette colors must be #rrggbb hex values") cfg.palette_rgb = [list(rgb) for rgb in parsed] if color_boost is not None: cfg.color_boost = max(0.0, min(2.0, color_boost)) if contrast_boost is not None: cfg.contrast_boost = max(0.0, min(2.0, contrast_boost)) if dither_strength is not None: cfg.dither_strength = max(0.0, min(1.0, dither_strength)) if hold_duration_ms is not None: cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms)) if next_hold_action is not None: cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None if back_hold_action is not None: cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None cfg.stats_config_saves += 1 return {"status": "saved"} @router.post("/api/frames/{frame_id}/take-control") def api_take_control( request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): """Always succeeds for any linked user -- the lock is deliberately soft. The previous holder just sees who has it now.""" user = require_user_api(request, db) previous = frame.controlled_by frame.controlled_by_user_id = user.id db.commit() logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id, previous.username if previous else "nobody") return {"status": "saved", "controller": user.display_name or user.username} @router.get("/api/frames/{frame_id}/stats") def api_stats(frame: Frame = Depends(require_frame_view)): 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/frames/{frame_id}/status") def api_status( request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): """Device liveness + control-lock info -- frame-level facts (battery, last-seen, firmware, who has control), not tied to any particular widget. Powers static/device_status_bar.js, shown on every per-frame page regardless of which widgets that frame has. Used to piggyback on the photo queue endpoint (back when a frame had at most one widget, always photos-shaped); split out once that stopped being true, so the status bar isn't blank on a frame with no photo widget.""" user = require_user_api(request, db) now = time.time() overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR return { "control": { "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None, "you": frame.controlled_by_user_id == user.id, }, "device": { "last_seen": frame.last_seen or None, "overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap), # When the device is next expected to check in, per the same # sleep duration frame_config() actually hands it (see # device.py's /frame/config) -- not the raw overdue_gap above, # which is deliberately generous (OVERDUE_FACTOR) to avoid # false alarms during quiet hours rather than a best guess. "expected_next_checkin": ( frame.last_seen + quiet_hours.effective_refresh_interval_s(frame) if frame.last_seen else None ), "firmware_version": frame.device_firmware_version or None, "firmware_available": frame.firmware_available_version or None, "battery": ( {"percent": frame.battery_percent, "as_of": frame.battery_as_of} if frame.battery_percent >= 0 else None ), "battery_estimate_s": battery_estimate_s(frame, db), }, } @router.get("/api/frames/{frame_id}/preview") def api_frame_preview( request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): """A small PNG of exactly what the frame is currently displaying -- the same widget compositor /frame/image uses (see routers/device.py's render_frame_preview_png), just handed back upright and unpacked for the dashboard header's live thumbnail instead of the device's packed native format. Not cached: cheap enough for an on-demand header image, and each widget's own render is already idempotent between a device's real wakes (see photo_queue.get_current, calendar widget's browse reset), so an extra read here doesn't skip or duplicate anything.""" png = render_frame_preview_png(db, frame, request) return Response(content=png, media_type="image/png") @router.get("/api/frames/{frame_id}/now-displaying") def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)): """Exactly what was last actually sent to this frame's device (see routers/device.py's _record_last_displayed) -- the frozen "now displaying" half of the header preview pair, as opposed to /preview's always-live "up next" re-render. 404 (not a placeholder image) until the device has fetched at least once, so the web UI can show its own empty state instead of a broken image. X-Displayed-At carries the capture time (unix seconds) for a "N ago" label -- a header, not the body, since the body is the raw PNG bytes.""" if frame.last_displayed_image is None: raise HTTPException(404, "This frame hasn't displayed anything yet") return Response( content=frame.last_displayed_image, media_type="image/png", headers={"X-Displayed-At": str(frame.last_displayed_at)}, ) @router.get("/api/frames/{frame_id}/battery-log") def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_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]} @router.post("/api/frames/{frame_id}/firmware") def api_firmware_upload( file: UploadFile = File(...), frame: Frame = Depends(require_frame_control), 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.""" 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 (learned from the device's X-Frame-Board header, never picked by hand) and stages it exactly like a manual upload. 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.post("/api/frames/{frame_id}/firmware/check") def api_firmware_check( force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) ): """Throttled check of the configured Gitea repo's latest release (gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is on and a newer version is found, applies it immediately; otherwise just reports it so the UI can offer the "Update frame" button. force=true (the "Check now" button) bypasses the throttle. require_frame_control (not view), and POST (not GET): this can silently stage new firmware as a side effect (the auto-apply path below) exactly like /firmware/apply-latest, so it needs the same guard that route has -- a linked viewer without control shouldn't be able to trigger that, and as a GET it would've been exempt from the CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS.""" 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, "running_version": frame.device_firmware_version or None, "update_available": update_available, } @router.post("/api/frames/{frame_id}/firmware/apply-latest") def api_firmware_apply_latest( frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) ): """The "Update frame" button: applies the latest Gitea release right now, bypassing the check throttle -- an explicit user action, not a background poll.""" 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}