Move button actions to per-widget config, add hold-for-global-action
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user