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:
@@ -380,6 +380,151 @@ class FrameCalendar(Base):
|
||||
)
|
||||
|
||||
|
||||
class Widget(Base):
|
||||
"""One placed/sized content item on a frame's panel -- the unit the
|
||||
widget system replaces the old single Frame.mode with (see
|
||||
app/grid.py for the grid this x/y/w/h is measured in, and app/widgets/
|
||||
for the widget_type -> render/action dispatch registry). Widgets never
|
||||
overlap (enforced server-side in routers/api_widgets.py), which is
|
||||
what keeps compositing simple: no z-order, no blending, just N
|
||||
independent regions pasted onto one shared canvas before a single
|
||||
shared dither/quantize pass (see image_pipeline.render_panel).
|
||||
|
||||
widget_type selects which of the three per-type extension tables below
|
||||
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig) holds
|
||||
this widget's actual settings/state -- a 1:1 relational split rather
|
||||
than one wide table with every type's columns, matching how
|
||||
FrameCalendar/BatteryLog are already their own tables in this
|
||||
codebase rather than crammed onto Frame."""
|
||||
|
||||
__tablename__ = "widgets"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard"
|
||||
x: Mapped[int] = mapped_column(Integer)
|
||||
y: Mapped[int] = mapped_column(Integer)
|
||||
w: Mapped[int] = mapped_column(Integer)
|
||||
h: Mapped[int] = mapped_column(Integer)
|
||||
# Display/tie-break ordering only (e.g. listing widgets in a UI) --
|
||||
# NOT a z-order, since widgets never overlap. Named sort_order, not
|
||||
# order, to sidestep the SQL-keyword dance Frame.order needed
|
||||
# (mapped to a differently-named column) -- nothing outside this
|
||||
# table needs to match a specific attribute name here.
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
|
||||
|
||||
|
||||
class PhotoWidgetConfig(Base):
|
||||
"""One photo widget's settings + queue state. Attribute names match
|
||||
Frame's old photo-queue columns exactly (down to `order`'s same
|
||||
photo_order column-name dodge) -- app/photo_queue.py's 5 functions
|
||||
are duck-typed against these exact names (never isinstance-checked
|
||||
against Frame), so they port unchanged onto this table."""
|
||||
|
||||
__tablename__ = "photo_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
album_id: Mapped[str] = mapped_column(String, default="")
|
||||
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
|
||||
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
|
||||
|
||||
class CalendarWidgetConfig(Base):
|
||||
"""One calendar widget's settings + cached-fetch state -- the same
|
||||
fields that used to live as calendar_* columns directly on Frame,
|
||||
minus calendar_photo_inlay (dropped: arbitrary widget placement
|
||||
subsumes what a fixed 50/50 inlay split did, so it's not a special
|
||||
case anymore, just place a photo widget alongside). "Included
|
||||
calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed
|
||||
to widget_id in a later phase once more than one calendar widget per
|
||||
frame is actually supported end to end)."""
|
||||
|
||||
__tablename__ = "calendar_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
view: Mapped[str] = mapped_column(String, default="agenda")
|
||||
week_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||
browse_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
fetch_summary: Mapped[str] = mapped_column(String, default="")
|
||||
weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
weather_units: Mapped[str] = mapped_column(String, default="fahrenheit")
|
||||
weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
week_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
|
||||
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
|
||||
tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
tasks_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
|
||||
class WhiteboardWidgetConfig(Base):
|
||||
"""One whiteboard widget's source + rendered-PNG cache -- the same
|
||||
fields that used to live as whiteboard_* columns directly on Frame."""
|
||||
|
||||
__tablename__ = "whiteboard_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
url: Mapped[str] = mapped_column(String, default="")
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
|
||||
# widget_type -> its per-type extension table, keyed by widget_id. Used
|
||||
# by db.widget_locked() to resolve the right config row without importing
|
||||
# app/widgets/'s heavier render/action registry just for this lookup.
|
||||
WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
"photos": PhotoWidgetConfig,
|
||||
"calendar": CalendarWidgetConfig,
|
||||
"whiteboard": WhiteboardWidgetConfig,
|
||||
}
|
||||
|
||||
|
||||
class FrameButtonAction(Base):
|
||||
"""One (widget, action) binding for one of a frame's two physical
|
||||
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
|
||||
"advance"}. A button can have several of these (sort_order gives
|
||||
execution order); on a press, every row for that (frame, button) runs
|
||||
-- see routers/device.py's frame_advance/frame_back. Deliberately
|
||||
unconstrained about which widget/action pairs with which button (the
|
||||
user's own idea for resolving "what does NEXT even mean with several
|
||||
widgets on screen": let them assign literally anything to either
|
||||
button, including mismatched combinations, rather than the server
|
||||
guessing a sensible default)."""
|
||||
|
||||
__tablename__ = "frame_button_actions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
button: Mapped[str] = mapped_column(String) # "next" | "back"
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
|
||||
action: Mapped[str] = mapped_column(String) # e.g. "advance", "back", "check_now" -- see app/widgets/
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
|
||||
)
|
||||
|
||||
|
||||
class PendingClaim(Base):
|
||||
"""A claim submitted before the frame's first check-in (the user beat
|
||||
the device to the server after provisioning). Attached automatically
|
||||
|
||||
Reference in New Issue
Block a user