Two widgets of the same type (e.g. two Photos widgets) both showed up
as plain "Photos" with no way to tell which was which. The buttons API
now includes each widget's grid placement plus the frame's grid dims;
the UI derives a rough position ("top-left", "right", etc.) from that
and only appends a number+position suffix when a type actually
repeats on the frame, leaving the common single-widget-per-type case
unchanged.
470 lines
21 KiB
Python
470 lines
21 KiB
Python
"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/...
|
|
Per-widget settings (album, calendar view/inclusion, whiteboard source,
|
|
etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/
|
|
{widget_id}/... -- split out once a frame could hold more than one
|
|
widget of the same type.
|
|
|
|
Auth: session-only (require_frame_view for reads, require_frame_control
|
|
for mutations -- the "take control" soft lock). The limited manage-QR
|
|
surface lives separately under /api/m/ (routers/manage.py), and device
|
|
traffic under /frame/* (routers/device.py).
|
|
|
|
Config saves are PARTIAL updates: only provided fields are touched.
|
|
Checkboxes are sent explicitly as "true"/"false" strings by the page JS
|
|
-- an absent field means "not this form's field", never "unchecked".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import gitea_releases, grid, quiet_hours
|
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
|
from ..db import frame_locked, get_db
|
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
|
from ..firmware import firmware_path, parse_app_version
|
|
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
|
from ..widgets import WIDGET_TYPES
|
|
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
|
from .device import render_frame_preview_png
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
MIN_REFRESH_INTERVAL_S = 60
|
|
MAX_REFRESH_INTERVAL_S = 86400
|
|
|
|
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
|
|
|
|
|
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
|
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
|
cols x rows (see grid.grid_dims) -- landscape and portrait use a
|
|
transposed grid (8x5 vs 5x8), so an existing placement is often
|
|
literally out of bounds on the new grid, not just visually wrong.
|
|
There's no sensible coordinate remap between two differently-shaped
|
|
grids, so instead: keep whichever widget was first by placement
|
|
order, resized to fill the new full panel, and delete the rest --
|
|
cascading to their own config rows and any FrameButtonAction
|
|
bindings via ondelete="CASCADE" (see models.py). The frontend is
|
|
expected to confirm this with the user before submitting an
|
|
orientation change (see frame_config.js) -- this always executes
|
|
unconditionally once called, same posture as every other
|
|
confirm-on-the-client / act-unconditionally-on-the-server action in
|
|
this codebase."""
|
|
widgets = db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
|
|
).all()
|
|
if not widgets:
|
|
return
|
|
keep, *rest = widgets
|
|
for widget in rest:
|
|
db.delete(widget)
|
|
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/albums")
|
|
def api_albums(frame: Frame = Depends(require_frame_view)):
|
|
url, key = immich_creds(frame)
|
|
if not url or not key:
|
|
raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
|
|
try:
|
|
albums = immich_client_for(frame).list_albums()
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
|
|
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/config")
|
|
def api_config_save(
|
|
name: str | None = Form(None),
|
|
refresh_interval_s: int | None = Form(None),
|
|
orientation: str | None = Form(None),
|
|
quiet_hours_enabled: bool | None = Form(None),
|
|
quiet_hours_start: str | None = Form(None),
|
|
quiet_hours_end: str | None = Form(None),
|
|
timezone: str | None = Form(None),
|
|
firmware_update_repo_url: str | None = Form(None),
|
|
firmware_auto_update: bool | None = Form(None),
|
|
battery_alert_threshold_pct: int | None = Form(None),
|
|
palette: list[str] | None = Form(None),
|
|
palette_reset: bool | None = Form(None),
|
|
color_boost: float | None = Form(None),
|
|
contrast_boost: float | None = Form(None),
|
|
dither_strength: float | None = Form(None),
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Partial update of frame-wide settings only -- per-widget settings
|
|
(album, calendar view/inclusion, whiteboard source, etc.) live on
|
|
routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
|
|
since a frame can hold more than one widget of the same type and
|
|
"the frame's calendar settings" stopped being unambiguous the moment
|
|
that became possible. `mode` and `calendar_photo_inlay` are no
|
|
longer accepted here either: mode no longer governs anything (a
|
|
frame's widgets do), and photo inlay has no widget-system equivalent
|
|
(place an independent photo widget alongside instead). All three are
|
|
harmless no-ops if an old cached page still POSTs them -- FastAPI
|
|
silently ignores form fields with no matching parameter.
|
|
|
|
An actual orientation *change* resets the frame's widget layout (see
|
|
_reset_widget_layout_for_new_orientation) -- widget placement is
|
|
grid-cell-relative to the panel's long/short axis, which swaps on a
|
|
landscape<->portrait change, so an old placement is usually not just
|
|
visually wrong but literally out of bounds on the new grid."""
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if name is not None:
|
|
cfg.name = name.strip()[:64] or cfg.name
|
|
if refresh_interval_s is not None:
|
|
cfg.refresh_interval_s = max(
|
|
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
|
)
|
|
if orientation is not None:
|
|
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
|
if new_orientation != cfg.orientation:
|
|
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
|
|
cfg.orientation = new_orientation
|
|
if quiet_hours_enabled is not None:
|
|
cfg.quiet_hours_enabled = quiet_hours_enabled
|
|
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
|
|
cfg.quiet_hours_start = quiet_hours_start
|
|
if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
|
|
cfg.quiet_hours_end = quiet_hours_end
|
|
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
|
cfg.timezone = timezone
|
|
if firmware_update_repo_url is not None:
|
|
stripped = firmware_update_repo_url.strip()
|
|
if stripped and not valid_http_url(stripped):
|
|
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
|
cfg.firmware_update_repo_url = stripped
|
|
if firmware_auto_update is not None:
|
|
cfg.firmware_auto_update = firmware_auto_update
|
|
if battery_alert_threshold_pct is not None:
|
|
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
|
|
# A changed threshold should be able to fire again immediately,
|
|
# not stay suppressed by a flag set under the old value.
|
|
cfg.battery_alert_sent = False
|
|
if palette_reset:
|
|
cfg.palette_rgb = None
|
|
elif palette is not None:
|
|
if len(palette) != len(PALETTE_LABELS):
|
|
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
|
|
parsed = [hex_to_rgb(h) for h in palette]
|
|
if any(rgb is None for rgb in parsed):
|
|
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
|
cfg.palette_rgb = [list(rgb) for rgb in parsed]
|
|
if color_boost is not None:
|
|
cfg.color_boost = max(0.0, min(2.0, color_boost))
|
|
if contrast_boost is not None:
|
|
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
|
if dither_strength is not None:
|
|
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
|
cfg.stats_config_saves += 1
|
|
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/take-control")
|
|
def api_take_control(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Always succeeds for any linked user -- the lock is deliberately
|
|
soft. The previous holder just sees who has it now."""
|
|
user = require_user_api(request, db)
|
|
previous = frame.controlled_by
|
|
frame.controlled_by_user_id = user.id
|
|
db.commit()
|
|
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
|
|
previous.username if previous else "nobody")
|
|
return {"status": "saved", "controller": user.display_name or user.username}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/stats")
|
|
def api_stats(frame: Frame = Depends(require_frame_view)):
|
|
return {
|
|
"first_seen": frame.stats_first_seen,
|
|
"device_wakes": frame.stats_device_wakes,
|
|
"photos_displayed": frame.stats_photos_displayed,
|
|
"photos_removed": frame.stats_photos_removed,
|
|
"battery_reports": frame.stats_battery_reports,
|
|
"recharge_cycles": frame.stats_recharge_cycles,
|
|
"ota_updates_applied": frame.stats_ota_updates_applied,
|
|
"config_saves": frame.stats_config_saves,
|
|
}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/status")
|
|
def api_status(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Device liveness + control-lock info -- frame-level facts (battery,
|
|
last-seen, firmware, who has control), not tied to any particular
|
|
widget. Powers static/device_status_bar.js, shown on every per-frame
|
|
page regardless of which widgets that frame has. Used to piggyback on
|
|
the photo queue endpoint (back when a frame had at most one widget,
|
|
always photos-shaped); split out once that stopped being true, so the
|
|
status bar isn't blank on a frame with no photo widget."""
|
|
user = require_user_api(request, db)
|
|
now = time.time()
|
|
overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
|
|
return {
|
|
"control": {
|
|
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
|
"you": frame.controlled_by_user_id == user.id,
|
|
},
|
|
"device": {
|
|
"last_seen": frame.last_seen or None,
|
|
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
|
"firmware_version": frame.device_firmware_version or None,
|
|
"firmware_available": frame.firmware_available_version or None,
|
|
"battery": (
|
|
{"percent": frame.battery_percent, "as_of": frame.battery_as_of}
|
|
if frame.battery_percent >= 0 else None
|
|
),
|
|
"battery_estimate_s": battery_estimate_s(frame, db),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/preview")
|
|
def api_frame_preview(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""A small PNG of exactly what the frame is currently displaying --
|
|
the same widget compositor /frame/image uses (see routers/device.py's
|
|
render_frame_preview_png), just handed back upright and unpacked for
|
|
the dashboard header's live thumbnail instead of the device's packed
|
|
native format. Not cached: cheap enough for an on-demand header image,
|
|
and each widget's own render is already idempotent between a device's
|
|
real wakes (see photo_queue.get_current, calendar widget's browse
|
|
reset), so an extra read here doesn't skip or duplicate anything."""
|
|
png = render_frame_preview_png(db, frame, request)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
BUTTONS = ("next", "back")
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/buttons")
|
|
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
"""Everything the button-assignment UI needs in one call: every
|
|
widget on the frame with the actions its type supports (see
|
|
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
|
|
ordered list of (widget, action) bindings.
|
|
|
|
Includes each widget's placement (x/y/w/h) and the frame's grid
|
|
dimensions -- two widgets of the same type otherwise look identical
|
|
in the assignment UI's dropdowns (both just say "Photos"); the
|
|
client derives a position label ("top-left" etc.) from this to tell
|
|
them apart, the same way you'd tell them apart by eye on the Layout
|
|
canvas."""
|
|
cols, rows = grid.grid_dims(frame.orientation)
|
|
widgets = db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
|
).all()
|
|
widget_options = [
|
|
{
|
|
"id": w.id,
|
|
"widget_type": w.widget_type,
|
|
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
|
"actions": [
|
|
{"action": action, "label": label}
|
|
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
|
|
],
|
|
}
|
|
for w in widgets
|
|
]
|
|
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
|
|
for button in BUTTONS:
|
|
rows = db.scalars(
|
|
select(FrameButtonAction)
|
|
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
|
.order_by(FrameButtonAction.sort_order)
|
|
).all()
|
|
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
|
|
return result
|
|
|
|
|
|
class ButtonActionItem(BaseModel):
|
|
widget_id: int
|
|
action: str
|
|
|
|
|
|
class ButtonActionsRequest(BaseModel):
|
|
actions: list[ButtonActionItem]
|
|
|
|
|
|
@router.put("/api/frames/{frame_id}/buttons/{button}")
|
|
def api_buttons_save(
|
|
button: str, body: ButtonActionsRequest,
|
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
|
):
|
|
"""Replaces the whole ordered action list for one button in a single
|
|
call -- simpler and more atomic than separate add/remove/reorder
|
|
endpoints for what's normally a list of one to a handful of entries,
|
|
and the UI always has the full list in hand anyway (see
|
|
static/frame_config.js)."""
|
|
if button not in BUTTONS:
|
|
raise HTTPException(404, "No such button")
|
|
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
|
|
for item in body.actions:
|
|
widget = widgets_by_id.get(item.widget_id)
|
|
if widget is None:
|
|
raise HTTPException(400, f"No such widget: {item.widget_id}")
|
|
module = WIDGET_TYPES.get(widget.widget_type)
|
|
if module is None or item.action not in module.ACTIONS:
|
|
raise HTTPException(
|
|
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
|
|
)
|
|
with frame_locked(db, frame.id):
|
|
db.execute(
|
|
delete(FrameButtonAction).where(
|
|
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
|
|
)
|
|
)
|
|
for i, item in enumerate(body.actions):
|
|
db.add(FrameButtonAction(
|
|
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
|
|
sort_order=i, created_at=time.time(),
|
|
))
|
|
db.commit()
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/battery-log")
|
|
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
rows = db.execute(
|
|
select(BatteryLog.ts, BatteryLog.percent)
|
|
.where(BatteryLog.frame_id == frame.id)
|
|
.order_by(BatteryLog.ts)
|
|
).all()
|
|
return {"log": [[ts, percent] for ts, percent in rows]}
|
|
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/firmware")
|
|
def api_firmware_upload(
|
|
file: UploadFile = File(...),
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Uploads a firmware image for OTA. The version is parsed out of the
|
|
image itself (esp_app_desc_t) rather than trusted from a filename or
|
|
form field, and the project name is checked so an unrelated .bin
|
|
can't be pushed to the frame by mistake."""
|
|
data = file.file.read()
|
|
version = parse_app_version(data)
|
|
path = firmware_path(frame.id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.firmware_available_version = version
|
|
return {"status": "saved", "version": version, "size": len(data)}
|
|
|
|
|
|
def _fetch_latest_release(frame: Frame) -> dict | None:
|
|
try:
|
|
return gitea_releases.fetch_latest_release(
|
|
frame.firmware_update_repo_url, frame.firmware_update_token
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e
|
|
|
|
|
|
def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
|
"""Downloads the configured Gitea repo's latest release asset for this
|
|
frame's board variant (learned from the device's X-Frame-Board
|
|
header, never picked by hand) and stages it exactly like a manual
|
|
upload. Network I/O happens before the lock is taken."""
|
|
if not frame.device_board_variant:
|
|
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
|
|
release = _fetch_latest_release(frame)
|
|
if not release:
|
|
raise HTTPException(404, "No releases found in the configured Gitea repo")
|
|
asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
|
|
asset_url = release["assets"].get(asset_name)
|
|
if not asset_url:
|
|
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
|
|
try:
|
|
data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
|
|
version = parse_app_version(data) # same validation the manual upload path applies
|
|
path = firmware_path(frame.id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.firmware_available_version = version
|
|
cfg.firmware_gitea_latest_version = version
|
|
cfg.firmware_update_checked_at = time.time()
|
|
return version
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/firmware/check")
|
|
def api_firmware_check(
|
|
force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
"""Throttled check of the configured Gitea repo's latest release
|
|
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
|
on and a newer version is found, applies it immediately; otherwise
|
|
just reports it so the UI can offer the "Update frame" button.
|
|
force=true (the "Check now" button) bypasses the throttle.
|
|
|
|
require_frame_control (not view), and POST (not GET): this can
|
|
silently stage new firmware as a side effect (the auto-apply path
|
|
below) exactly like /firmware/apply-latest, so it needs the same
|
|
guard that route has -- a linked viewer without control shouldn't be
|
|
able to trigger that, and as a GET it would've been exempt from the
|
|
CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
|
|
if not frame.firmware_update_repo_url:
|
|
return {"enabled": False}
|
|
|
|
now = time.time()
|
|
if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
|
|
# checked_at only advances on a successful reach, so a Gitea
|
|
# outage gets retried every poll instead of waiting out the full
|
|
# throttle interval.
|
|
release = _fetch_latest_release(frame)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.firmware_update_checked_at = now
|
|
if release:
|
|
cfg.firmware_gitea_latest_version = release["version"]
|
|
|
|
update_available = (
|
|
bool(frame.firmware_gitea_latest_version)
|
|
and frame.firmware_gitea_latest_version != frame.firmware_available_version
|
|
and bool(frame.device_board_variant)
|
|
)
|
|
if update_available and frame.firmware_auto_update:
|
|
_apply_gitea_update(db, frame)
|
|
update_available = False
|
|
|
|
return {
|
|
"enabled": True,
|
|
"board": frame.device_board_variant or None,
|
|
"latest_version": frame.firmware_gitea_latest_version or None,
|
|
"staged_version": frame.firmware_available_version or None,
|
|
"update_available": update_available,
|
|
}
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/firmware/apply-latest")
|
|
def api_firmware_apply_latest(
|
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
"""The "Update frame" button: applies the latest Gitea release right
|
|
now, bypassing the check throttle -- an explicit user action, not a
|
|
background poll."""
|
|
if not frame.firmware_update_repo_url:
|
|
raise HTTPException(400, "No Gitea firmware repo configured")
|
|
version = _apply_gitea_update(db, frame)
|
|
return {"status": "saved", "version": version} |