Widget system Phase 1: per-type render/action modules
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s

New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus
the WIDGET_TYPES registry) -- the widget-system analogue of
routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS,
generalized from "one mode owns the whole panel" to "each widget renders
into its own region and responds to named button actions." Each module
exposes render(db, frame, widget, target_w, target_h) -> Image.Image
(never raises -- a widget's own fetch hiccup falls back to a small
placeholder rather than taking the whole panel's render down) and an
ACTIONS registry for NEXT/BACK button assignment.

Supporting changes needed to give the widget modules something to call,
all mechanical/behavior-preserving for every existing caller:

- image_pipeline.py: render_panel(regions, ...) generalizes render_frame's
  tail (paste, enhance once, overlay once, quantize once, pack once) from
  one photo to N regions -- not a restructuring, since calendar mode's
  photo-inlay feature already pastes a second composed image onto the
  canvas before that single shared pipeline runs.
- photo_queue.py: advance_forced/back_forced/remove_from_rotation/
  get_current take an explicit `frame` param now that `cfg` won't always
  be the Frame itself once photo-queue state moves to PhotoWidgetConfig
  -- caught a real latent bug while doing this: get_current was reading
  refresh_interval_s off `cfg`, but that's a frame-level wake-cadence
  setting, not something that becomes per-widget, so it now reads that
  off `frame` explicitly instead.
- routers/common.py: list_assets/fetch_source_and_faces take album_id/
  display_mode directly instead of a whole Frame (both only ever read
  that one attribute off it); new get_or_refresh_*_for_widget siblings
  of the existing calendar/weather/tasks/whiteboard cache helpers, read/
  writing the new per-widget config tables -- the Frame-scoped originals
  are untouched and still what routers/device.py's actual dispatch calls
  until the Phase 2 cutover.

26 new tests (95 total): render_panel size/placement/orientation
coverage, and per-widget-type render/action tests (unconfigured ->
placeholder, a fetch failure -> placeholder not a crash, actions mutate
the right state). Full suite passes; diff-reviewed to confirm
device.py's actual RENDERERS dispatch and the old Frame-scoped
get_or_refresh_* bodies are unchanged, so this is safe to deploy on its
own despite being step 1 of a two-step cutover (see the project plan on
why the *next* step, not this one, has to ship atomically).
This commit is contained in:
2026-07-24 08:44:53 -04:00
parent 8bc0749b42
commit f48daa71c8
15 changed files with 861 additions and 45 deletions
+40
View File
@@ -0,0 +1,40 @@
"""Registry mapping a Widget's widget_type to its render/action module --
the widget-system's analogue of routers/device.py's old RENDERERS/
ADVANCE_RENDERERS/BACK_RENDERERS dicts, generalized from "one mode owns
the whole panel" to "each widget renders into its own region and
optionally responds to named button actions."
Each module in this package exposes:
render(db, frame, widget, target_w, target_h) -> Image.Image
An RGB image exactly target_w x target_h, unquantized -- the
widget's content composed into its own region. Never returns
packed panel bytes or raises for a foreseeable failure (a
widget's own fetch hiccup shows a small placeholder instead) --
image_pipeline.render_panel composites every widget's own
render() result onto one shared canvas and quantizes/packs the
whole thing once (see its own docstring).
ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
Named button actions this widget type supports (e.g. "advance",
"back", "check_now") -- see models.FrameButtonAction. Each
function mutates the widget's own state via db.widget_locked
internally; none return a value or re-render themselves --
whatever dispatches a button press (routers/device.py, once the
widget-system cutover lands) re-renders the whole panel once
after running every assigned action.
ACTION_LABELS: dict[str, str]
Human-readable labels for ACTIONS' keys, for the button-
assignment UI.
"""
from __future__ import annotations
from . import calendar, photos, whiteboard
WIDGET_TYPES = {
"photos": photos,
"calendar": calendar,
"whiteboard": whiteboard,
}
+30
View File
@@ -0,0 +1,30 @@
"""Tiny widget-region placeholder image, shared by every widget-type
module for the "not configured yet" / "temporarily unavailable" case --
deliberately much simpler than image_pipeline.render_placeholder (no QR
code, no full-panel-scale fonts): a widget's own region can be a small
fraction of the panel, so its placeholder needs to scale down with it.
"""
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
_BG = (245, 245, 245)
_FG = (90, 90, 90)
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
img = Image.new("RGB", (target_w, target_h), _BG)
draw = ImageDraw.Draw(img)
font_size = max(10, min(20, target_h // 8))
font = ImageFont.load_default(size=font_size)
line_h = font_size + 4
total_h = line_h * len(lines)
y = max(4, (target_h - total_h) // 2)
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
x = max(4, (target_w - line_w) // 2)
draw.text((x, y), line, fill=_FG, font=font)
y += line_h
return img
+68
View File
@@ -0,0 +1,68 @@
"""Calendar widget: merged-event agenda/week/month view into the
widget's own region -- the widget-system analogue of routers/device.py's
old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode.
Full-panel-only in this phase: calendar_render.py's layout math (font
sizes, margins, row heights) is still tuned for a full ~800x480 canvas,
not derived from an arbitrary target box -- see calendar_render._build.
render() below builds at the frame's own logical_render_size(orientation)
and resizes to whatever target box it's actually asked for, which is
correct today (the only calendar widget that exists pre-Phase-4a is the
single auto-migrated full-panel one) but not yet a real "small calendar
widget" layout -- that's a later phase's job (see the project's plan
file), not a shortcut being silently taken here.
"Photo inlay" (calendar mode's old half-and-half photo split) has no
widget-system equivalent -- place an independent photo widget alongside
instead; arbitrary placement is strictly more flexible than one fixed
split ever was."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from ..calendar_render import _build
from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget
from ..routers.common import (
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
)
ACTION_LABELS = {"advance": "Next period", "back": "Previous period"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
cfg = db.get(CalendarWidgetConfig, widget.id)
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (cfg.view == "week" and cfg.tasks_enabled) else None
)
img = _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, orientation=frame.orientation,
timezone=frame.timezone, photo_inlay=None, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
week_start_offset=cfg.week_start_offset,
)
if img.size != (target_w, target_h):
img = img.resize((target_w, target_h), Image.LANCZOS)
return img
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.browse_offset += 1
def _back(db: Session, frame: Frame, widget: Widget) -> None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.browse_offset -= 1
ACTIONS = {"advance": _advance, "back": _back}
+76
View File
@@ -0,0 +1,76 @@
"""Photos widget: composes one photo from Immich into the widget's own
region -- the widget-system analogue of routers/device.py's old
_render_photos_mode/_advance_photos_mode/_back_photos_mode, and
app/photo_queue.py's real client.
render() never raises -- an Immich hiccup for this one widget shouldn't
take down the whole panel's render just because one region out of
several couldn't be composed this cycle; it falls back to a small
placeholder instead, the same resilience calendar mode's old photo-inlay
already had (see routers/device.py's `except HTTPException: pass` around
its own inlay fetch)."""
from __future__ import annotations
from fastapi import HTTPException
from PIL import Image
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import widget_locked
from ..image_pipeline import compose_into
from ..models import Frame, PhotoWidgetConfig, Widget
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
from ._shared import placeholder_image
ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id:
return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"])
try:
client = immich_client_for(frame)
assets = list_assets(client, cfg.album_id)
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
photo_queue.get_current(locked_cfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
asset_id = locked_cfg.current_asset_id
if not asset_id:
return placeholder_image(target_w, target_h, ["No photos available"])
source, faces = fetch_source_and_faces(client, cfg.display_mode, asset_id)
except HTTPException as e:
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
return compose_into(source, faces, target_w, target_h, cfg.display_mode)
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id:
return
try:
client = immich_client_for(frame)
assets = list_assets(client, cfg.album_id)
except HTTPException:
return # nothing to advance to this cycle -- next button press tries again
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
photo_queue.advance_forced(locked_cfg, assets, locked_frame)
def _back(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id:
return
try:
client = immich_client_for(frame)
assets = list_assets(client, cfg.album_id)
except HTTPException:
return
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
photo_queue.back_forced(locked_cfg, assets, locked_frame)
ACTIONS = {"advance": _advance, "back": _back}
+41
View File
@@ -0,0 +1,41 @@
"""Whiteboard widget: fetches/renders a Nextcloud Whiteboard (or any
WebDAV .whiteboard file) into the widget's own region -- the
widget-system analogue of routers/device.py's old
_render_whiteboard_mode/_advance_whiteboard_mode/_back_whiteboard_mode.
No real "next"/"back" concept for a static board (same as before the
widget system) -- both buttons map to the same "check now" action, a
forced re-fetch/re-render bypassing the normal throttle."""
from __future__ import annotations
import io
from PIL import Image
from sqlalchemy.orm import Session
from ..image_pipeline import compose_into
from ..models import Frame, Widget
from ..routers.common import get_or_refresh_whiteboard_for_widget
from ._shared import placeholder_image
ACTION_LABELS = {"check_now": "Check for updates"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget)
if png_bytes is None:
return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"])
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
# letterbox, never cropped: unlike a photo, losing part of a
# whiteboard to a crop loses actual content, not just some background
# (see the old _render_whiteboard_mode's identical reasoning).
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
get_or_refresh_whiteboard_for_widget(db, frame, widget, force=True)
ACTIONS = {"check_now": _check_now}