"""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 pydantic import BaseModel from sqlalchemy import delete, 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 ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, Frame, FrameButtonAction, Widget from ..widgets import WIDGET_TYPES 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") 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), 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.""" 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)) 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), "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") BUTTONS = ("next", "back") @router.get("/api/frames/{frame_id}/buttons") def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): """Everything the button-assignment UI needs in one call: every widget on the frame with the actions its type supports (see app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current ordered list of (widget, action) bindings. Includes each widget's placement (x/y/w/h) and the frame's grid dimensions -- two widgets of the same type otherwise look identical in the assignment UI's dropdowns (both just say "Photos"); the client derives a position label ("top-left" etc.) from this to tell them apart, the same way you'd tell them apart by eye on the Layout canvas.""" cols, rows = grid.grid_dims(frame.orientation) widgets = db.scalars( select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) ).all() widget_options = [ { "id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, "actions": [ {"action": action, "label": label} for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items() ], } for w in widgets ] result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}} for button in BUTTONS: rows = db.scalars( select(FrameButtonAction) .where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button) .order_by(FrameButtonAction.sort_order) ).all() result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows] return result class ButtonActionItem(BaseModel): widget_id: int action: str class ButtonActionsRequest(BaseModel): actions: list[ButtonActionItem] @router.put("/api/frames/{frame_id}/buttons/{button}") def api_buttons_save( button: str, body: ButtonActionsRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Replaces the whole ordered action list for one button in a single call -- simpler and more atomic than separate add/remove/reorder endpoints for what's normally a list of one to a handful of entries, and the UI always has the full list in hand anyway (see static/frame_config.js).""" if button not in BUTTONS: raise HTTPException(404, "No such button") widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))} for item in body.actions: widget = widgets_by_id.get(item.widget_id) if widget is None: raise HTTPException(400, f"No such widget: {item.widget_id}") module = WIDGET_TYPES.get(widget.widget_type) if module is None or item.action not in module.ACTIONS: raise HTTPException( 400, f"{widget.widget_type} widgets don't support the {item.action!r} action" ) with frame_locked(db, frame.id): db.execute( delete(FrameButtonAction).where( FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button ) ) for i, item in enumerate(body.actions): db.add(FrameButtonAction( frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action, sort_order=i, created_at=time.time(), )) db.commit() return {"status": "saved"} @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, "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}