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
+127 -219
View File
@@ -12,39 +12,32 @@ see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations
import io
import logging
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import calendar_render, mail, photo_queue, quiet_hours
from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..firmware import firmware_path
from ..image_pipeline import render_frame, render_placeholder
from ..models import BatteryLog, Frame
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
from ..widgets import WIDGET_TYPES
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK,
build_manage_content,
get_or_refresh_calendar_events,
get_or_refresh_tasks,
get_or_refresh_weather,
get_or_refresh_whiteboard,
immich_client_for,
immich_creds,
list_assets,
render_asset,
require_configured,
photo_widgets_for_frame,
)
logger = logging.getLogger(__name__)
@@ -53,11 +46,11 @@ router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
built from the request's own base URL: whatever address the device
reached us at is by definition an address that works on this
"""What an unclaimed or widget-less frame displays instead of real
content -- instructions with a QR, rendered at 200 so the device
treats it as a perfectly normal image and never error-loops. The
URLs are built from the request's own base URL: whatever address the
device reached us at is by definition an address that works on this
network."""
base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id:
@@ -77,7 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
["Almost there!", "Add a widget for this frame at", base],
qr_url=base,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
@@ -85,187 +78,90 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
)
def _frame_configured(frame: Frame) -> bool:
url, key = immich_creds(frame)
return bool(url and key and frame.album_id)
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool) -> bytes:
"""The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math) and
hands the results to image_pipeline.render_panel for the single
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
per-mode RENDERERS dict -- a frame can now show several widgets at
once instead of exactly one mode owning the whole panel."""
all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
panel_w, panel_h = logical_render_size(frame.orientation)
regions = []
for widget in all_widgets:
module = WIDGET_TYPES.get(widget.widget_type)
if module is None:
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
px, py, pw, ph = grid.cell_to_pixels(
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
)
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
regions.append(((px, py, pw, ph), img))
return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage,
)
# --- photos mode ---
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request, manage=manage)
client = immich_client_for(frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as 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)
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.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
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.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
# --- calendar mode ---
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
is_normal_wake: bool) -> bytes:
from .common import calendar_sources_for_frame
"""The top-level "what does this frame show right now" entry point.
An unclaimed frame or one with no widgets yet gets the setup
placeholder (needs `request` for its QR URLs -- only available on the
normal-wake path where a real request is on hand, never on an
advance/back button press); otherwise every widget on it gets
composited via _render_widgets. Individual widgets that are
themselves unconfigured show their own small placeholder within
their own region (see app/widgets/*.py) rather than blanking the
whole panel -- a partially-set-up multi-widget frame still shows
whatever IS configured."""
has_widgets = frame.owner_user_id is not None and (
db.scalars(select(Widget.id).where(Widget.frame_id == frame.id).limit(1)).first() is not None
)
if not has_widgets:
if request is None:
return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage
)
return _setup_placeholder(frame, request, manage=manage)
if not calendar_sources_for_frame(db, frame):
return render_placeholder(
["This frame's calendar isn't set up yet",
"Add a calendar in Settings, then include it on",
"this frame's Configuration -> Calendar card."],
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
)
return _render_widgets(db, frame, manage, is_normal_wake)
with frame_locked(db, frame.id) as locked:
if is_normal_wake and locked.calendar_browse_offset != 0:
locked.calendar_browse_offset = 0
browse_offset = locked.calendar_browse_offset
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
week_start = locked.calendar_week_start
week_days = locked.calendar_week_days
week_layout = locked.calendar_week_layout
week_start_offset = locked.calendar_week_start_offset
inlay_wanted = locked.calendar_photo_inlay
events, summary = get_or_refresh_calendar_events(db, frame)
weather_cities = get_or_refresh_weather(db, frame)
# Only ever shown on the week view (see calendar_render._build_week) --
# gated here too so a disabled/other-view frame never pays for the
# fetch, and so None (not just an empty list) reaches render_calendar
# to mean "no tasks slot at all", distinct from "slot reserved but
# nothing outstanding right now".
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
photo_inlay = None
if inlay_wanted and _frame_configured(frame):
def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
"""Executes every (widget, action) binding assigned to this physical
button, in order -- see models.FrameButtonAction and the button-
assignment UI (a later phase). Each action runs to completion (its
own widget_locked span) before the next one starts -- never nested,
since db.widget_locked's underlying lock isn't reentrant (see its own
docstring) -- a button assigned several actions would deadlock
instantly if this looped any other way. One action failing
unexpectedly doesn't block the others, or the eventual re-render,
from happening -- the user pressed a physical button and expects
*something* to happen even if one of several assigned widgets is
having a bad moment."""
actions = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
.order_by(FrameButtonAction.sort_order)
).all()
for action_row in actions:
widget = db.get(Widget, action_row.widget_id)
if widget is None:
continue
module = WIDGET_TYPES.get(widget.widget_type)
action_fn = module.ACTIONS.get(action_row.action) if module else None
if action_fn is None:
continue
try:
client = immich_client_for(frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as 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)
import io
from PIL import Image
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
except HTTPException:
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
return calendar_render.render_calendar(
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
week_days=week_days, week_layout=week_layout, tasks=tasks, week_start_offset=week_start_offset,
)
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""NEXT in calendar mode: moves the displayed period forward one step
(day for agenda, week for week view, month for month view) from
wherever it currently is -- not from "today" -- so repeated presses
walk further forward. See Frame.calendar_browse_offset."""
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset += 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset -= 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
# --- whiteboard mode ---
def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
"""Fetches (throttled, see get_or_refresh_whiteboard) and renders the
frame's configured .whiteboard file. The rendered PNG is treated
exactly like a photo from here on -- run through the same
render_frame composition/quantization pipeline as photos mode,
letterboxed (never cropped: unlike a photo, losing part of a
whiteboard to a crop loses actual content, not just some background)
-- rather than a second parallel image pipeline just for this mode."""
png_bytes = get_or_refresh_whiteboard(db, frame)
if png_bytes is None:
return render_placeholder(
["This frame's whiteboard isn't set up yet",
"Add a WebDAV/Nextcloud whiteboard file URL on",
"this frame's Whiteboard tab."],
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
)
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
return render_frame(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox", manage=manage,
)
def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""NEXT in whiteboard mode: there's no "next" concept for a single
static board, so this instead forces an immediate re-fetch/re-render
bypassing the throttle -- a "check now" button for "someone just
updated the board, show it right away" rather than waiting out
calendar_feed.CHECK_INTERVAL_S."""
with frame_locked(db, frame.id) as locked:
locked.whiteboard_checked_at = 0.0
return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""Same "check now" behavior as _advance_whiteboard_mode -- there's
no separate "back" concept for a single static board either."""
return _advance_whiteboard_mode(db, frame, manage)
RENDERERS = {
"photos": _render_photos_mode,
"calendar": _render_calendar_mode,
"whiteboard": _render_whiteboard_mode,
}
ADVANCE_RENDERERS = {
"photos": _advance_photos_mode,
"calendar": _advance_calendar_mode,
"whiteboard": _advance_whiteboard_mode,
}
BACK_RENDERERS = {
"photos": _back_photos_mode,
"calendar": _back_calendar_mode,
"whiteboard": _back_whiteboard_mode,
}
action_fn(db, frame, widget)
except Exception:
logger.exception(
"Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id
)
@router.get("/frame/config")
@@ -311,42 +207,47 @@ def _manage_flag(request: Request) -> bool:
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Returns the frame's current image. For photos mode: idempotent --
only actually advances to the next photo once refresh_interval_s has
elapsed since the current one was set (see app/photo_queue.py) --
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops.
"""Returns the frame's current image -- every widget on the frame
composited into one panel (see _render_widgets). Each widget's own
render is idempotent in whatever way makes sense for its type (e.g.
a photo widget only actually advances once its own refresh interval
has elapsed, see app/photo_queue.py) -- safe to call as often as the
device wants, including after an unplanned reboot, without skipping
ahead. An unclaimed frame or one with no widgets yet gets a rendered
instruction placeholder (200, not an error) so a fresh device never
error-loops.
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
For calendar mode, this is also the "normal wake" that resets
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
This is also the "normal wake" that resets any calendar widget's
browse position back to today (see app/widgets/calendar.py)."""
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = renderer(db, frame, request, manage, True)
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate move forward -- the next photo in photos mode,
or the next day/week/month in calendar mode -- ignoring
refresh_interval_s. Used by the device's next-photo button."""
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
"""Forces an immediate move forward on whatever widget(s) the NEXT
button is assigned to (see models.FrameButtonAction) -- e.g. the next
photo for a photo widget, or the next day/week/month for a calendar
widget -- then re-renders and returns the whole panel. Used by the
device's next-photo button."""
_run_button_actions(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/advance -- back a photo in photos mode, back
a period in calendar mode. A no-op (still 200, unchanged) if there's
nothing to go back to. Used by the device's back-photo button."""
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
"""The mirror of /frame/advance, for whatever widget(s) the BACK
button is assigned to. A no-op (still 200, unchanged) for any widget
with nothing to go back to. Used by the device's back-photo button."""
_run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
class BatteryReport(BaseModel):
@@ -441,18 +342,25 @@ def frame_firmware(frame: Frame = Depends(require_device)):
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame --
not any arbitrary Immich asset id -- as a second layer even a leaked
token wouldn't bypass."""
require_configured(frame)
Also scoped to the photo currently showing or queued on one of THIS
frame's own photo widgets -- not any arbitrary Immich asset id -- as
a second layer even a leaked token wouldn't bypass."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
photo_widgets = photo_widgets_for_frame(db, frame)
showing_or_queued = any(
asset_id == cfg.current_asset_id or asset_id in cfg.queue
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
)
if not showing_or_queued:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)