Widget system Phase 0: data model + migration
First step of replacing Frame.mode (one renderer owns the whole panel) with an Android-home-screen-style widget system -- a frame will hold N independently placed/sized widgets (photos/calendar/whiteboard), each with its own config/state, plus fully user-assignable NEXT/BACK button actions. Full plan at .claude/plans/prancy-snacking-iverson.md. This phase is additive only and changes no existing behavior -- nothing reads these new tables yet: - models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/ WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this codebase's existing convention of dedicated tables for naturally-scoped state rather than one wide table) + FrameButtonAction (ordered (widget, action) bindings per physical button). - grid.py: pure snap-to-grid placement math, defined relative to the panel's long/short axis so it stays valid across logical_render_size(orientation)'s genuine width/height swap for portrait, not just a rotation applied at the end. - db.py: widget_locked(), the widget-scoped equivalent of frame_locked() -- deliberately still locks at frame granularity (not a new per-widget lock) to avoid a new class of multi-lock deadlock bugs. - migration.py: _migration_16 creates the new tables; a separate _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less error-prone for this much per-mode branching) gives every existing frame a widget reproducing its exact current mode/settings, so upgrading changes nothing about what a frame displays or what its buttons do. calendar_photo_inlay frames specifically get two widgets (calendar + photo, split like the old inlay did) rather than silently losing the photo half. 10 new tests covering fresh-install backfill, re-run idempotency, the photo-inlay two-widget case, whiteboard's check_now button mapping, and migration_16's actual CREATE TABLE path against a simulated pre-existing database (not just the fresh-install create_all() shortcut). Full suite (69 tests) passes.
This commit is contained in:
+30
-1
@@ -16,7 +16,7 @@ from typing import Iterator
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .models import Frame
|
||||
from .models import WIDGET_CONFIG_MODELS, Frame, Widget
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
|
||||
|
||||
@@ -86,3 +86,32 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
||||
db.refresh(frame)
|
||||
yield frame
|
||||
db.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
|
||||
"""Same lock/refresh/commit dance as frame_locked, additionally
|
||||
resolving and refreshing the widget's own per-type config row
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see
|
||||
models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame*
|
||||
granularity -- the exact same per-frame threading.Lock frame_locked
|
||||
uses, not a separate per-widget lock -- simplest, avoids a new class
|
||||
of multi-lock deadlock bugs, and this project's actual concurrency
|
||||
needs are tiny (a handful of users per household frame).
|
||||
|
||||
threading.Lock is not reentrant: a caller executing several widget
|
||||
actions in one pass (e.g. a button press assigned multiple
|
||||
(widget, action) pairs, see routers/device.py) MUST call this once
|
||||
per action, sequentially, never nested inside an outer
|
||||
frame_locked/widget_locked span for the same frame -- nesting would
|
||||
deadlock instantly, not just misbehave."""
|
||||
with frame_locked(db, frame_id) as frame:
|
||||
widget = db.get(Widget, widget_id)
|
||||
if widget is None or widget.frame_id != frame_id:
|
||||
raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
|
||||
config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
|
||||
config = db.get(config_model, widget_id)
|
||||
if config is None:
|
||||
raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
|
||||
db.refresh(config)
|
||||
yield frame, widget, config
|
||||
|
||||
Reference in New Issue
Block a user