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
+46
View File
@@ -25,6 +25,7 @@ from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
@@ -184,6 +185,22 @@ def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
)
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
"""The hold-triggered counterpart to _run_button_actions -- runs
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
Frame.next_hold_action/back_hold_action points to, if any (unset or
unrecognized is a silent no-op, same posture as an unbound short-
press button). See routers/device.py's frame_global_next/back."""
action = frame.next_hold_action if button == "next" else frame.back_hold_action
action_fn = GLOBAL_ACTIONS.get(action) if action else None
if action_fn is None:
return
try:
action_fn(db, frame)
except Exception:
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
@router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its
@@ -209,6 +226,11 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
# Additive key -- old firmware's hand-rolled parser only ever
# extracts the keys it knows about, so this is safe for
# firmware that predates hold-for-global-action (see
# firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
@@ -270,6 +292,30 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/global-next")
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
instead of a short press -- runs Frame.next_hold_action (see
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
returns the whole panel same as /frame/advance. A separate endpoint
from /frame/advance (not a query flag on it) so the frozen short-press
path's behavior never has to account for the long-press case -- see
firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/global-back")
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int