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.
919 lines
41 KiB
Python
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} |