Widget system Phase 2: full cutover to widget-based rendering
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s

device.py's mode-keyed dispatch is replaced by a real compositor:
load a frame's widgets, compute pixel rects via app/grid.py, render
each through its widget module, and composite with render_panel.
Physical NEXT/BACK buttons now execute each frame's assigned
FrameButtonAction rows instead of one hardcoded per-mode action.

api_frames.py, manage.py, and common.py's build_manage_content are
repointed to read/write the frame's widget config rows instead of
the old Frame columns, and every settings page (Photos/Calendar/
Whiteboard tabs) now pre-fills its form from the same widget config
the write endpoints actually save to -- previously the read and
write sides would have silently diverged. The old mode selector and
photo-inlay checkbox are removed along with their now-inert wiring;
arbitrary widget placement subsumes what the fixed inlay split did.

Ships together with Phase 1 (per-type render/action modules) since
splitting the read/write cutover across deploys would have left
settings changes with no visible effect.
This commit is contained in:
2026-07-24 09:26:28 -04:00
parent f48daa71c8
commit 37bd657299
26 changed files with 1070 additions and 791 deletions
+157 -219
View File
@@ -16,11 +16,21 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
from ..db import frame_locked, widget_locked
from ..image_pipeline import render_frame
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import widget_locked
from ..image_pipeline import logical_render_size
from ..immich_client import ImmichClient
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, User, Widget, WhiteboardWidgetConfig
from ..models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
PhotoWidgetConfig,
User,
Widget,
WhiteboardWidgetConfig,
)
logger = logging.getLogger(__name__)
@@ -73,14 +83,6 @@ def immich_client_for(frame: Frame) -> ImmichClient:
return ImmichClient(url, key)
def require_configured(frame: Frame) -> None:
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not frame.album_id:
raise HTTPException(400, "No album configured yet")
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
try:
assets = client.list_album_assets(album_id)
@@ -118,14 +120,6 @@ def fetch_source_and_faces(
return Image.open(io.BytesIO(jpeg_bytes)), faces
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
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,
dither_strength=frame.dither_strength, manage=manage)
def _avg_wake_interval_s(frame: Frame) -> float:
"""Average wall-clock seconds between wakes: refresh_interval_s
scaled up for however much of each day quiet hours removes from the
@@ -355,30 +349,63 @@ def _format_taken_at(exif: dict) -> str | None:
return None
def _manage_content_asset_id(frame: Frame) -> str | None:
"""Whether frame.current_asset_id refers to a photo actually visible
right now, for whichever mode is active -- always true in photos
mode; only true in calendar mode when that view's photo inlay is on
(otherwise current_asset_id could be stale, left over from whenever
photos mode last ran, and showing its location/date/share info on a
manage overlay over a view with no visible photo at all would be
actively misleading, not just unhelpful)."""
relevant = frame.mode != "calendar" or frame.calendar_photo_inlay
return frame.current_asset_id if relevant and frame.current_asset_id else None
def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
"""The frame's first widget of this type, by placement order. Until
the placement UI (a later phase) ships, every frame has at most one
widget per type -- the auto-migrated default -- so callers needing
"the photo widget" / "the calendar widget" / "the whiteboard widget"
for what's still effectively a single-widget-per-type frame use this
rather than querying Widget directly. None if the frame has no widget
of this type."""
return db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type)
.order_by(Widget.sort_order)
).first()
def _manage_content_region(frame: Frame) -> tuple[int, int, int, int] | None:
"""Where the photo behind _manage_content_asset_id actually landed in
the logical canvas -- None (the whole canvas) in photos mode, or
calendar_render.inlay_region(...) when a calendar view's photo inlay
is what's showing. Needed so face labels (and, if ever added, other
photo-relative overlay positioning) land on the actual inlaid photo
instead of where a full-panel photo would have been."""
if frame.mode == "calendar" and frame.calendar_photo_inlay:
from ..calendar_render import inlay_region
def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
"""The frame's photo widget + its config, or a 400 if Immich creds or
an album aren't set up yet. Immich creds are frame/owner-level, but
album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
manage.py, whose photo-related endpoints both need exactly this."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
widget = widget_of_type(db, frame, "photos")
cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None
if widget is None or not cfg.album_id:
raise HTTPException(400, "No album configured yet")
return widget, cfg
return inlay_region(frame.orientation)
return None
def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]:
return db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
.order_by(Widget.sort_order)
).all()
def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None:
"""The one photo widget the manage overlay's location/date/share-link
boxes show info for -- unlike face labels (which generalize to every
photo widget on screen, see build_manage_content), there's only one
of each of these fixed panel corners to go around, so with more than
one photo widget some single one has to be picked. Resolution rule:
whichever photo widget the NEXT button's first assigned action
targets, falling back to the first photo widget by placement order
if none is button-assigned."""
if not photo_widgets:
return None
next_actions = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next")
.order_by(FrameButtonAction.sort_order)
).all()
photo_widget_ids = {w.id for w in photo_widgets}
for action in next_actions:
if action.widget_id in photo_widget_ids:
return next(w for w in photo_widgets if w.id == action.widget_id)
return photo_widgets[0]
def build_manage_content(db: Session, frame: Frame, request) -> dict:
@@ -387,47 +414,66 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
/frame/face-labels, both removed -- see the module docstring in
manage_overlay.py) are now just internal calls made here, once,
server-side, since compositing itself also moved server-side.
management_url and battery_percent always apply; location/date/
share-URL/face-labels only when there's a real current photo (see
_manage_content_asset_id) -- absent otherwise, which
manage_overlay.compose() already treats as "skip that region",
exactly the graceful-degradation behavior the old firmware-fetched
version had."""
management_url and battery_percent always apply. location/date/
share-URL come from one "primary" photo widget (see
_primary_photo_widget -- there's only one of each of those fixed
panel corners, so with more than one photo widget on screen some
single one has to be picked); face labels generalize more simply,
since manage_overlay.compose() already takes a flat list and draws
each one independently -- every photo widget's own named faces get
concatenated in, each positioned within that widget's own region
(see face_labels.compute_face_labels' region param) rather than as
if a photo filled the whole panel."""
base = str(request.base_url).rstrip("/")
content: dict = {
"management_url": f"{base}/m/{frame.manage_token}",
"battery_percent": frame.battery_percent,
}
asset_id = _manage_content_asset_id(frame)
if not asset_id:
photo_widgets = photo_widgets_for_frame(db, frame)
if not photo_widgets:
return content
client = immich_client_for(frame)
try:
asset = client.get_asset(asset_id)
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
return content
exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif)
content["share_url"] = f"{base}/frame/share/{asset_id}"
if any((face.get("person") or {}).get("name") for face in faces):
primary = _primary_photo_widget(db, frame, photo_widgets)
primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
if primary_cfg and primary_cfg.current_asset_id:
client = immich_client_for(frame)
try:
preview_bytes = client.download_asset_preview(asset_id)
from ..face_labels import compute_face_labels
content["face_labels"] = compute_face_labels(
preview_bytes, faces, frame.display_mode, frame.orientation,
region=_manage_content_region(frame),
)
asset = client.get_asset(primary_cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
logger.warning(
"Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e
)
else:
exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif)
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
panel_w, panel_h = logical_render_size(frame.orientation)
face_labels: list[dict] = []
for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.current_asset_id:
continue
client = immich_client_for(frame)
try:
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
faces = client.get_asset_faces(cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e)
continue
if not any((face.get("person") or {}).get("name") for face in faces):
continue # no Immich-identified person on this widget's current photo -- nothing to label
from ..face_labels import compute_face_labels
region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h))
face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation,
region=region))
if face_labels:
content["face_labels"] = face_labels
return content
@@ -459,45 +505,21 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.
return sources
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
-- same shape as the Gitea release-check throttle in api_frames.py's
api_firmware_check. One shared cache for the whole merged result
(every included user's events together), not per-user -- ICS feeds
are small and this refetches at most every ~20 minutes regardless of
how many are included, so per-user cache columns would add
bookkeeping for a marginal benefit."""
now = time.time()
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
return frame.calendar_cached_events, frame.calendar_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 frame_locked(db, frame.id) as locked:
locked.calendar_cached_events = events
locked.calendar_fetch_summary = summary
locked.calendar_checked_at = now
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."""
api_firmware_check -- reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py, which this backs). One shared cache for the
whole merged result (every included user's events together), not
per-user -- ICS feeds are small and this refetches at most every ~20
minutes regardless of how many are included, so per-user cache
columns would add bookkeeping for a marginal benefit.
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:
@@ -517,44 +539,15 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget:
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
that fresh). [] if weather's off or no cities are configured. A city
whose refetch fails keeps its last-known days rather than going
blank for one bad cycle -- calendar_render.py would otherwise show a
real city as having no forecast at all just because one refresh hit
a network hiccup."""
if not frame.calendar_weather_enabled or not frame.calendar_weather_cities:
return []
now = time.time()
if (frame.calendar_weather_cached is not None
and now - frame.calendar_weather_checked_at < weather.CHECK_INTERVAL_S):
return frame.calendar_weather_cached
previous_days = {c["label"]: c.get("days", {}) for c in (frame.calendar_weather_cached or [])}
result = []
for city in frame.calendar_weather_cities:
try:
days = weather.fetch_daily_forecast(
city["latitude"], city["longitude"], frame.calendar_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 frame_locked(db, frame.id) as locked:
locked.calendar_weather_cached = result
locked.calendar_weather_checked_at = now
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."""
"""Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
longer than calendar_feed's -- weather doesn't need to be that
fresh), reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py). [] if weather's off or no cities are
configured. A city whose refetch fails keeps its last-known days
rather than going blank for one bad cycle -- calendar_render.py
would otherwise show a real city as having no forecast at all just
because one refresh hit a network hiccup."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.weather_enabled or not cfg.weather_cities:
return []
@@ -578,42 +571,14 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
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
set, or the source user's CalDAV credentials/calendar_key have gone
missing (e.g. they unlinked their account). A refetch failure keeps
the last-known list rather than going blank for one bad cycle, same
reasoning as get_or_refresh_weather."""
if not frame.calendar_tasks_enabled or not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
return []
now = time.time()
if (frame.calendar_tasks_cached is not None
and now - frame.calendar_tasks_checked_at < calendar_feed.CHECK_INTERVAL_S):
return frame.calendar_tasks_cached
user = db.get(User, frame.calendar_tasks_user_id)
key = frame.calendar_tasks_calendar_key
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
return frame.calendar_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 frame %d: %s", frame.id, e)
return frame.calendar_tasks_cached or []
with frame_locked(db, frame.id) as locked:
locked.calendar_tasks_cached = tasks
locked.calendar_tasks_checked_at = now
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."""
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
cadence as event merging), reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py). [] if tasks are off, no source is set, or
the source user's CalDAV credentials/calendar_key have gone missing
(e.g. they unlinked their account). A refetch failure keeps the
last-known list rather than going blank for one bad cycle, same
reasoning as get_or_refresh_weather_for_widget."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
return []
@@ -653,48 +618,21 @@ def webdav_creds_for(user: User) -> tuple[str, str] | None:
return None
def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) -> bytes | None:
"""Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
-- None if no whiteboard source is configured, credentials are
missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
or the most recent fetch/render failed and nothing was ever cached
yet. A failure after a previous success keeps showing the last
good render rather than going blank for one bad refresh cycle, same
reasoning as get_or_refresh_weather/get_or_refresh_tasks. force=True
(the web UI's "Refresh now" button) skips the throttle entirely --
unlike a device's normal wake, a person clicking a button means do
it right now, not eventually once the cache goes stale."""
if not frame.whiteboard_url or not frame.whiteboard_user_id:
return None
now = time.time()
if (not force and frame.whiteboard_cached_image is not None
and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
return frame.whiteboard_cached_image
user = db.get(User, frame.whiteboard_user_id)
creds = webdav_creds_for(user) if user else None
if creds is None:
return frame.whiteboard_cached_image
try:
png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1])
except whiteboard.WhiteboardRenderError as e:
logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e)
return frame.whiteboard_cached_image
with frame_locked(db, frame.id) as locked:
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."""
"""Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
None if no whiteboard source is configured, credentials are missing
(e.g. the owning user unlinked their WebDAV/CalDAV account), or the
most recent fetch/render failed and nothing was ever cached yet. A
failure after a previous success keeps showing the last good render
rather than going blank for one bad refresh cycle, same reasoning as
get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget.
force=True (the web UI's "Refresh now" button) skips the throttle
entirely -- unlike a device's normal wake, a person clicking a
button means do it right now, not eventually once the cache goes
stale."""
cfg = db.get(WhiteboardWidgetConfig, widget.id)
if not cfg.url or not cfg.user_id:
return None