From 8b9f636cce0407dfe707abb14c229c82d2b46490 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Fri, 24 Jul 2026 17:13:51 -0400 Subject: [PATCH] Mark whiteboard as (alpha); add the Phase 5 button-assignment UI 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. --- server/app/routers/api_frames.py | 86 +++++++++- server/app/static/common.js | 4 + server/app/static/frame_config.js | 162 ++++++++++++++++++ server/app/static/frame_layout.js | 3 +- server/app/static/theme.css | 18 ++ .../templates/_widget_dialog_whiteboard.html | 2 +- server/app/templates/frame_config.html | 33 ++++ server/app/templates/settings.html | 2 +- server/tests/test_button_actions.py | 149 ++++++++++++++++ 9 files changed, 454 insertions(+), 5 deletions(-) create mode 100644 server/tests/test_button_actions.py diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index c1eb464..f38db01 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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( diff --git a/server/app/static/common.js b/server/app/static/common.js index fc6bbe3..0ed31ea 100644 --- a/server/app/static/common.js +++ b/server/app/static/common.js @@ -58,6 +58,10 @@ } })(); +// Shared display names for widget_type, everywhere one shows up in the +// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). +const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)' }; + function showStatus(ok, message) { // While a is open, its own .dialog-result container gets the // message instead of the page-level #result -- otherwise it lands diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 3529e2d..3a8804a 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -362,3 +362,165 @@ loadFirmwareCheck(); // The server throttles actual Gitea API calls itself, so this poll is // cheap either way. setInterval(loadFirmwareCheck, 60000); + +// --- Button assignments ----------------------------------------------- +// {widgets: [{id, widget_type, actions: [{action, label}]}], next: [...], back: [...]} +// -- see api_frames.py's api_buttons_get. Each button's list is edited +// client-side (add/remove/reorder) then PUT as a whole -- simpler than +// separate reorder/add/remove endpoints for what's normally a handful of +// entries, and this file already has the full list in hand after any +// edit. +let buttonsData = null; +const BUTTONS = ['next', 'back']; + +function widgetActionLabel(widgetId, action) { + const w = buttonsData.widgets.find((w) => w.id === widgetId); + if (!w) return `(deleted widget): ${action}`; + const found = w.actions.find((a) => a.action === action); + const actionLabel = found ? found.label : action; + return `${WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`; +} + +function renderButtonList(button) { + const list = document.getElementById(`button-actions-${button}`); + const rows = buttonsData[button]; + list.innerHTML = ''; + if (!rows.length) { + list.innerHTML = '
  • Nothing assigned -- this button won’t do anything.
  • '; + return; + } + rows.forEach((row, i) => { + const li = document.createElement('li'); + li.className = 'button-action-row'; + + const span = document.createElement('span'); + span.textContent = widgetActionLabel(row.widget_id, row.action); + + const controls = document.createElement('span'); + controls.className = 'button-action-controls'; + + const up = document.createElement('button'); + up.type = 'button'; + up.className = 'icon-btn'; + up.textContent = '↑'; + up.title = 'Move up'; + up.disabled = i === 0; + up.addEventListener('click', () => moveButtonAction(button, i, -1)); + + const down = document.createElement('button'); + down.type = 'button'; + down.className = 'icon-btn'; + down.textContent = '↓'; + down.title = 'Move down'; + down.disabled = i === rows.length - 1; + down.addEventListener('click', () => moveButtonAction(button, i, 1)); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'icon-btn'; + remove.textContent = '×'; + remove.title = 'Remove'; + remove.addEventListener('click', () => removeButtonAction(button, i)); + + controls.appendChild(up); + controls.appendChild(down); + controls.appendChild(remove); + li.appendChild(span); + li.appendChild(controls); + list.appendChild(li); + }); +} + +function populateActionSelect(button) { + const widgetSel = document.getElementById(`button-add-widget-${button}`); + const actionSel = document.getElementById(`button-add-action-${button}`); + actionSel.innerHTML = ''; + const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value); + if (!w) return; + w.actions.forEach((a) => { + const opt = document.createElement('option'); + opt.value = a.action; + opt.textContent = a.label; + actionSel.appendChild(opt); + }); +} + +function populateWidgetSelect(button) { + const widgetSel = document.getElementById(`button-add-widget-${button}`); + widgetSel.innerHTML = ''; + buttonsData.widgets.forEach((w) => { + const opt = document.createElement('option'); + opt.value = w.id; + opt.textContent = WIDGET_LABELS[w.widget_type] || w.widget_type; + widgetSel.appendChild(opt); + }); + populateActionSelect(button); +} + +async function saveButtonActions(button) { + try { + const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })), + }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Button assignments saved.'); + } catch (e) { + showStatus(false, e.message); + await loadButtons(); // resync with server truth rather than leave a stale edit on screen + } +} + +function moveButtonAction(button, index, delta) { + const rows = buttonsData[button]; + const target = index + delta; + if (target < 0 || target >= rows.length) return; + [rows[index], rows[target]] = [rows[target], rows[index]]; + renderButtonList(button); + saveButtonActions(button); +} + +function removeButtonAction(button, index) { + buttonsData[button].splice(index, 1); + renderButtonList(button); + saveButtonActions(button); +} + +function addButtonAction(button) { + const widgetSel = document.getElementById(`button-add-widget-${button}`); + const actionSel = document.getElementById(`button-add-action-${button}`); + if (!widgetSel.value || !actionSel.value) return; + buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value }); + renderButtonList(button); + saveButtonActions(button); +} + +async function loadButtons() { + try { + const resp = await fetch(`${window.FRAME_BASE_API}/buttons`); + if (!resp.ok) throw new Error(await apiError(resp)); + buttonsData = await resp.json(); + document.getElementById('button-assign-groups').style.display = + buttonsData.widgets.length ? '' : 'none'; + document.getElementById('button-assign-empty-hint').style.display = + buttonsData.widgets.length ? 'none' : ''; + BUTTONS.forEach((button) => { + renderButtonList(button); + populateWidgetSelect(button); + }); + } catch (e) { + showStatus(false, e.message); + } +} + +BUTTONS.forEach((button) => { + document.getElementById(`button-add-widget-${button}`) + .addEventListener('change', () => populateActionSelect(button)); + document.getElementById(`button-add-${button}`) + .addEventListener('click', () => addButtonAction(button)); +}); + +loadButtons(); diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index bc33c8a..a836f56 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -18,7 +18,8 @@ let gridState = null; // last-loaded GET .../widgets response -const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' }; +// WIDGET_LABELS comes from common.js (shared with frame_config.js's +// button-assignment UI). function renderControlBanner(control) { const banner = document.getElementById('control-banner'); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index e3800b7..0c9d79e 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -257,6 +257,24 @@ input:focus, select:focus { box-shadow: 0 0 0 3px var(--focus-ring); } +.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; } +.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.button-action-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-alt); +} +.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; } +.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; } +.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; } +.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; } +.button-action-add select { width: auto; margin-top: 0; } + .checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; } .checkbox-row input { width: auto; margin-top: 0; } .checkbox-row label { margin-top: 0; font-weight: normal; } diff --git a/server/app/templates/_widget_dialog_whiteboard.html b/server/app/templates/_widget_dialog_whiteboard.html index 311c1d3..8de42d8 100644 --- a/server/app/templates/_widget_dialog_whiteboard.html +++ b/server/app/templates/_widget_dialog_whiteboard.html @@ -1,4 +1,4 @@ -

    Whiteboard widget

    +

    Whiteboard widget (alpha)

    Whiteboard source

    diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 3d6e8ba..b2d2a4a 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -53,6 +53,39 @@
    + +
    +

    Button assignments

    +

    What the frame's physical NEXT and BACK buttons do -- + assign one or more widget actions to each, in the order they + should run. A button with several actions runs all of them, in + order, then the panel redraws once.

    + + +
    +
    +

    NEXT button

    +
      +
      + + + +
      +
      + +
      +

      BACK button

      +
        +
        + + + +
        +
        +
        +
        diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index 4dc03fa..08098e4 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -71,7 +71,7 @@ you're linked to from that frame's Calendar tab, so a frame only shows calendars people have actually chosen to share with it.

        -

        Whiteboard (WebDAV)

        +

        Whiteboard (WebDAV) (alpha)

        Credentials for whiteboard frame mode -- fetching a specific file (e.g. a Nextcloud Whiteboard board) over WebDAV. Any WebDAV server works, not just Nextcloud.

        diff --git a/server/tests/test_button_actions.py b/server/tests/test_button_actions.py new file mode 100644 index 0000000..309fabd --- /dev/null +++ b/server/tests/test_button_actions.py @@ -0,0 +1,149 @@ +"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API +(see routers/api_frames.py's api_buttons_get/api_buttons_save and +static/frame_config.js). Covers the CRUD/validation layer; multi-action +execution order and partial-failure-continues on an actual button press +are already exercised end-to-end in test_device_widget_dispatch.py and +routers/device.py's _run_button_actions -- this file doesn't re-test +device.py's dispatch, just that the assignment API stores/serves/ +validates what the UI edits.""" + +from __future__ import annotations + +import time + +from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig + +from .conftest import csrf_headers, link_user, login, make_user + + +def _add_whiteboard_widget(db_session, frame: Frame) -> Widget: + widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(WhiteboardWidgetConfig(widget_id=widget.id)) + db_session.commit() + return widget + + +def test_get_buttons_reflects_the_default_migration_mapping(client, db_session): + """Frame #1's auto-migrated photos widget should already have NEXT -> + advance, BACK -> back from _default_button_actions (see + migration.py) -- the UI just needs to be able to see that default.""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + + resp = client.get("/api/frames/1/buttons") + assert resp.status_code == 200 + data = resp.json() + + assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"} + photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]} + assert photo_actions == {"advance", "back"} + + assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}] + assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}] + + +def test_put_replaces_the_whole_list_in_order(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + board_widget = _add_whiteboard_widget(db_session, frame) + + resp = client.put("/api/frames/1/buttons/next", json={ + "actions": [ + {"widget_id": board_widget.id, "action": "check_now"}, + {"widget_id": photo_widget.id, "action": "advance"}, + ], + }, headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + + rows = db_session.query(FrameButtonAction).filter_by( + frame_id=frame.id, button="next" + ).order_by(FrameButtonAction.sort_order).all() + assert [(r.widget_id, r.action) for r in rows] == [ + (board_widget.id, "check_now"), (photo_widget.id, "advance"), + ] + + # BACK's own default mapping (photos -> back) is untouched by a PUT to next. + back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all() + assert len(back_rows) == 1 + assert back_rows[0].action == "back" + + +def test_put_empty_list_clears_the_button(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + + resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0 + + +def test_put_rejects_unknown_button_name(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client)) + assert resp.status_code == 404 + + +def test_put_rejects_widget_from_another_frame(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time()) + db_session.add(other) + db_session.flush() + other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5, + sort_order=0, created_at=time.time()) + db_session.add(other_widget) + db_session.commit() + + resp = client.put("/api/frames/1/buttons/next", json={ + "actions": [{"widget_id": other_widget.id, "action": "advance"}], + }, headers=csrf_headers(client)) + assert resp.status_code == 400 + # Nothing partially applied -- the whole request is validated before any write. + assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1 + + +def test_put_rejects_action_the_widget_type_does_not_support(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + + resp = client.put("/api/frames/1/buttons/next", json={ + "actions": [{"widget_id": photo_widget.id, "action": "check_now"}], + }, headers=csrf_headers(client)) + assert resp.status_code == 400 + + +def test_deleting_a_widget_cascades_its_button_bindings(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() + + resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client)) + assert resp.status_code == 200, resp.text + assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0 + + +def test_linked_user_can_view_and_control_can_save(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + bob = make_user(db_session, "bob") + frame = db_session.get(Frame, 1) + link_user(db_session, bob, frame) + + client.cookies.clear() + login(client, "bob") + resp = client.get("/api/frames/1/buttons") + assert resp.status_code == 200 + + +def test_unrelated_user_cannot_view(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + make_user(db_session, "mallory") + + client.cookies.clear() + login(client, "mallory") + resp = client.get("/api/frames/1/buttons") + assert resp.status_code == 404