Move button actions to per-widget config, add hold-for-global-action
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s

Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
This commit is contained in:
2026-07-27 22:09:33 +00:00
parent 9911151d8d
commit fcf3aec4c0
47 changed files with 1351 additions and 602 deletions
+54 -23
View File
@@ -24,7 +24,6 @@ from .models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
@@ -32,6 +31,7 @@ from .models import (
WhiteboardWidgetConfig,
Widget,
)
from .widgets import default_button_actions
logger = logging.getLogger(__name__)
@@ -725,6 +725,55 @@ def _migration_27(conn) -> None:
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
def _migration_28(conn) -> None:
"""One action per (widget, button) instead of an ordered per-button
list -- button-action editing moved from the frame-level "Button
assignments" card into each widget's own config dialog (see
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
api_widget_config_save). Cross-widget execution order never actually
mattered (each widget's action only touches its own state), so this
only needs to de-dupe down to one row before the new unique index can
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
but deterministically, since which specific extra binding a user's
old list happened to have doesn't matter anymore."""
conn.execute(text(
"DELETE FROM frame_button_actions WHERE id NOT IN "
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
))
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
"ON frame_button_actions (widget_id, button)"
))
def _migration_29(conn) -> None:
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
BACK past hold_duration_ms triggers a frame-wide action instead of
the per-widget one a short press runs. next_hold_action/
back_hold_action are NULL (disabled) by default -- existing frames
get no new button behavior until someone opts in on the
Configuration tab. last_cycled_layout_id tracks where a repeated
"cycle saved layouts" hold should resume from.
Guarded per-column, same reasoning as migration 26/27's own
comments: frames is a table test_migrations.py's pre-widget-system
replay tests leave un-dropped (unlike calendar/task/widget tables
those tests DROP and recreate in an old shape), so it keeps the
fresh-install create_all() copy -- which already has these columns
-- when those tests replay migrations 17+ from schema_version 16.
Without the guard, replaying this migration there re-adds a column
that's already there and SQLite raises "duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "hold_duration_ms" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
if "next_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
if "back_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
if "last_cycled_layout_id" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -753,6 +802,8 @@ MIGRATIONS = [
(25, _migration_25),
(26, _migration_26),
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
]
@@ -963,26 +1014,6 @@ def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWid
)
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is
(see app/widgets/ for the actual action registry, built in a later
phase) -- reproduces each mode's exact old button behavior for the
one auto-migrated widget, so upgrading changes nothing about what the
physical buttons do until someone deliberately reassigns them."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons already meant "check now" before this migration (see
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
@@ -1032,7 +1063,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
@@ -1048,7 +1079,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode))
db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)