Files
espresso_frame/server/app/routers/api_frames.py
T
tfaour 99069ba5fe
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m58s
Widget system Phase 3: calendar widgets become size-aware
calendar_render.py's _build_* functions now take a real target box
and pick font sizes/margins from three discrete size tiers (nearest
pixel-area fit) instead of always laying out at full panel size and
resizing after the fact -- a calendar widget placed smaller than the
full panel gets an actually-legible layout instead of shrunk text.
Month view falls back to agenda below the smallest tier, where 7
columns can no longer stay readable.

The old photo-inlay split (inlay_region/_content_region/_paste_inlay)
is deleted along with it -- arbitrary widget placement already
subsumes what a fixed half-panel split did, and every call site has
passed photo_inlay=None since the Phase 2 cutover.

Also adds HTTP-level test coverage for GET .../preview/calendar,
which had none before this -- it's what caught a stale photo_inlay
kwarg left over from the _build signature change that would have
TypeError'd on every request.
2026-07-24 09:38:10 -04:00

919 lines
41 KiB
Python

"""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:<href>", 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}