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
+25 -94
View File
@@ -22,17 +22,16 @@ import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import delete, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from ..models import BatteryLog, Frame, Widget
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
# See app/global_actions.py -- how long NEXT/BACK must be held before the
# device treats it as a hold instead of a short press.
MIN_HOLD_DURATION_MS = 3000
MAX_HOLD_DURATION_MS = 10000
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
@@ -101,6 +105,9 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
@@ -120,7 +127,14 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid."""
visually wrong but literally out of bounds on the new grid.
hold_duration_ms/next_hold_action/back_hold_action configure hold-
for-global-action (see app/global_actions.py) -- a frame-wide
setting, not per-widget, hence living here rather than on
api_widgets.py's per-widget button-actions endpoint. An unrecognized
action value clears the binding rather than erroring, same posture
as this endpoint's other enum-ish fields (orientation, timezone)."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
@@ -168,6 +182,12 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None:
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
if back_hold_action is not None:
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -251,95 +271,6 @@ 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.
Includes each widget's placement (x/y/w/h) and the frame's grid
dimensions -- two widgets of the same type otherwise look identical
in the assignment UI's dropdowns (both just say "Photos"); the
client derives a position label ("top-left" etc.) from this to tell
them apart, the same way you'd tell them apart by eye on the Layout
canvas."""
cols, rows = grid.grid_dims(frame.orientation)
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,
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
"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, "grid": {"cols": cols, "rows": rows}}
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(