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.
116 lines
4.4 KiB
Python
116 lines
4.4 KiB
Python
"""Frame-wide actions triggered by holding NEXT/BACK past
|
|
Frame.hold_duration_ms, instead of the per-widget action a short press
|
|
runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not
|
|
scoped to any one widget -- e.g. cycling through the owner's saved
|
|
layouts -- so this is its own registry rather than living in a widget
|
|
module.
|
|
|
|
Each function's signature is (db, frame) -> None, the frame-level
|
|
analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and
|
|
each is responsible for its own locking/commit internally (frame_locked/
|
|
widget_locked), same convention as app/widgets/*.py. routers/device.py's
|
|
/frame/global-next and /frame/global-back look up which (if any) of
|
|
these Frame.next_hold_action/back_hold_action points to and call it,
|
|
same "unset/unknown -> silent no-op" posture as an unbound short-press
|
|
button."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import grid
|
|
from .db import frame_locked
|
|
from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget
|
|
from .routers.api_layouts import apply_layout_to_frame
|
|
from .widgets import WIDGET_TYPES
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def cycle_layout(db: Session, frame: Frame) -> None:
|
|
"""Applies the owner's next saved layout compatible with this
|
|
frame's current grid size, in a stable order (by id), wrapping back
|
|
to the first past the last one. A silent no-op if the frame is
|
|
unclaimed or its owner has no compatible saved layouts -- same
|
|
posture as every other action here when there's nothing to do."""
|
|
if frame.owner_user_id is None:
|
|
return
|
|
cols, rows = grid.grid_dims(frame.orientation)
|
|
candidates = db.scalars(
|
|
select(SavedLayout)
|
|
.where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows)
|
|
.order_by(SavedLayout.id)
|
|
).all()
|
|
if not candidates:
|
|
return
|
|
|
|
next_layout = candidates[0]
|
|
if frame.last_cycled_layout_id is not None:
|
|
for i, layout in enumerate(candidates):
|
|
if layout.id == frame.last_cycled_layout_id:
|
|
next_layout = candidates[(i + 1) % len(candidates)]
|
|
break
|
|
|
|
apply_layout_to_frame(db, frame, next_layout)
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.last_cycled_layout_id = next_layout.id
|
|
|
|
|
|
def refresh_all_widgets(db: Session, frame: Frame) -> None:
|
|
"""Runs every widget's own check_now (calendar/weather/whiteboard),
|
|
regardless of which button it's normally bound to -- a manual "sync
|
|
everything now" global action. One widget's failure doesn't block
|
|
the rest, same posture as routers/device.py's _run_button_actions."""
|
|
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
|
for widget in widgets:
|
|
module = WIDGET_TYPES.get(widget.widget_type)
|
|
check_now = module.ACTIONS.get("check_now") if module else None
|
|
if check_now is None:
|
|
continue
|
|
try:
|
|
check_now(db, frame, widget)
|
|
except Exception:
|
|
logger.exception(
|
|
"refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id
|
|
)
|
|
|
|
|
|
def toggle_all_photo_locks(db: Session, frame: Frame) -> None:
|
|
"""Flips PhotoWidgetConfig.locked for every photo widget on the frame
|
|
at once. Target state is the opposite of "everything's already
|
|
locked" -- one hold freezes every photo widget unless they're all
|
|
already frozen, in which case it unfreezes all of them. A no-op if
|
|
the frame has no photo widgets."""
|
|
widget_ids = [
|
|
w.id for w in db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
|
|
)
|
|
]
|
|
if not widget_ids:
|
|
return
|
|
configs = db.scalars(
|
|
select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids))
|
|
).all()
|
|
if not configs:
|
|
return
|
|
target = not all(c.locked for c in configs)
|
|
with frame_locked(db, frame.id):
|
|
for config in configs:
|
|
config.locked = target
|
|
|
|
|
|
GLOBAL_ACTIONS = {
|
|
"cycle_layout": cycle_layout,
|
|
"refresh_all_widgets": refresh_all_widgets,
|
|
"toggle_all_photo_locks": toggle_all_photo_locks,
|
|
}
|
|
|
|
GLOBAL_ACTION_LABELS = {
|
|
"cycle_layout": "Cycle saved layouts",
|
|
"refresh_all_widgets": "Refresh all widgets now",
|
|
"toggle_all_photo_locks": "Freeze/unfreeze all photo widgets",
|
|
}
|