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(
+4
View File
@@ -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 <dialog> is open, its own .dialog-result container gets the
// message instead of the page-level #result -- otherwise it lands
+162
View File
@@ -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 = '<li class="sub">Nothing assigned -- this button wont do anything.</li>';
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();
+2 -1
View File
@@ -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');
+18
View File
@@ -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; }
@@ -1,4 +1,4 @@
<h2 class="dialog-title">Whiteboard widget</h2>
<h2 class="dialog-title">Whiteboard widget (alpha)</h2>
<section class="card">
<h2 class="card-title">Whiteboard source</h2>
+33
View File
@@ -53,6 +53,39 @@
<button type="submit">Save</button>
</form>
</section>
<section class="card">
<h2 class="card-title">Button assignments</h2>
<p class="sub">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.</p>
<p class="sub" id="button-assign-empty-hint" style="display: none;">
Add a widget on the Layout tab first -- there's nothing to assign
a button to yet.</p>
<div id="button-assign-groups">
<div class="button-assign-group">
<h3 class="button-assign-label">NEXT button</h3>
<ul class="button-action-list" id="button-actions-next"></ul>
<div class="button-action-add">
<select id="button-add-widget-next"></select>
<select id="button-add-action-next"></select>
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
</div>
</div>
<div class="button-assign-group" style="margin-top: 20px;">
<h3 class="button-assign-label">BACK button</h3>
<ul class="button-action-list" id="button-actions-back"></ul>
<div class="button-action-add">
<select id="button-add-widget-back"></select>
<select id="button-add-action-back"></select>
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
</div>
</div>
</div>
</section>
</div>
<div class="side-col">
+1 -1
View File
@@ -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.</p>
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV)</h2>
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV) (alpha)</h2>
<p class="sub">Credentials for whiteboard frame mode -- fetching a
specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
Any WebDAV server works, not just Nextcloud.</p>
+149
View File
@@ -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