"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/... 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: each page's form posts only its own fields (the old single Settings form split across the Photos and Configuration tabs), so every field is optional and only provided ones 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 select from sqlalchemy.orm import Session from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db, widget_locked from ..image_pipeline import ( DEFAULT_DISPLAY_MODE, DISPLAY_MODES, PALETTE_LABELS, hex_to_rgb, render_preview_png, ) from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig from .common import ( OVERDUE_FACTOR, battery_estimate_s, calendar_sources_for_frame, fetch_source_and_faces, get_or_refresh_calendar_events_for_widget, get_or_refresh_tasks_for_widget, get_or_refresh_weather_for_widget, get_or_refresh_whiteboard_for_widget, immich_client_for, immich_creds, list_assets, photo_widget_config_or_404, valid_http_url, webdav_creds_for, widget_of_type, ) logger = logging.getLogger(__name__) router = APIRouter() 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/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), album_id: str | None = Form(None), order: str | None = Form(None), refresh_interval_s: int | None = Form(None), display_mode: str | None = Form(None), queue_target_len: 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), calendar_view: str | None = Form(None), calendar_week_start: int | None = Form(None), calendar_week_days: int | None = Form(None), calendar_week_layout: str | None = Form(None), calendar_week_start_offset: int | None = Form(None), calendar_weather_enabled: bool | None = Form(None), calendar_weather_units: str | None = Form(None), calendar_tasks_enabled: bool | None = Form(None), frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Partial update, split across up to three sequential lock spans -- frame-level settings, the frame's photo widget, the frame's calendar widget -- rather than one, now that those settings live on separate rows (see models.Widget's per-type extension tables). Never nested (see db.widget_locked's own docstring on why that would deadlock). `mode` and `calendar_photo_inlay` are no longer accepted here: 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 -- see CalendarWidgetConfig's docstring). Both are harmless no-ops if an old cached page still POSTs them -- FastAPI silently ignores form fields with no matching parameter. Until the widget-placement UI (a later phase) lets a frame have more than one widget of a type, "the photo widget" / "the calendar widget" below unambiguously means the frame's single auto-migrated one (see widget_of_type) -- these fields are silent no-ops if the frame doesn't have one yet, same posture as any other partial update whose target doesn't exist.""" 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: cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape" 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 photo_widget = widget_of_type(db, frame, "photos") photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len)) if photo_widget and photo_fields_present: with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg): if album_id is not None and album_id != pcfg.album_id: # A newly selected album starts clean -- the old current # photo and queue don't mean anything in the new album's # context. pcfg.current_asset_id = "" pcfg.current_asset_set_at = 0.0 pcfg.queue = [] pcfg.queue_cursor = 0 pcfg.history = [] pcfg.excluded_asset_ids = [] pcfg.album_id = album_id if order is not None: pcfg.order = order if order in ("sequential", "shuffle") else "sequential" if display_mode is not None: pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE if queue_target_len is not None: pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) calendar_widget = widget_of_type(db, frame, "calendar") calendar_fields_present = any(v is not None for v in ( calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout, calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled, )) if calendar_widget and calendar_fields_present: with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg): if calendar_view is not None: new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" if new_view != ccfg.view: # A stale offset means something different in a # different view's units (days vs. weeks vs. months). ccfg.browse_offset = 0 ccfg.view = new_view if calendar_week_start is not None: ccfg.week_start = max(0, min(6, calendar_week_start)) if calendar_week_days is not None: new_days = max(2, min(10, calendar_week_days)) if new_days != ccfg.week_days: # A stale offset counts a different-sized page under # the old day count. ccfg.browse_offset = 0 ccfg.week_days = new_days if calendar_week_layout is not None: ccfg.week_layout = ( calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal" ) if calendar_week_start_offset is not None: new_offset = max(-30, min(30, calendar_week_start_offset)) if new_offset != ccfg.week_start_offset: ccfg.browse_offset = 0 ccfg.week_start_offset = new_offset if calendar_weather_enabled is not None: ccfg.weather_enabled = calendar_weather_enabled if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"): if calendar_weather_units != ccfg.weather_units: # Cached forecasts are in the old unit -- force a # refetch rather than showing stale numbers under a # new unit label. ccfg.weather_checked_at = 0.0 ccfg.weather_units = calendar_weather_units if calendar_tasks_enabled is not None: ccfg.tasks_enabled = calendar_tasks_enabled 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}/queue") def api_queue( request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): user = require_user_api(request, db) photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) assets = list_assets(client, pcfg.album_id) with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): photo_queue.get_current(locked_pcfg, assets, locked_frame, in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) photo_queue.sync_queue_length(locked_pcfg, assets) snapshot = { "current_asset_id": locked_pcfg.current_asset_id, "queue": list(locked_pcfg.queue), "last_seen": locked_frame.last_seen, "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR, "firmware_version": locked_frame.device_firmware_version, "firmware_available": locked_frame.firmware_available_version, "battery_percent": locked_frame.battery_percent, "battery_as_of": locked_frame.battery_as_of, "battery_estimate_s": battery_estimate_s(locked_frame, db), "controller_id": locked_frame.controlled_by_user_id, "controller": ( (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) if locked_frame.controlled_by else None ), } def entry(asset_id: str) -> dict: return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/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"]], "control": { "controller": snapshot["controller"], "you": snapshot["controller_id"] == user.id, }, "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 ), "battery_estimate_s": snapshot["battery_estimate_s"], }, } @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]} class QueueReorderRequest(BaseModel): queue: list[str] @router.post("/api/frames/{frame_id}/queue/reorder") def api_queue_reorder( body: QueueReorderRequest, frame: Frame = Depends(require_frame_control), 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.""" photo_widget = widget_of_type(db, frame, "photos") if photo_widget is None: raise HTTPException(404, "No photo widget on this frame") with widget_locked(db, frame.id, photo_widget.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/frames/{frame_id}/queue/promote") def api_queue_promote( body: QueuePromoteRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Moves a single photo to the front of the queue -- "Show next". Unlike reorder, doesn't depend on the client knowing the queue's exact current order, so it can't fail from staleness.""" photo_widget = widget_of_type(db, frame, "photos") if photo_widget is None: raise HTTPException(404, "No photo widget on this frame") with widget_locked(db, frame.id, photo_widget.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/frames/{frame_id}/queue/remove") def api_queue_remove( body: QueueRemoveRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Permanently removes a photo from this frame's rotation. Does NOT touch Immich or the album itself; see photo_queue.remove_from_rotation().""" photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) assets = list_assets(client, pcfg.album_id) with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame) return {"status": "removed"} @router.get("/api/frames/{frame_id}/thumbnail/{asset_id}") def api_thumbnail( asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): """Scoped to what this frame is actually showing/queuing -- a user merely linked to view this frame shouldn't be able to pull thumbnails for arbitrary asset ids in the owner's Immich library, only the frame's own curated album. Same rule device.frame_share and manage.manage_thumbnail already enforce.""" _, pcfg = photo_widget_config_or_404(db, frame) if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue: raise HTTPException(404, "Not on this 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) def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]: """Same idempotent get_current() dance /api/frames/{id}/queue uses -- picks a current photo if none is set yet, otherwise just reads it, never advances early. Returns the photo widget's own config alongside the asset id, since callers (api_preview_rendered) also need its display_mode.""" photo_widget, pcfg = photo_widget_config_or_404(db, frame) client = immich_client_for(frame) assets = list_assets(client, pcfg.album_id) with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg): photo_queue.get_current(locked_pcfg, assets, locked_frame, in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame)) asset_id = locked_pcfg.current_asset_id if not asset_id: raise HTTPException(404, "No current photo") return asset_id, pcfg @router.get("/api/frames/{frame_id}/preview/original") def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): """The Immich preview image behind the currently-displayed photo, unprocessed -- the "now displaying" side of the Configuration tab's before/after comparison.""" asset_id, _ = _current_asset_id(frame, db) client = immich_client_for(frame) 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 return Response(content=jpeg_bytes, media_type="image/jpeg") @router.get("/api/frames/{frame_id}/preview/rendered") def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): """The same photo run through this frame's actual saved rendering pipeline (display mode, palette, color/contrast/dithering) and exported as a PNG -- the "how it will look on the frame" side of the comparison. Not a live preview of unsaved slider values; reflects whatever's currently saved. display_mode comes from the photo widget's own config now (palette/color/contrast/dither stay frame-level -- one physical panel, one set of those).""" asset_id, pcfg = _current_asset_id(frame, db) client = immich_client_for(frame) source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id) png = render_preview_png( source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, display_mode=pcfg.display_mode, color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, ) return Response(content=png, media_type="image/png") class CalendarSelectRequest(BaseModel): user_id: int calendar_key: str calendar_label: str = "" included: bool @router.post("/api/frames/{frame_id}/calendar-select") def api_calendar_select( body: CalendarSelectRequest, request: Request, frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control db: Session = Depends(get_db), ): """Include/exclude one calendar (calendar_key "ics" or "caldav:", see FrameCalendar) on this frame. Deliberately not require_frame_control: adding your own calendar, or muting anyone's (including your own), is each viewer's own call, not something a frame's controller manages on someone else's behalf. The one-sided permission split lives here: turning a calendar ON requires being its owner (nobody can add someone else's calendar to a shared frame for them); turning one OFF only requires being linked to the frame at all, so anyone sharing the display can mute a calendar they'd rather not see there even if they don't own it.""" user = require_user_api(request, db) if body.included and body.user_id != user.id: raise HTTPException(403, "Only a calendar's owner can add it to a frame") row = db.execute( select(FrameCalendar).where( FrameCalendar.frame_id == frame.id, FrameCalendar.user_id == body.user_id, FrameCalendar.calendar_key == body.calendar_key, ) ).scalar_one_or_none() if row is None: if not body.included: raise HTTPException(404, "Not currently included on this frame") row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key) db.add(row) row.included = body.included if body.calendar_label: row.calendar_label = body.calendar_label # Force the frame's calendar widget's merged cache to pick up the # change promptly rather than waiting out the throttle. calendar_widget = widget_of_type(db, frame, "calendar") if calendar_widget is not None: db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0 db.commit() return {"status": "saved", "included": row.included} CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS class CalendarColorRequest(BaseModel): calendar_key: str color_index: int | None # None clears the pin, reverting to auto-cycle @router.post("/api/frames/{frame_id}/calendar-color") def api_calendar_color( body: CalendarColorRequest, request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db), ): """Pins a specific panel color to one of your own included calendars (models.FrameCalendar.color_index) -- always owner-only, unlike calendar-select's included=False, since recoloring someone else's calendar isn't the same kind of "I'd rather not see this" veto as muting it. None clears the pin, reverting calendar_render.py to its old auto-cycle-by-owner-name behavior for this calendar.""" user = require_user_api(request, db) if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE: raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)") row = db.execute( select(FrameCalendar).where( FrameCalendar.frame_id == frame.id, FrameCalendar.user_id == user.id, FrameCalendar.calendar_key == body.calendar_key, ) ).scalar_one_or_none() if row is None: raise HTTPException(404, "Not included on this frame") row.color_index = body.color_index calendar_widget = widget_of_type(db, frame, "calendar") if calendar_widget is not None: db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0 db.commit() return {"status": "saved", "color_index": row.color_index} @router.get("/api/frames/{frame_id}/preview/calendar") def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): """The same merged, cached event set a live device render would use -- not a live preview of an unsaved calendar_view choice, same "reflects what's currently saved" convention as preview/rendered. No photo_inlay parameter anymore -- that's not a widget-system concept (see CalendarWidgetConfig's docstring); place an independent photo widget alongside instead.""" calendar_widget = widget_of_type(db, frame, "calendar") if calendar_widget is None: raise HTTPException(400, "No calendar widget on this frame yet") if not calendar_sources_for_frame(db, frame): raise HTTPException(400, "No calendars included on this frame yet") ccfg = db.get(CalendarWidgetConfig, calendar_widget.id) events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget) weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None tasks = ( get_or_refresh_tasks_for_widget(db, frame, calendar_widget) if (ccfg.view == "week" and ccfg.tasks_enabled) else None ) png = calendar_render.render_calendar_preview_png( events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, week_start=ccfg.week_start, weather_cities=weather_cities, weather_units=ccfg.weather_units, week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks, week_start_offset=ccfg.week_start_offset, ) return Response(content=png, media_type="image/png") class TasksSourceRequest(BaseModel): calendar_key: str | None # None clears the source @router.post("/api/frames/{frame_id}/tasks-source") def api_tasks_source( body: TasksSourceRequest, request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db), ): """Points this frame's week-view task list at one of the calling user's own CalDAV calendars -- same owner-controls-their-own-data permission split as calendar-select's included=True, since this is volunteering personal calendar data, not a frame-wide display setting a controller should get to pick on someone else's behalf. None clears the source; clearing (unlike setting) isn't ownership-gated -- like muting a shared calendar, anyone linked to the frame can turn off a task list they'd rather not see, but only its owner can point the frame at one of their calendars to begin with.""" user = require_user_api(request, db) calendar_widget = widget_of_type(db, frame, "calendar") if calendar_widget is None: raise HTTPException(404, "No calendar widget on this frame yet") with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): if body.calendar_key is None: cfg.tasks_user_id = None cfg.tasks_calendar_key = None cfg.tasks_cached = None else: cfg.tasks_user_id = user.id cfg.tasks_calendar_key = body.calendar_key cfg.tasks_checked_at = 0.0 # pick up the change promptly return {"status": "saved", "calendar_key": body.calendar_key} class WhiteboardSourceRequest(BaseModel): url: str | None # None clears the source @router.post("/api/frames/{frame_id}/whiteboard-source") def api_whiteboard_source( body: WhiteboardSourceRequest, request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db), ): """Points this frame's whiteboard at one of the calling user's own WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials -- same owner-controls-their-own-data permission split as api_tasks_source: only the account owner can set the frame to use it, but anyone linked to the frame can clear it, same as muting a shared calendar.""" user = require_user_api(request, db) whiteboard_widget = widget_of_type(db, frame, "whiteboard") if whiteboard_widget is None: raise HTTPException(404, "No whiteboard widget on this frame yet") with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg): if body.url is None: cfg.user_id = None cfg.url = "" cfg.cached_image = None else: stripped = body.url.strip() if not valid_http_url(stripped): raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL") cfg.user_id = user.id cfg.url = stripped cfg.checked_at = 0.0 # pick up the change promptly return {"status": "saved", "url": body.url} @router.get("/api/frames/{frame_id}/whiteboard-browse") def api_whiteboard_browse( request: Request, url: str | None = None, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db), ): """One level of a WebDAV directory listing, using the calling user's own credentials (never the frame's saved whiteboard_user_id -- this is "help me find a file in MY account", same person as whoever would go on to Save it, before that's even happened) -- powers the file picker on the Whiteboard tab as an alternative to pasting a URL. `url` omitted/None starts from the user's webdav_base_url (see models.py's User docstring); passing back a previous response's `entries[].url` (for a folder) descends into it.""" user = require_user_api(request, db) creds = webdav_creds_for(user) if creds is None: raise HTTPException(400, "Set up WebDAV credentials in Settings first") target = url or user.webdav_base_url if not target: raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly") if not valid_http_url(target): raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL") try: entries = webdav_client.list_directory(target, creds[0], creds[1]) except webdav_client.WebDavError as e: raise HTTPException(502, f"Could not browse: {e}") base = user.webdav_base_url or target parent_url = webdav_client.parent_directory_url(base, target) return {"current_url": target, "parent_url": parent_url, "entries": entries} @router.get("/api/frames/{frame_id}/preview/whiteboard") def api_preview_whiteboard( force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) ): """The same throttled fetch/render cache a live device request would use, run through the same panel composition/quantization pipeline (see routers/device.py's _render_whiteboard_mode) -- "how it will look on the frame" (dithered, letterboxed), not just the raw Excalidraw export, same convention as preview/rendered and preview/calendar. force=True (the "Refresh now" button, as opposed to just reopening this tab) bypasses the fetch throttle.""" whiteboard_widget = widget_of_type(db, frame, "whiteboard") if whiteboard_widget is None: raise HTTPException(400, "No whiteboard widget on this frame yet") wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id) png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force) if png_bytes is None: if not wcfg.url: raise HTTPException(400, "No whiteboard configured on this frame yet") raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") import io from PIL import Image source = Image.open(io.BytesIO(png_bytes)).convert("RGB") png = render_preview_png( source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, display_mode="letterbox", ) return Response(content=png, media_type="image/png") class WeatherCityAddRequest(BaseModel): name: str @router.post("/api/frames/{frame_id}/weather-cities/add") def api_weather_city_add( body: WeatherCityAddRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): """Geocodes a free-text city name (e.g. "Portland, OR") and adds it to this frame's weather strip -- a frame-wide display setting (like calendar_view), not personal data, so this is gated the same way as api_config_save rather than the calendar-select owner/mute split.""" calendar_widget = widget_of_type(db, frame, "calendar") if calendar_widget is None: raise HTTPException(404, "No calendar widget on this frame yet") try: city = weather.geocode_city(body.name) except weather.WeatherFetchError as e: raise HTTPException(400, str(e)) from e with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): cities = list(cfg.weather_cities or []) if any(c["label"] == city["label"] for c in cities): raise HTTPException(400, f"{city['label']} is already on this frame's list") cities.append(city) cfg.weather_cities = cities cfg.weather_checked_at = 0.0 # pick up the new city promptly return {"status": "saved", "city": city} class WeatherCityRemoveRequest(BaseModel): label: str @router.post("/api/frames/{frame_id}/weather-cities/remove") def api_weather_city_remove( body: WeatherCityRemoveRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): calendar_widget = widget_of_type(db, frame, "calendar") if calendar_widget is None: raise HTTPException(404, "No calendar widget on this frame yet") with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg): cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label] cfg.weather_cities = cities cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label] cfg.weather_cached = cached return {"status": "saved"} @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}