From 37bd657299c6eb17aa26e91e6bb2f6af63c83500 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Fri, 24 Jul 2026 09:26:28 -0400
Subject: [PATCH] Widget system Phase 2: full cutover to widget-based rendering
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.
---
server/app/routers/api_frames.py | 390 ++++++++++--------
server/app/routers/common.py | 376 +++++++----------
server/app/routers/device.py | 346 ++++++----------
server/app/routers/frame_pages.py | 51 ++-
server/app/routers/manage.py | 43 +-
server/app/static/frame_calendar.js | 9 +-
server/app/static/frame_header.js | 36 +-
server/app/static/theme.css | 5 -
server/app/templates/_frame_mode_picker.html | 4 -
server/app/templates/_frame_mode_select.html | 5 -
server/app/templates/_frame_tabs.html | 4 +-
server/app/templates/app_base.html | 1 -
server/app/templates/frame_calendar.html | 42 +-
server/app/templates/frame_config.html | 1 -
server/app/templates/frame_photos.html | 11 +-
server/app/templates/frame_stats.html | 1 -
server/app/templates/frame_whiteboard.html | 8 +-
server/app/widgets/__init__.py | 9 +-
server/app/widgets/calendar.py | 14 +-
server/app/widgets/photos.py | 8 +-
server/app/widgets/whiteboard.py | 6 +-
server/tests/test_device_widget_dispatch.py | 177 ++++++++
server/tests/test_manage_widget_dispatch.py | 121 ++++++
server/tests/test_permission_boundaries.py | 103 ++++-
.../test_whiteboard_refresh_and_browse.py | 65 +--
server/tests/test_widgets_calendar.py | 25 ++
26 files changed, 1070 insertions(+), 791 deletions(-)
delete mode 100644 server/app/templates/_frame_mode_picker.html
delete mode 100644 server/app/templates/_frame_mode_select.html
create mode 100644 server/tests/test_device_widget_dispatch.py
create mode 100644 server/tests/test_manage_widget_dispatch.py
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index fddf590..eb1e3e3 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
-from ..db import frame_locked, get_db
+from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
DEFAULT_DISPLAY_MODE,
DISPLAY_MODES,
@@ -36,23 +36,23 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
-from ..models import BatteryLog, Frame, FrameCalendar
+from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, WhiteboardWidgetConfig
from .common import (
- FRAME_MODES,
OVERDUE_FACTOR,
battery_estimate_s,
calendar_sources_for_frame,
fetch_source_and_faces,
- get_or_refresh_calendar_events,
- get_or_refresh_tasks,
- get_or_refresh_weather,
- get_or_refresh_whiteboard,
+ get_or_refresh_calendar_events_for_widget,
+ get_or_refresh_tasks_for_widget,
+ get_or_refresh_weather_for_widget,
+ get_or_refresh_whiteboard_for_widget,
immich_client_for,
immich_creds,
list_assets,
- require_configured,
+ photo_widget_config_or_404,
valid_http_url,
webdav_creds_for,
+ widget_of_type,
)
logger = logging.getLogger(__name__)
@@ -100,9 +100,7 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
- mode: str | None = Form(None),
calendar_view: str | None = Form(None),
- calendar_photo_inlay: bool | None = Form(None),
calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None),
@@ -113,29 +111,32 @@ def api_config_save(
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
+ """Partial update, split across up to three sequential lock spans --
+ frame-level settings, the frame's photo widget, the frame's calendar
+ widget -- rather than one, now that those settings live on separate
+ rows (see models.Widget's per-type extension tables). Never nested
+ (see db.widget_locked's own docstring on why that would deadlock).
+
+ `mode` and `calendar_photo_inlay` are no longer accepted here: mode
+ no longer governs anything (a frame's widgets do), and photo inlay
+ has no widget-system equivalent (place an independent photo widget
+ alongside instead -- see CalendarWidgetConfig's docstring). Both are
+ harmless no-ops if an old cached page still POSTs them -- FastAPI
+ silently ignores form fields with no matching parameter.
+
+ Until the widget-placement UI (a later phase) lets a frame have more
+ than one widget of a type, "the photo widget" / "the calendar
+ widget" below unambiguously means the frame's single auto-migrated
+ one (see widget_of_type) -- these fields are silent no-ops if the
+ frame doesn't have one yet, same posture as any other partial update
+ whose target doesn't exist."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
- if album_id is not None and album_id != cfg.album_id:
- # A newly selected album starts clean -- the old current photo
- # and queue don't mean anything in the new album's context.
- cfg.current_asset_id = ""
- cfg.current_asset_set_at = 0.0
- cfg.queue = []
- cfg.queue_cursor = 0
- cfg.history = []
- cfg.excluded_asset_ids = []
- cfg.album_id = album_id
- if order is not None:
- cfg.order = order if order in ("sequential", "shuffle") else "sequential"
if refresh_interval_s is not None:
cfg.refresh_interval_s = max(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
- if display_mode is not None:
- cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
- if queue_target_len is not None:
- cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
if quiet_hours_enabled is not None:
@@ -173,46 +174,74 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
- if mode is not None:
- cfg.mode = mode if mode in FRAME_MODES else "photos"
- if calendar_view is not None:
- new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
- if new_view != cfg.calendar_view:
- # A stale offset means something different in a different
- # view's units (days vs. weeks vs. months) -- same
- # reasoning as album_id's reset above.
- cfg.calendar_browse_offset = 0
- cfg.calendar_view = new_view
- if calendar_photo_inlay is not None:
- cfg.calendar_photo_inlay = calendar_photo_inlay
- if calendar_week_start is not None:
- cfg.calendar_week_start = max(0, min(6, calendar_week_start))
- if calendar_week_days is not None:
- new_days = max(2, min(10, calendar_week_days))
- if new_days != cfg.calendar_week_days:
- # A stale offset counts a different-sized page under the
- # old day count -- same reasoning as calendar_view's own
- # reset below.
- cfg.calendar_browse_offset = 0
- cfg.calendar_week_days = new_days
- if calendar_week_layout is not None:
- cfg.calendar_week_layout = calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
- if calendar_week_start_offset is not None:
- new_offset = max(-30, min(30, calendar_week_start_offset))
- if new_offset != cfg.calendar_week_start_offset:
- cfg.calendar_browse_offset = 0
- cfg.calendar_week_start_offset = new_offset
- if calendar_weather_enabled is not None:
- cfg.calendar_weather_enabled = calendar_weather_enabled
- if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
- if calendar_weather_units != cfg.calendar_weather_units:
- # Cached forecasts are in the old unit -- force a refetch
- # rather than showing stale numbers under a new unit label.
- cfg.calendar_weather_checked_at = 0.0
- cfg.calendar_weather_units = calendar_weather_units
- if calendar_tasks_enabled is not None:
- cfg.calendar_tasks_enabled = calendar_tasks_enabled
cfg.stats_config_saves += 1
+
+ photo_widget = widget_of_type(db, frame, "photos")
+ photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len))
+ if photo_widget and photo_fields_present:
+ with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg):
+ if album_id is not None and album_id != pcfg.album_id:
+ # A newly selected album starts clean -- the old current
+ # photo and queue don't mean anything in the new album's
+ # context.
+ pcfg.current_asset_id = ""
+ pcfg.current_asset_set_at = 0.0
+ pcfg.queue = []
+ pcfg.queue_cursor = 0
+ pcfg.history = []
+ pcfg.excluded_asset_ids = []
+ pcfg.album_id = album_id
+ if order is not None:
+ pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
+ if display_mode is not None:
+ pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
+ if queue_target_len is not None:
+ pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
+
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ calendar_fields_present = any(v is not None for v in (
+ calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout,
+ calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled,
+ ))
+ if calendar_widget and calendar_fields_present:
+ with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg):
+ if calendar_view is not None:
+ new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
+ if new_view != ccfg.view:
+ # A stale offset means something different in a
+ # different view's units (days vs. weeks vs. months).
+ ccfg.browse_offset = 0
+ ccfg.view = new_view
+ if calendar_week_start is not None:
+ ccfg.week_start = max(0, min(6, calendar_week_start))
+ if calendar_week_days is not None:
+ new_days = max(2, min(10, calendar_week_days))
+ if new_days != ccfg.week_days:
+ # A stale offset counts a different-sized page under
+ # the old day count.
+ ccfg.browse_offset = 0
+ ccfg.week_days = new_days
+ if calendar_week_layout is not None:
+ ccfg.week_layout = (
+ calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
+ )
+ if calendar_week_start_offset is not None:
+ new_offset = max(-30, min(30, calendar_week_start_offset))
+ if new_offset != ccfg.week_start_offset:
+ ccfg.browse_offset = 0
+ ccfg.week_start_offset = new_offset
+ if calendar_weather_enabled is not None:
+ ccfg.weather_enabled = calendar_weather_enabled
+ if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
+ if calendar_weather_units != ccfg.weather_units:
+ # Cached forecasts are in the old unit -- force a
+ # refetch rather than showing stale numbers under a
+ # new unit label.
+ ccfg.weather_checked_at = 0.0
+ ccfg.weather_units = calendar_weather_units
+ if calendar_tasks_enabled is not None:
+ ccfg.tasks_enabled = calendar_tasks_enabled
+
return {"status": "saved"}
@@ -250,28 +279,29 @@ def api_queue(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
user = require_user_api(request, db)
- require_configured(frame)
+ photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
- assets = list_assets(client, frame.album_id)
+ assets = list_assets(client, pcfg.album_id)
- with frame_locked(db, frame.id) as cfg:
- photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
- photo_queue.sync_queue_length(cfg, assets)
+ with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.get_current(locked_pcfg, assets, locked_frame,
+ in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
+ photo_queue.sync_queue_length(locked_pcfg, assets)
snapshot = {
- "current_asset_id": cfg.current_asset_id,
- "queue": list(cfg.queue),
- "last_seen": cfg.last_seen,
- "overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
- "firmware_version": cfg.device_firmware_version,
- "firmware_available": cfg.firmware_available_version,
- "battery_percent": cfg.battery_percent,
- "battery_as_of": cfg.battery_as_of,
- "battery_estimate_s": battery_estimate_s(cfg, db),
- "controller_id": cfg.controlled_by_user_id,
+ "current_asset_id": locked_pcfg.current_asset_id,
+ "queue": list(locked_pcfg.queue),
+ "last_seen": locked_frame.last_seen,
+ "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR,
+ "firmware_version": locked_frame.device_firmware_version,
+ "firmware_available": locked_frame.firmware_available_version,
+ "battery_percent": locked_frame.battery_percent,
+ "battery_as_of": locked_frame.battery_as_of,
+ "battery_estimate_s": battery_estimate_s(locked_frame, db),
+ "controller_id": locked_frame.controlled_by_user_id,
"controller": (
- (cfg.controlled_by.display_name or cfg.controlled_by.username)
- if cfg.controlled_by
+ (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
+ if locked_frame.controlled_by
else None
),
}
@@ -330,7 +360,10 @@ def api_queue_reorder(
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
- with frame_locked(db, frame.id) as cfg:
+ photo_widget = widget_of_type(db, frame, "photos")
+ if photo_widget is None:
+ raise HTTPException(404, "No photo widget on this frame")
+ with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
@@ -351,7 +384,10 @@ def api_queue_promote(
"""Moves a single photo to the front of the queue -- "Show next".
Unlike reorder, doesn't depend on the client knowing the queue's
exact current order, so it can't fail from staleness."""
- with frame_locked(db, frame.id) as cfg:
+ photo_widget = widget_of_type(db, frame, "photos")
+ if photo_widget is None:
+ raise HTTPException(404, "No photo widget on this frame")
+ with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
@@ -370,24 +406,26 @@ def api_queue_remove(
):
"""Permanently removes a photo from this frame's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
- require_configured(frame)
+ photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
- assets = list_assets(client, frame.album_id)
+ assets = list_assets(client, pcfg.album_id)
- with frame_locked(db, frame.id) as cfg:
- photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg)
+ with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
return {"status": "removed"}
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
-def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
+def api_thumbnail(
+ asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
+):
"""Scoped to what this frame is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails
for arbitrary asset ids in the owner's Immich library, only the
frame's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
- require_configured(frame)
- if asset_id != frame.current_asset_id and asset_id not in frame.queue:
+ _, pcfg = photo_widget_config_or_404(db, frame)
+ if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
try:
@@ -397,19 +435,22 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
return Response(content=content, media_type=content_type)
-def _current_asset_id(frame: Frame, db: Session) -> str:
+def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]:
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
picks a current photo if none is set yet, otherwise just reads it,
- never advances early."""
- require_configured(frame)
+ never advances early. Returns the photo widget's own config
+ alongside the asset id, since callers (api_preview_rendered) also
+ need its display_mode."""
+ photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
- assets = list_assets(client, frame.album_id)
- with frame_locked(db, frame.id) as cfg:
- photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
- asset_id = cfg.current_asset_id
+ assets = list_assets(client, pcfg.album_id)
+ with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.get_current(locked_pcfg, assets, locked_frame,
+ in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
+ asset_id = locked_pcfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
- return asset_id
+ return asset_id, pcfg
@router.get("/api/frames/{frame_id}/preview/original")
@@ -417,7 +458,7 @@ def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session
"""The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the Configuration tab's
before/after comparison."""
- asset_id = _current_asset_id(frame, db)
+ asset_id, _ = _current_asset_id(frame, db)
client = immich_client_for(frame)
try:
jpeg_bytes = client.download_asset_preview(asset_id)
@@ -432,13 +473,15 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
pipeline (display mode, palette, color/contrast/dithering) and
exported as a PNG -- the "how it will look on the frame" side of the
comparison. Not a live preview of unsaved slider values; reflects
- whatever's currently saved."""
- asset_id = _current_asset_id(frame, db)
+ whatever's currently saved. display_mode comes from the photo
+ widget's own config now (palette/color/contrast/dither stay
+ frame-level -- one physical panel, one set of those)."""
+ asset_id, pcfg = _current_asset_id(frame, db)
client = immich_client_for(frame)
- source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
+ source, faces = fetch_source_and_faces(client, pcfg.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,
+ display_mode=pcfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
return Response(content=png, media_type="image/png")
@@ -486,9 +529,11 @@ def api_calendar_select(
row.included = body.included
if body.calendar_label:
row.calendar_label = body.calendar_label
- # Force this frame's merged cache to pick up the change promptly
- # rather than waiting out the throttle.
- frame.calendar_checked_at = 0.0
+ # Force the frame's calendar widget's merged cache to pick up the
+ # change promptly rather than waiting out the throttle.
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is not None:
+ db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.included}
@@ -527,59 +572,40 @@ def api_calendar_color(
if row is None:
raise HTTPException(404, "Not included on this frame")
row.color_index = body.color_index
- frame.calendar_checked_at = 0.0
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is not None:
+ db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "color_index": row.color_index}
-def _calendar_photo_inlay(frame: Frame, db: Session):
- """The photo-inlay's source image (any view now, not just agenda), or
- None if inlay is off or the frame's photos-mode album isn't
- configured. Shared shape between the live render (routers/device.py's
- _render_calendar_mode) and this preview endpoint; small enough that
- duplicating rather than factoring out is fine, since the two call
- sites differ slightly in error handling."""
- if not frame.calendar_photo_inlay:
- return None
- url, key = immich_creds(frame)
- if not (url and key and frame.album_id):
- return None
- 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 not asset_id:
- return None
- import io
-
- from PIL import Image
-
- return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
- except HTTPException:
- return None
-
-
@router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same merged, cached event set a live device render would use
-- not a live preview of an unsaved calendar_view choice, same
- "reflects what's currently saved" convention as preview/rendered."""
+ "reflects what's currently saved" convention as preview/rendered.
+ No photo_inlay parameter anymore -- that's not a widget-system
+ concept (see CalendarWidgetConfig's docstring); place an independent
+ photo widget alongside instead."""
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is None:
+ raise HTTPException(400, "No calendar widget on this frame yet")
if not calendar_sources_for_frame(db, frame):
raise HTTPException(400, "No calendars included on this frame yet")
- events, summary = get_or_refresh_calendar_events(db, frame)
- photo_inlay = _calendar_photo_inlay(frame, db)
- view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
- weather_cities = get_or_refresh_weather(db, frame)
- tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
+ ccfg = db.get(CalendarWidgetConfig, calendar_widget.id)
+ events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget)
+ weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None
+ tasks = (
+ get_or_refresh_tasks_for_widget(db, frame, calendar_widget)
+ if (ccfg.view == "week" and ccfg.tasks_enabled) else None
+ )
png = calendar_render.render_calendar_preview_png(
- events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
- palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
- week_start=frame.calendar_week_start,
- weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
- week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks,
- week_start_offset=frame.calendar_week_start_offset,
+ events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
+ palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=None, fetch_summary=summary,
+ week_start=ccfg.week_start,
+ weather_cities=weather_cities, weather_units=ccfg.weather_units,
+ week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
+ week_start_offset=ccfg.week_start_offset,
)
return Response(content=png, media_type="image/png")
@@ -606,15 +632,18 @@ def api_tasks_source(
its owner can point the frame at one of their calendars to begin
with."""
user = require_user_api(request, db)
- with frame_locked(db, frame.id) as cfg:
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is None:
+ raise HTTPException(404, "No calendar widget on this frame yet")
+ with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
if body.calendar_key is None:
- cfg.calendar_tasks_user_id = None
- cfg.calendar_tasks_calendar_key = None
- cfg.calendar_tasks_cached = None
+ cfg.tasks_user_id = None
+ cfg.tasks_calendar_key = None
+ cfg.tasks_cached = None
else:
- cfg.calendar_tasks_user_id = user.id
- cfg.calendar_tasks_calendar_key = body.calendar_key
- cfg.calendar_tasks_checked_at = 0.0 # pick up the change promptly
+ cfg.tasks_user_id = user.id
+ cfg.tasks_calendar_key = body.calendar_key
+ cfg.tasks_checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key}
@@ -636,18 +665,21 @@ def api_whiteboard_source(
it, but anyone linked to the frame can clear it, same as muting a
shared calendar."""
user = require_user_api(request, db)
- with frame_locked(db, frame.id) as cfg:
+ whiteboard_widget = widget_of_type(db, frame, "whiteboard")
+ if whiteboard_widget is None:
+ raise HTTPException(404, "No whiteboard widget on this frame yet")
+ with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg):
if body.url is None:
- cfg.whiteboard_user_id = None
- cfg.whiteboard_url = ""
- cfg.whiteboard_cached_image = None
+ cfg.user_id = None
+ cfg.url = ""
+ cfg.cached_image = None
else:
stripped = body.url.strip()
if not valid_http_url(stripped):
raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
- cfg.whiteboard_user_id = user.id
- cfg.whiteboard_url = stripped
- cfg.whiteboard_checked_at = 0.0 # pick up the change promptly
+ cfg.user_id = user.id
+ cfg.url = stripped
+ cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "url": body.url}
@@ -695,9 +727,13 @@ def api_preview_whiteboard(
Excalidraw export, same convention as preview/rendered and
preview/calendar. force=True (the "Refresh now" button, as opposed
to just reopening this tab) bypasses the fetch throttle."""
- png_bytes = get_or_refresh_whiteboard(db, frame, force=force)
+ whiteboard_widget = widget_of_type(db, frame, "whiteboard")
+ if whiteboard_widget is None:
+ raise HTTPException(400, "No whiteboard widget on this frame yet")
+ wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
+ png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force)
if png_bytes is None:
- if not frame.whiteboard_url:
+ if not wcfg.url:
raise HTTPException(400, "No whiteboard configured on this frame yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
import io
@@ -726,17 +762,20 @@ def api_weather_city_add(
to this frame's weather strip -- a frame-wide display setting (like
calendar_view), not personal data, so this is gated the same way as
api_config_save rather than the calendar-select owner/mute split."""
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is None:
+ raise HTTPException(404, "No calendar widget on this frame yet")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
- with frame_locked(db, frame.id) as cfg:
- cities = list(cfg.calendar_weather_cities or [])
+ with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
+ cities = list(cfg.weather_cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this frame's list")
cities.append(city)
- cfg.calendar_weather_cities = cities
- cfg.calendar_weather_checked_at = 0.0 # pick up the new city promptly
+ cfg.weather_cities = cities
+ cfg.weather_checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
@@ -750,11 +789,14 @@ def api_weather_city_remove(
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
- with frame_locked(db, frame.id) as cfg:
- cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label]
- cfg.calendar_weather_cities = cities
- cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label]
- cfg.calendar_weather_cached = cached
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is None:
+ raise HTTPException(404, "No calendar widget on this frame yet")
+ with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
+ cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
+ cfg.weather_cities = cities
+ cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
+ cfg.weather_cached = cached
return {"status": "saved"}
diff --git a/server/app/routers/common.py b/server/app/routers/common.py
index a1c9370..e06b4e2 100644
--- a/server/app/routers/common.py
+++ b/server/app/routers/common.py
@@ -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
diff --git a/server/app/routers/device.py b/server/app/routers/device.py
index 13c773e..73bd160 100644
--- a/server/app/routers/device.py
+++ b/server/app/routers/device.py
@@ -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)
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index be46090..3039453 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -20,9 +20,9 @@ from ..image_pipeline import (
PALETTE_LABELS,
palette_to_hex,
)
-from ..models import Frame, FrameCalendar, User, UserFrame
+from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig
from ..quiet_hours import ALL_TIMEZONES
-from .common import shell_context
+from .common import shell_context, widget_of_type
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -36,15 +36,27 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
ctx = shell_context(request, db, user, active_frame=frame)
- ctx.update({"frame": frame, "active_tab": tab, **extra})
+ ctx.update({
+ "frame": frame, "active_tab": tab,
+ "has_calendar_widget": widget_of_type(db, frame, "calendar") is not None,
+ "has_whiteboard_widget": widget_of_type(db, frame, "whiteboard") is not None,
+ **extra,
+ })
return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
+ frame = db.get(Frame, frame_id)
+ photo_cfg = None
+ if frame is not None:
+ photo_widget = widget_of_type(db, frame, "photos")
+ if photo_widget is not None:
+ photo_cfg = db.get(PhotoWidgetConfig, photo_widget.id)
return _frame_page(
request, db, frame_id, "frame_photos.html", "photos",
display_mode_labels=DISPLAY_MODE_LABELS,
+ photo_cfg=photo_cfg,
)
@@ -99,19 +111,19 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
return result
-def _tasks_source_info(db: Session, frame: Frame) -> dict | None:
+def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None:
"""Whose CalDAV calendar this frame's week-view task list currently
pulls from, and its label -- for showing "using 's Chores
list" to everyone linked, not just whoever set it. None if no
source is configured."""
- if not frame.calendar_tasks_user_id or not frame.calendar_tasks_calendar_key:
+ if calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
return None
- user = db.get(User, frame.calendar_tasks_user_id)
+ user = db.get(User, calendar_cfg.tasks_user_id)
if user is None:
return None
- label = frame.calendar_tasks_calendar_key
+ label = calendar_cfg.tasks_calendar_key
for c in (user.calendar_caldav_calendars or []):
- if f"caldav:{c['href']}" == frame.calendar_tasks_calendar_key:
+ if f"caldav:{c['href']}" == calendar_cfg.tasks_calendar_key:
label = c.get("display_name") or label
break
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
@@ -137,8 +149,13 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
viewer = current_user(request, db)
frame = db.get(Frame, frame_id)
viewer_task_calendars = []
+ calendar_cfg = None
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")]
+ if frame is not None:
+ calendar_widget = widget_of_type(db, frame, "calendar")
+ if calendar_widget is not None:
+ calendar_cfg = db.get(CalendarWidgetConfig, calendar_widget.id)
return _frame_page(
request, db, frame_id, "frame_calendar.html", "calendar",
calendar_views=CALENDAR_VIEW_LABELS,
@@ -148,20 +165,21 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
viewer_task_calendars=viewer_task_calendars,
- tasks_source=_tasks_source_info(db, frame) if frame is not None else None,
+ calendar_cfg=calendar_cfg,
+ tasks_source=_tasks_source_info(db, calendar_cfg),
)
-def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None:
+def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig | None) -> dict | None:
"""Whose account this frame's whiteboard currently fetches with, for
showing "using 's account" to everyone linked, not just
whoever set it. None if no source is configured."""
- if not frame.whiteboard_user_id or not frame.whiteboard_url:
+ if whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url:
return None
- user = db.get(User, frame.whiteboard_user_id)
+ user = db.get(User, whiteboard_cfg.user_id)
if user is None:
return None
- return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url}
+ return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
@@ -169,13 +187,18 @@ def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends
viewer = current_user(request, db)
frame = db.get(Frame, frame_id)
viewer_has_webdav_creds = False
+ whiteboard_cfg = None
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
viewer_has_webdav_creds = bool(
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
)
+ if frame is not None:
+ whiteboard_widget = widget_of_type(db, frame, "whiteboard")
+ if whiteboard_widget is not None:
+ whiteboard_cfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
return _frame_page(
request, db, frame_id, "frame_whiteboard.html", "whiteboard",
- whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None,
+ whiteboard_source=_whiteboard_source_info(db, whiteboard_cfg),
viewer_has_webdav_creds=viewer_has_webdav_creds,
)
diff --git a/server/app/routers/manage.py b/server/app/routers/manage.py
index 40d5bec..820fdf2 100644
--- a/server/app/routers/manage.py
+++ b/server/app/routers/manage.py
@@ -18,9 +18,9 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
-from ..db import frame_locked, get_db
+from ..db import get_db, widget_locked
from ..models import Frame
-from .common import immich_client_for, list_assets, require_configured
+from .common import immich_client_for, list_assets, photo_widget_config_or_404
logger = logging.getLogger(__name__)
@@ -46,15 +46,16 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
@router.get("/api/m/{manage_token}/queue")
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
- require_configured(frame)
+ photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
- assets = list_assets(client, frame.album_id)
+ assets = list_assets(client, pcfg.album_id)
- with frame_locked(db, frame.id) as 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)
+ with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.get_current(locked_pcfg, assets, locked_frame,
+ in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
+ photo_queue.sync_queue_length(locked_pcfg, assets)
+ current = locked_pcfg.current_asset_id
+ queue = list(locked_pcfg.queue)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
@@ -76,7 +77,8 @@ def manage_promote(
frame: Frame = Depends(require_manage),
db: Session = Depends(get_db),
):
- with frame_locked(db, frame.id) as cfg:
+ photo_widget, _ = photo_widget_config_or_404(db, frame)
+ with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
@@ -87,29 +89,30 @@ def manage_promote(
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Advances the server-side current photo; the panel itself updates
on the device's next wake (or its next-photo button)."""
- require_configured(frame)
+ photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
- assets = list_assets(client, frame.album_id)
- with frame_locked(db, frame.id) as cfg:
- photo_queue.advance_forced(cfg, assets, cfg)
+ assets = list_assets(client, pcfg.album_id)
+ with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
return {"status": "saved"}
@router.post("/api/m/{manage_token}/back")
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
- require_configured(frame)
+ photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
- assets = list_assets(client, frame.album_id)
- with frame_locked(db, frame.id) as cfg:
- photo_queue.back_forced(cfg, assets, cfg)
+ assets = list_assets(client, pcfg.album_id)
+ with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
+ photo_queue.back_forced(locked_pcfg, assets, locked_frame)
return {"status": "saved"}
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
-def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
+def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Thumbnails scoped to what this frame is actually showing/queuing --
the manage token must not become a general Immich proxy."""
- if asset_id != frame.current_asset_id and asset_id not in frame.queue:
+ _, pcfg = photo_widget_config_or_404(db, frame)
+ if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
try:
diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js
index 233adae..6343798 100644
--- a/server/app/static/frame_calendar.js
+++ b/server/app/static/frame_calendar.js
@@ -1,7 +1,7 @@
-// Calendar tab: view/week-start/photo-inlay settings, per-user opt-in,
-// and the rendered preview. Extracted from frame_config.js when the
-// Calendar card became its own tab (window.FRAME_API is set by the
-// template; checkboxes are always sent explicitly as "true"/"false").
+// Calendar tab: view/week-start settings, per-user opt-in, and the
+// rendered preview. Extracted from frame_config.js when the Calendar
+// card became its own tab (window.FRAME_API is set by the template;
+// checkboxes are always sent explicitly as "true"/"false").
// Week-view-only settings (days/layout/start-offset) only matter when
// View is actually "Week"; "Week starts on" also matters for Month, so
@@ -31,7 +31,6 @@ document.getElementById('calendar-config-form').addEventListener('submit', async
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
- calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js
index c5b00e3..8bb29c1 100644
--- a/server/app/static/frame_header.js
+++ b/server/app/static/frame_header.js
@@ -1,8 +1,8 @@
// Page-header controls shared by every per-frame page (Photos/
-// Configuration/Calendar/Stats): the frame-name pencil-edit and the
-// mode selector, both now living outside the tab structure since they
-// apply regardless of which tab is open. Depends on window.FRAME_API
-// (set per-page) and common.js's showStatus/apiError.
+// Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit,
+// living outside the tab structure since it applies regardless of which
+// tab is open. Depends on window.FRAME_API (set per-page) and
+// common.js's showStatus/apiError.
(function () {
var view = document.getElementById('frame-name-view');
@@ -55,31 +55,3 @@
if (e.key === 'Escape') closeEdit();
});
})();
-
-(function () {
- var sel = document.getElementById('frame-mode-select');
- if (!sel || !window.FRAME_API) return;
- var previous = sel.value;
-
- sel.addEventListener('change', async function () {
- var mode = sel.value;
- try {
- const resp = await fetch(`${window.FRAME_API}/config`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: new URLSearchParams({ mode }),
- });
- if (!resp.ok) throw new Error(await apiError(resp));
- previous = mode;
- var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
- showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`);
- var calTab = document.querySelector('.tabs a[href$="/calendar"]');
- if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar');
- var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]');
- if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard');
- } catch (e) {
- sel.value = previous;
- showStatus(false, e.message);
- }
- });
-})();
diff --git a/server/app/static/theme.css b/server/app/static/theme.css
index d57d199..3d521bb 100644
--- a/server/app/static/theme.css
+++ b/server/app/static/theme.css
@@ -514,8 +514,6 @@ code {
}
.frame-name-edit button { margin-top: 0; }
-.frame-mode-select { width: auto; margin-top: 0; padding: 7px 10px; font-size: 13px; font-weight: 600; }
-
.control-banner {
display: flex;
align-items: center;
@@ -558,9 +556,6 @@ code {
.device-status-row { column-gap: 16px; }
}
-.mode-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; }
-.mode-picker-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
-
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; }
.sidebar-backdrop { display: none; }
diff --git a/server/app/templates/_frame_mode_picker.html b/server/app/templates/_frame_mode_picker.html
deleted file mode 100644
index dc4e26e..0000000
--- a/server/app/templates/_frame_mode_picker.html
+++ /dev/null
@@ -1,4 +0,0 @@
-
- Mode
- {% include "_frame_mode_select.html" %}
-
This frame is currently in Photos mode --
- settings below take effect once you switch it to Calendar mode
- using the selector at the top of the page.
+ {% if not has_calendar_widget %}
+
This frame doesn't have a Calendar widget on
+ screen yet -- settings below won't show up anywhere until one is added.