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
+9 -9
View File
@@ -253,10 +253,10 @@ def api_queue(
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
snapshot = {
"current_asset_id": cfg.current_asset_id,
@@ -372,10 +372,10 @@ def api_queue_remove(
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg)
return {"status": "removed"}
@@ -403,9 +403,9 @@ def _current_asset_id(frame: Frame, db: Session) -> str:
never advances early."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
asset_id = cfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
@@ -435,7 +435,7 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
whatever's currently saved."""
asset_id = _current_asset_id(frame, db)
client = immich_client_for(frame)
source, faces = fetch_source_and_faces(client, frame, asset_id)
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=frame.display_mode, color_boost=frame.color_boost,
@@ -546,9 +546,9 @@ def _calendar_photo_inlay(frame: Frame, db: Session):
return None
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
return None
+133 -8
View File
@@ -17,10 +17,10 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
from ..db import frame_locked
from ..db import frame_locked, widget_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import BatteryLog, Frame, FrameCalendar, User
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, User, Widget, WhiteboardWidgetConfig
logger = logging.getLogger(__name__)
@@ -81,9 +81,9 @@ def require_configured(frame: Frame) -> None:
raise HTTPException(400, "No album configured yet")
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
try:
assets = client.list_album_assets(frame.album_id)
assets = client.list_album_assets(album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
if not assets:
@@ -91,18 +91,23 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
return assets
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
def fetch_source_and_faces(
client: ImmichClient, display_mode: str, asset_id: str
) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
and (only if display_mode needs it) its detected faces. Used by both
render_asset (device-facing) and the web UI's rendered-preview
endpoint (routers/api_frames.py) so they can't drift apart."""
endpoint (routers/api_frames.py) so they can't drift apart. Takes
display_mode directly (a photos widget's own setting, see
PhotoWidgetConfig) rather than a whole Frame -- this function only
ever needed that one attribute off it."""
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
faces = None
if frame.display_mode == "crop_faces":
if display_mode == "crop_faces":
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
@@ -114,7 +119,7 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
source, faces = fetch_source_and_faces(client, frame, asset_id)
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
return render_frame(source, faces=faces, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
@@ -480,6 +485,38 @@ def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict
return events, summary
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
"""Widget-scoped twin of get_or_refresh_calendar_events above,
reading/writing CalendarWidgetConfig instead of Frame columns
directly -- same throttle/caching rationale, unchanged. Not yet used
by any router (see app/widgets/calendar.py, which this backs) --
device.py's actual dispatch still calls the Frame-scoped version
above until the widget-system cutover lands; both exist side by side
until then. calendar_sources_for_frame stays frame_id-scoped (see
FrameCalendar's own docstring) until a later phase re-keys it to
widget_id, so every calendar widget on a frame currently shares the
same "included calendars" set -- not a real limitation yet since
nothing supports more than one calendar widget per frame end to end
until that phase lands."""
cfg = db.get(CalendarWidgetConfig, widget.id)
now = time.time()
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_events, cfg.fetch_summary
sources = calendar_sources_for_frame(db, frame)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
)
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached_events = events
locked_cfg.fetch_summary = summary
locked_cfg.checked_at = now
return events, summary
def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
much longer than calendar_feed's -- weather doesn't need to be
@@ -513,6 +550,34 @@ def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
return result
def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Widget-scoped twin of get_or_refresh_weather above -- same
throttle/caching rationale, unchanged. Not yet used by any router
(see app/widgets/calendar.py) -- both versions coexist until the
widget-system cutover lands."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.weather_enabled or not cfg.weather_cities:
return []
now = time.time()
if cfg.weather_cached is not None and now - cfg.weather_checked_at < weather.CHECK_INTERVAL_S:
return cfg.weather_cached
previous_days = {c["label"]: c.get("days", {}) for c in (cfg.weather_cached or [])}
result = []
for city in cfg.weather_cities:
try:
days = weather.fetch_daily_forecast(city["latitude"], city["longitude"], cfg.weather_units)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
days = previous_days.get(city["label"], {})
result.append({"label": city["label"], "days": days})
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.weather_cached = result
locked_cfg.weather_checked_at = now
return result
def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
"""Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S,
same cadence as event merging) -- [] if tasks are off, no source is
@@ -544,6 +609,35 @@ def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
return tasks
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Widget-scoped twin of get_or_refresh_tasks above -- same throttle/
caching rationale, unchanged. Not yet used by any router (see
app/widgets/calendar.py) -- both versions coexist until the
widget-system cutover lands."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
return []
now = time.time()
if cfg.tasks_cached is not None and now - cfg.tasks_checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.tasks_cached
user = db.get(User, cfg.tasks_user_id)
key = cfg.tasks_calendar_key
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
return cfg.tasks_cached or []
href = key[len("caldav:"):]
try:
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
except caldav_client.CalDavError as e:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
return cfg.tasks_cached or []
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.tasks_cached = tasks
locked_cfg.tasks_checked_at = now
return tasks
def webdav_creds_for(user: User) -> tuple[str, str] | None:
"""(username, password) for `user`'s WebDAV access -- their own
dedicated webdav_username/password, or (if they opted in)
@@ -592,3 +686,34 @@ def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) ->
locked.whiteboard_cached_image = png
locked.whiteboard_checked_at = now
return png
def get_or_refresh_whiteboard_for_widget(
db: Session, frame: Frame, widget: Widget, force: bool = False
) -> bytes | None:
"""Widget-scoped twin of get_or_refresh_whiteboard above -- same
throttle/caching/force rationale, unchanged. Not yet used by any
router (see app/widgets/whiteboard.py) -- both versions coexist
until the widget-system cutover lands."""
cfg = db.get(WhiteboardWidgetConfig, widget.id)
if not cfg.url or not cfg.user_id:
return None
now = time.time()
if not force and cfg.cached_image is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_image
user = db.get(User, cfg.user_id)
creds = webdav_creds_for(user) if user else None
if creds is None:
return cfg.cached_image
try:
png = whiteboard.fetch_and_render(cfg.url, creds[0], creds[1])
except whiteboard.WhiteboardRenderError as e:
logger.warning("Could not refresh whiteboard for widget %d: %s", widget.id, e)
return cfg.cached_image
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached_image = png
locked_cfg.checked_at = now
return png
+8 -8
View File
@@ -97,10 +97,10 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dic
if not _frame_configured(frame):
return _setup_placeholder(frame, request, manage=manage)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
@@ -109,10 +109,10 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dic
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
photo_queue.advance_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
@@ -121,10 +121,10 @@ def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> byte
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
photo_queue.back_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
@@ -168,9 +168,9 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
if inlay_wanted and _frame_configured(frame):
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if asset_id:
jpeg_bytes = client.download_asset_preview(asset_id)
+6 -6
View File
@@ -48,10 +48,10 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
current = cfg.current_asset_id
queue = list(cfg.queue)
@@ -89,9 +89,9 @@ def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends
on the device's next wake (or its next-photo button)."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.advance_forced(cfg, assets)
photo_queue.advance_forced(cfg, assets, cfg)
return {"status": "saved"}
@@ -99,9 +99,9 @@ def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.back_forced(cfg, assets)
photo_queue.back_forced(cfg, assets, cfg)
return {"status": "saved"}