Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 52s

Whiteboard rendering isn't fully reliable yet -- tag it (alpha)
everywhere it's user-facing (widget label, add-widget button, dialog
title, Settings' WebDAV section) via one shared WIDGET_LABELS map
(moved to common.js so both the Layout canvas and the new Configuration
tab section can use it).

Button assignments: a new "Button assignments" card on the
Configuration tab lets you assign an ordered list of (widget, action)
bindings to each physical NEXT/BACK button -- add/remove/reorder, all
autosaved. Backed by new GET/PUT /api/frames/{id}/buttons endpoints;
PUT replaces a button's whole list in one atomic, fully-validated call
rather than separate add/remove/reorder endpoints. Device-side
consumption already existed (routers/device.py's _run_button_actions);
this is the UI for what was previously only reachable via the default
migration mapping.
This commit is contained in:
2026-07-24 17:13:51 -04:00
parent 9c8a87e90d
commit 8b9f636cce
9 changed files with 454 additions and 5 deletions
+84 -2
View File
@@ -22,7 +22,8 @@ import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy import select
from pydantic import BaseModel
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours
@@ -30,7 +31,8 @@ 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, Widget
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
@@ -249,6 +251,86 @@ def api_frame_preview(
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."""
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,
"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}
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(