New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus the WIDGET_TYPES registry) -- the widget-system analogue of routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS, generalized from "one mode owns the whole panel" to "each widget renders into its own region and responds to named button actions." Each module exposes render(db, frame, widget, target_w, target_h) -> Image.Image (never raises -- a widget's own fetch hiccup falls back to a small placeholder rather than taking the whole panel's render down) and an ACTIONS registry for NEXT/BACK button assignment. Supporting changes needed to give the widget modules something to call, all mechanical/behavior-preserving for every existing caller: - image_pipeline.py: render_panel(regions, ...) generalizes render_frame's tail (paste, enhance once, overlay once, quantize once, pack once) from one photo to N regions -- not a restructuring, since calendar mode's photo-inlay feature already pastes a second composed image onto the canvas before that single shared pipeline runs. - photo_queue.py: advance_forced/back_forced/remove_from_rotation/ get_current take an explicit `frame` param now that `cfg` won't always be the Frame itself once photo-queue state moves to PhotoWidgetConfig -- caught a real latent bug while doing this: get_current was reading refresh_interval_s off `cfg`, but that's a frame-level wake-cadence setting, not something that becomes per-widget, so it now reads that off `frame` explicitly instead. - routers/common.py: list_assets/fetch_source_and_faces take album_id/ display_mode directly instead of a whole Frame (both only ever read that one attribute off it); new get_or_refresh_*_for_widget siblings of the existing calendar/weather/tasks/whiteboard cache helpers, read/ writing the new per-widget config tables -- the Frame-scoped originals are untouched and still what routers/device.py's actual dispatch calls until the Phase 2 cutover. 26 new tests (95 total): render_panel size/placement/orientation coverage, and per-widget-type render/action tests (unconfigured -> placeholder, a fetch failure -> placeholder not a crash, actions mutate the right state). Full suite passes; diff-reviewed to confirm device.py's actual RENDERERS dispatch and the old Frame-scoped get_or_refresh_* bodies are unchanged, so this is safe to deploy on its own despite being step 1 of a two-step cutover (see the project plan on why the *next* step, not this one, has to ship atomically).
877 lines
38 KiB
Python
877 lines
38 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
|
|
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, Frame, FrameCalendar
|
|
from .common import (
|
|
FRAME_MODES,
|
|
OVERDUE_FACTOR,
|
|
battery_estimate_s,
|
|
calendar_sources_for_frame,
|
|
fetch_source_and_faces,
|
|
get_or_refresh_calendar_events,
|
|
get_or_refresh_tasks,
|
|
get_or_refresh_weather,
|
|
get_or_refresh_whiteboard,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
require_configured,
|
|
valid_http_url,
|
|
webdav_creds_for,
|
|
)
|
|
|
|
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),
|
|
mode: str | None = Form(None),
|
|
calendar_view: str | None = Form(None),
|
|
calendar_photo_inlay: bool | 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),
|
|
):
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if name is not None:
|
|
cfg.name = name.strip()[:64] or cfg.name
|
|
if album_id is not None and album_id != cfg.album_id:
|
|
# A newly selected album starts clean -- the old current photo
|
|
# and queue don't mean anything in the new album's context.
|
|
cfg.current_asset_id = ""
|
|
cfg.current_asset_set_at = 0.0
|
|
cfg.queue = []
|
|
cfg.queue_cursor = 0
|
|
cfg.history = []
|
|
cfg.excluded_asset_ids = []
|
|
cfg.album_id = album_id
|
|
if order is not None:
|
|
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
|
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 display_mode is not None:
|
|
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
|
if queue_target_len is not None:
|
|
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
|
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))
|
|
if mode is not None:
|
|
cfg.mode = mode if mode in FRAME_MODES else "photos"
|
|
if calendar_view is not None:
|
|
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
|
if new_view != cfg.calendar_view:
|
|
# A stale offset means something different in a different
|
|
# view's units (days vs. weeks vs. months) -- same
|
|
# reasoning as album_id's reset above.
|
|
cfg.calendar_browse_offset = 0
|
|
cfg.calendar_view = new_view
|
|
if calendar_photo_inlay is not None:
|
|
cfg.calendar_photo_inlay = calendar_photo_inlay
|
|
if calendar_week_start is not None:
|
|
cfg.calendar_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 != cfg.calendar_week_days:
|
|
# A stale offset counts a different-sized page under the
|
|
# old day count -- same reasoning as calendar_view's own
|
|
# reset below.
|
|
cfg.calendar_browse_offset = 0
|
|
cfg.calendar_week_days = new_days
|
|
if calendar_week_layout is not None:
|
|
cfg.calendar_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 != cfg.calendar_week_start_offset:
|
|
cfg.calendar_browse_offset = 0
|
|
cfg.calendar_week_start_offset = new_offset
|
|
if calendar_weather_enabled is not None:
|
|
cfg.calendar_weather_enabled = calendar_weather_enabled
|
|
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
|
|
if calendar_weather_units != cfg.calendar_weather_units:
|
|
# Cached forecasts are in the old unit -- force a refetch
|
|
# rather than showing stale numbers under a new unit label.
|
|
cfg.calendar_weather_checked_at = 0.0
|
|
cfg.calendar_weather_units = calendar_weather_units
|
|
if calendar_tasks_enabled is not None:
|
|
cfg.calendar_tasks_enabled = calendar_tasks_enabled
|
|
cfg.stats_config_saves += 1
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/take-control")
|
|
def api_take_control(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Always succeeds for any linked user -- the lock is deliberately
|
|
soft. The previous holder just sees who has it now."""
|
|
user = require_user_api(request, db)
|
|
previous = frame.controlled_by
|
|
frame.controlled_by_user_id = user.id
|
|
db.commit()
|
|
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
|
|
previous.username if previous else "nobody")
|
|
return {"status": "saved", "controller": user.display_name or user.username}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/stats")
|
|
def api_stats(frame: Frame = Depends(require_frame_view)):
|
|
return {
|
|
"first_seen": frame.stats_first_seen,
|
|
"device_wakes": frame.stats_device_wakes,
|
|
"photos_displayed": frame.stats_photos_displayed,
|
|
"photos_removed": frame.stats_photos_removed,
|
|
"battery_reports": frame.stats_battery_reports,
|
|
"recharge_cycles": frame.stats_recharge_cycles,
|
|
"ota_updates_applied": frame.stats_ota_updates_applied,
|
|
"config_saves": frame.stats_config_saves,
|
|
}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/queue")
|
|
def api_queue(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
user = require_user_api(request, db)
|
|
require_configured(frame)
|
|
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame.album_id)
|
|
|
|
with frame_locked(db, frame.id) as cfg:
|
|
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
|
photo_queue.sync_queue_length(cfg, assets)
|
|
snapshot = {
|
|
"current_asset_id": cfg.current_asset_id,
|
|
"queue": list(cfg.queue),
|
|
"last_seen": cfg.last_seen,
|
|
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
|
|
"firmware_version": cfg.device_firmware_version,
|
|
"firmware_available": cfg.firmware_available_version,
|
|
"battery_percent": cfg.battery_percent,
|
|
"battery_as_of": cfg.battery_as_of,
|
|
"battery_estimate_s": battery_estimate_s(cfg, db),
|
|
"controller_id": cfg.controlled_by_user_id,
|
|
"controller": (
|
|
(cfg.controlled_by.display_name or cfg.controlled_by.username)
|
|
if cfg.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."""
|
|
with frame_locked(db, frame.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."""
|
|
with frame_locked(db, frame.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()."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame.album_id)
|
|
|
|
with frame_locked(db, frame.id) as cfg:
|
|
photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg)
|
|
return {"status": "removed"}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
|
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
|
"""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."""
|
|
require_configured(frame)
|
|
if asset_id != frame.current_asset_id and asset_id not in frame.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) -> str:
|
|
"""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."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame.album_id)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
|
asset_id = cfg.current_asset_id
|
|
if not asset_id:
|
|
raise HTTPException(404, "No current photo")
|
|
return asset_id
|
|
|
|
|
|
@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."""
|
|
asset_id = _current_asset_id(frame, db)
|
|
client = immich_client_for(frame)
|
|
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
|
|
png = render_preview_png(
|
|
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
display_mode=frame.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 this frame's merged cache to pick up the change promptly
|
|
# rather than waiting out the throttle.
|
|
frame.calendar_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
|
|
frame.calendar_checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "color_index": row.color_index}
|
|
|
|
|
|
def _calendar_photo_inlay(frame: Frame, db: Session):
|
|
"""The photo-inlay's source image (any view now, not just agenda), or
|
|
None if inlay is off or the frame's photos-mode album isn't
|
|
configured. Shared shape between the live render (routers/device.py's
|
|
_render_calendar_mode) and this preview endpoint; small enough that
|
|
duplicating rather than factoring out is fine, since the two call
|
|
sites differ slightly in error handling."""
|
|
if not frame.calendar_photo_inlay:
|
|
return None
|
|
url, key = immich_creds(frame)
|
|
if not (url and key and frame.album_id):
|
|
return None
|
|
try:
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame.album_id)
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
|
asset_id = locked.current_asset_id
|
|
if not asset_id:
|
|
return None
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
|
|
except HTTPException:
|
|
return None
|
|
|
|
|
|
@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."""
|
|
if not calendar_sources_for_frame(db, frame):
|
|
raise HTTPException(400, "No calendars included on this frame yet")
|
|
events, summary = get_or_refresh_calendar_events(db, frame)
|
|
photo_inlay = _calendar_photo_inlay(frame, db)
|
|
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
|
weather_cities = get_or_refresh_weather(db, frame)
|
|
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
|
|
png = calendar_render.render_calendar_preview_png(
|
|
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
|
week_start=frame.calendar_week_start,
|
|
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
|
|
week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks,
|
|
week_start_offset=frame.calendar_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)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if body.calendar_key is None:
|
|
cfg.calendar_tasks_user_id = None
|
|
cfg.calendar_tasks_calendar_key = None
|
|
cfg.calendar_tasks_cached = None
|
|
else:
|
|
cfg.calendar_tasks_user_id = user.id
|
|
cfg.calendar_tasks_calendar_key = body.calendar_key
|
|
cfg.calendar_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)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if body.url is None:
|
|
cfg.whiteboard_user_id = None
|
|
cfg.whiteboard_url = ""
|
|
cfg.whiteboard_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.whiteboard_user_id = user.id
|
|
cfg.whiteboard_url = stripped
|
|
cfg.whiteboard_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."""
|
|
png_bytes = get_or_refresh_whiteboard(db, frame, force=force)
|
|
if png_bytes is None:
|
|
if not frame.whiteboard_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."""
|
|
try:
|
|
city = weather.geocode_city(body.name)
|
|
except weather.WeatherFetchError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cities = list(cfg.calendar_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.calendar_weather_cities = cities
|
|
cfg.calendar_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),
|
|
):
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label]
|
|
cfg.calendar_weather_cities = cities
|
|
cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label]
|
|
cfg.calendar_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} |