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
+216 -174
View File
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather, webdav_client 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 ..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 ( from ..image_pipeline import (
DEFAULT_DISPLAY_MODE, DEFAULT_DISPLAY_MODE,
DISPLAY_MODES, DISPLAY_MODES,
@@ -36,23 +36,23 @@ from ..image_pipeline import (
render_preview_png, render_preview_png,
) )
from ..firmware import firmware_path, parse_app_version 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 ( from .common import (
FRAME_MODES,
OVERDUE_FACTOR, OVERDUE_FACTOR,
battery_estimate_s, battery_estimate_s,
calendar_sources_for_frame, calendar_sources_for_frame,
fetch_source_and_faces, fetch_source_and_faces,
get_or_refresh_calendar_events, get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks, get_or_refresh_tasks_for_widget,
get_or_refresh_weather, get_or_refresh_weather_for_widget,
get_or_refresh_whiteboard, get_or_refresh_whiteboard_for_widget,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
list_assets, list_assets,
require_configured, photo_widget_config_or_404,
valid_http_url, valid_http_url,
webdav_creds_for, webdav_creds_for,
widget_of_type,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -100,9 +100,7 @@ def api_config_save(
color_boost: float | None = Form(None), color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None), contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None), dither_strength: float | None = Form(None),
mode: str | None = Form(None),
calendar_view: 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_start: int | None = Form(None),
calendar_week_days: int | None = Form(None), calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None), calendar_week_layout: str | None = Form(None),
@@ -113,29 +111,32 @@ def api_config_save(
frame: Frame = Depends(require_frame_control), frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db), 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: with frame_locked(db, frame.id) as cfg:
if name is not None: if name is not None:
cfg.name = name.strip()[:64] or cfg.name 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: if refresh_interval_s is not None:
cfg.refresh_interval_s = max( cfg.refresh_interval_s = max(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s) 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: if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape" cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
if quiet_hours_enabled is not None: 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)) cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None: if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength)) 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 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"} return {"status": "saved"}
@@ -250,28 +279,29 @@ def api_queue(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
): ):
user = require_user_api(request, 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) 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: with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) photo_queue.get_current(locked_pcfg, assets, locked_frame,
photo_queue.sync_queue_length(cfg, assets) in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
photo_queue.sync_queue_length(locked_pcfg, assets)
snapshot = { snapshot = {
"current_asset_id": cfg.current_asset_id, "current_asset_id": locked_pcfg.current_asset_id,
"queue": list(cfg.queue), "queue": list(locked_pcfg.queue),
"last_seen": cfg.last_seen, "last_seen": locked_frame.last_seen,
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR, "overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR,
"firmware_version": cfg.device_firmware_version, "firmware_version": locked_frame.device_firmware_version,
"firmware_available": cfg.firmware_available_version, "firmware_available": locked_frame.firmware_available_version,
"battery_percent": cfg.battery_percent, "battery_percent": locked_frame.battery_percent,
"battery_as_of": cfg.battery_as_of, "battery_as_of": locked_frame.battery_as_of,
"battery_estimate_s": battery_estimate_s(cfg, db), "battery_estimate_s": battery_estimate_s(locked_frame, db),
"controller_id": cfg.controlled_by_user_id, "controller_id": locked_frame.controlled_by_user_id,
"controller": ( "controller": (
(cfg.controlled_by.display_name or cfg.controlled_by.username) (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
if cfg.controlled_by if locked_frame.controlled_by
else None else None
), ),
} }
@@ -330,7 +360,10 @@ def api_queue_reorder(
the client sent that's no longer actually queued is dropped, and any 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 ID the server has that the client didn't know about is appended
rather than lost.""" 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) 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 body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)] 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". """Moves a single photo to the front of the queue -- "Show next".
Unlike reorder, doesn't depend on the client knowing the queue's Unlike reorder, doesn't depend on the client knowing the queue's
exact current order, so it can't fail from staleness.""" 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: if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming 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] 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 """Permanently removes a photo from this frame's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation().""" 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) 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: with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg) photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
return {"status": "removed"} return {"status": "removed"}
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}") @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 """Scoped to what this frame is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails 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 for arbitrary asset ids in the owner's Immich library, only the
frame's own curated album. Same rule device.frame_share and frame's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce.""" manage.manage_thumbnail already enforce."""
require_configured(frame) _, pcfg = photo_widget_config_or_404(db, frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue: if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
raise HTTPException(404, "Not on this frame") raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame) client = immich_client_for(frame)
try: 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) 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 -- """Same idempotent get_current() dance /api/frames/{id}/queue uses --
picks a current photo if none is set yet, otherwise just reads it, picks a current photo if none is set yet, otherwise just reads it,
never advances early.""" never advances early. Returns the photo widget's own config
require_configured(frame) 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) 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: with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) photo_queue.get_current(locked_pcfg, assets, locked_frame,
asset_id = cfg.current_asset_id in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
asset_id = locked_pcfg.current_asset_id
if not asset_id: if not asset_id:
raise HTTPException(404, "No current photo") raise HTTPException(404, "No current photo")
return asset_id return asset_id, pcfg
@router.get("/api/frames/{frame_id}/preview/original") @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, """The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the Configuration tab's unprocessed -- the "now displaying" side of the Configuration tab's
before/after comparison.""" before/after comparison."""
asset_id = _current_asset_id(frame, db) asset_id, _ = _current_asset_id(frame, db)
client = immich_client_for(frame) client = immich_client_for(frame)
try: try:
jpeg_bytes = client.download_asset_preview(asset_id) 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 pipeline (display mode, palette, color/contrast/dithering) and
exported as a PNG -- the "how it will look on the frame" side of the 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 comparison. Not a live preview of unsaved slider values; reflects
whatever's currently saved.""" whatever's currently saved. display_mode comes from the photo
asset_id = _current_asset_id(frame, db) 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) 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( png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, 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, contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -486,9 +529,11 @@ def api_calendar_select(
row.included = body.included row.included = body.included
if body.calendar_label: if body.calendar_label:
row.calendar_label = body.calendar_label row.calendar_label = body.calendar_label
# Force this frame's merged cache to pick up the change promptly # Force the frame's calendar widget's merged cache to pick up the
# rather than waiting out the throttle. # change promptly rather than waiting out the throttle.
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() db.commit()
return {"status": "saved", "included": row.included} return {"status": "saved", "included": row.included}
@@ -527,59 +572,40 @@ def api_calendar_color(
if row is None: if row is None:
raise HTTPException(404, "Not included on this frame") raise HTTPException(404, "Not included on this frame")
row.color_index = body.color_index 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() db.commit()
return {"status": "saved", "color_index": row.color_index} 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") @router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): 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 """The same merged, cached event set a live device render would use
-- not a live preview of an unsaved calendar_view choice, same -- 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): if not calendar_sources_for_frame(db, frame):
raise HTTPException(400, "No calendars included on this frame yet") raise HTTPException(400, "No calendars included on this frame yet")
events, summary = get_or_refresh_calendar_events(db, frame) ccfg = db.get(CalendarWidgetConfig, calendar_widget.id)
photo_inlay = _calendar_photo_inlay(frame, db) events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget)
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None
weather_cities = get_or_refresh_weather(db, frame) tasks = (
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None 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( png = calendar_render.render_calendar_preview_png(
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation, events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary, palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=None, fetch_summary=summary,
week_start=frame.calendar_week_start, week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=frame.calendar_weather_units, weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks, week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
week_start_offset=frame.calendar_week_start_offset, week_start_offset=ccfg.week_start_offset,
) )
return Response(content=png, media_type="image/png") 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 its owner can point the frame at one of their calendars to begin
with.""" with."""
user = require_user_api(request, db) 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: if body.calendar_key is None:
cfg.calendar_tasks_user_id = None cfg.tasks_user_id = None
cfg.calendar_tasks_calendar_key = None cfg.tasks_calendar_key = None
cfg.calendar_tasks_cached = None cfg.tasks_cached = None
else: else:
cfg.calendar_tasks_user_id = user.id cfg.tasks_user_id = user.id
cfg.calendar_tasks_calendar_key = body.calendar_key cfg.tasks_calendar_key = body.calendar_key
cfg.calendar_tasks_checked_at = 0.0 # pick up the change promptly cfg.tasks_checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key} 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 it, but anyone linked to the frame can clear it, same as muting a
shared calendar.""" shared calendar."""
user = require_user_api(request, db) 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: if body.url is None:
cfg.whiteboard_user_id = None cfg.user_id = None
cfg.whiteboard_url = "" cfg.url = ""
cfg.whiteboard_cached_image = None cfg.cached_image = None
else: else:
stripped = body.url.strip() stripped = body.url.strip()
if not valid_http_url(stripped): if not valid_http_url(stripped):
raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL") raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
cfg.whiteboard_user_id = user.id cfg.user_id = user.id
cfg.whiteboard_url = stripped cfg.url = stripped
cfg.whiteboard_checked_at = 0.0 # pick up the change promptly cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "url": body.url} return {"status": "saved", "url": body.url}
@@ -695,9 +727,13 @@ def api_preview_whiteboard(
Excalidraw export, same convention as preview/rendered and Excalidraw export, same convention as preview/rendered and
preview/calendar. force=True (the "Refresh now" button, as opposed preview/calendar. force=True (the "Refresh now" button, as opposed
to just reopening this tab) bypasses the fetch throttle.""" 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 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(400, "No whiteboard configured on this frame yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials") raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
import io import io
@@ -726,17 +762,20 @@ def api_weather_city_add(
to this frame's weather strip -- a frame-wide display setting (like 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 calendar_view), not personal data, so this is gated the same way as
api_config_save rather than the calendar-select owner/mute split.""" 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: try:
city = weather.geocode_city(body.name) city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e: except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e raise HTTPException(400, str(e)) from e
with frame_locked(db, frame.id) as cfg: with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
cities = list(cfg.calendar_weather_cities or []) cities = list(cfg.weather_cities or [])
if any(c["label"] == city["label"] for c in cities): if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this frame's list") raise HTTPException(400, f"{city['label']} is already on this frame's list")
cities.append(city) cities.append(city)
cfg.calendar_weather_cities = cities cfg.weather_cities = cities
cfg.calendar_weather_checked_at = 0.0 # pick up the new city promptly cfg.weather_checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city} return {"status": "saved", "city": city}
@@ -750,11 +789,14 @@ def api_weather_city_remove(
frame: Frame = Depends(require_frame_control), frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
with frame_locked(db, frame.id) as cfg: calendar_widget = widget_of_type(db, frame, "calendar")
cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label] if calendar_widget is None:
cfg.calendar_weather_cities = cities raise HTTPException(404, "No calendar widget on this frame yet")
cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label] with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
cfg.calendar_weather_cached = cached 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"} return {"status": "saved"}
+157 -219
View File
@@ -16,11 +16,21 @@ from PIL import Image
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import frame_locked, widget_locked from ..db import widget_locked
from ..image_pipeline import render_frame from ..image_pipeline import logical_render_size
from ..immich_client import ImmichClient 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__) logger = logging.getLogger(__name__)
@@ -73,14 +83,6 @@ def immich_client_for(frame: Frame) -> ImmichClient:
return ImmichClient(url, key) 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]: def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
try: try:
assets = client.list_album_assets(album_id) assets = client.list_album_assets(album_id)
@@ -118,14 +120,6 @@ def fetch_source_and_faces(
return Image.open(io.BytesIO(jpeg_bytes)), 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: def _avg_wake_interval_s(frame: Frame) -> float:
"""Average wall-clock seconds between wakes: refresh_interval_s """Average wall-clock seconds between wakes: refresh_interval_s
scaled up for however much of each day quiet hours removes from the 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 return None
def _manage_content_asset_id(frame: Frame) -> str | None: def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
"""Whether frame.current_asset_id refers to a photo actually visible """The frame's first widget of this type, by placement order. Until
right now, for whichever mode is active -- always true in photos the placement UI (a later phase) ships, every frame has at most one
mode; only true in calendar mode when that view's photo inlay is on widget per type -- the auto-migrated default -- so callers needing
(otherwise current_asset_id could be stale, left over from whenever "the photo widget" / "the calendar widget" / "the whiteboard widget"
photos mode last ran, and showing its location/date/share info on a for what's still effectively a single-widget-per-type frame use this
manage overlay over a view with no visible photo at all would be rather than querying Widget directly. None if the frame has no widget
actively misleading, not just unhelpful).""" of this type."""
relevant = frame.mode != "calendar" or frame.calendar_photo_inlay return db.scalars(
return frame.current_asset_id if relevant and frame.current_asset_id else None 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: def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
"""Where the photo behind _manage_content_asset_id actually landed in """The frame's photo widget + its config, or a 400 if Immich creds or
the logical canvas -- None (the whole canvas) in photos mode, or an album aren't set up yet. Immich creds are frame/owner-level, but
calendar_render.inlay_region(...) when a calendar view's photo inlay album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
is what's showing. Needed so face labels (and, if ever added, other manage.py, whose photo-related endpoints both need exactly this."""
photo-relative overlay positioning) land on the actual inlaid photo url, key = immich_creds(frame)
instead of where a full-panel photo would have been.""" if not url or not key:
if frame.mode == "calendar" and frame.calendar_photo_inlay: raise HTTPException(400, "Immich URL/API key not configured yet")
from ..calendar_render import inlay_region 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: 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 /frame/face-labels, both removed -- see the module docstring in
manage_overlay.py) are now just internal calls made here, once, manage_overlay.py) are now just internal calls made here, once,
server-side, since compositing itself also moved server-side. server-side, since compositing itself also moved server-side.
management_url and battery_percent always apply; location/date/ management_url and battery_percent always apply. location/date/
share-URL/face-labels only when there's a real current photo (see share-URL come from one "primary" photo widget (see
_manage_content_asset_id) -- absent otherwise, which _primary_photo_widget -- there's only one of each of those fixed
manage_overlay.compose() already treats as "skip that region", panel corners, so with more than one photo widget on screen some
exactly the graceful-degradation behavior the old firmware-fetched single one has to be picked); face labels generalize more simply,
version had.""" 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("/") base = str(request.base_url).rstrip("/")
content: dict = { content: dict = {
"management_url": f"{base}/m/{frame.manage_token}", "management_url": f"{base}/m/{frame.manage_token}",
"battery_percent": frame.battery_percent, "battery_percent": frame.battery_percent,
} }
asset_id = _manage_content_asset_id(frame) photo_widgets = photo_widgets_for_frame(db, frame)
if not asset_id: if not photo_widgets:
return content return content
client = immich_client_for(frame) primary = _primary_photo_widget(db, frame, photo_widgets)
try: primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
asset = client.get_asset(asset_id) if primary_cfg and primary_cfg.current_asset_id:
faces = client.get_asset_faces(asset_id) client = immich_client_for(frame)
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):
try: try:
preview_bytes = client.download_asset_preview(asset_id) asset = client.get_asset(primary_cfg.current_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),
)
except httpx.HTTPError as e: 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 return content
@@ -459,45 +505,21 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.
return sources 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) """Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
-- same shape as the Gitea release-check throttle in api_frames.py'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 api_firmware_check -- reading/writing CalendarWidgetConfig (see
(every included user's events together), not per-user -- ICS feeds app/widgets/calendar.py, which this backs). One shared cache for the
are small and this refetches at most every ~20 minutes regardless of whole merged result (every included user's events together), not
how many are included, so per-user cache columns would add per-user -- ICS feeds are small and this refetches at most every ~20
bookkeeping for a marginal benefit.""" minutes regardless of how many are included, so per-user cache
now = time.time() columns would add bookkeeping for a marginal benefit.
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S: calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's
return frame.calendar_cached_events, frame.calendar_fetch_summary own docstring) until a later phase re-keys it to widget_id, so every
calendar widget on a frame currently shares the same "included
sources = calendar_sources_for_frame(db, frame) calendars" set -- not a real limitation yet since nothing supports
today = quiet_hours.local_date(frame) more than one calendar widget per frame end to end until that phase
events, summary = calendar_feed.merge_events( lands."""
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."""
cfg = db.get(CalendarWidgetConfig, widget.id) cfg = db.get(CalendarWidgetConfig, widget.id)
now = time.time() now = time.time()
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S: 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 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]: 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 """Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
throttle/caching rationale, unchanged. Not yet used by any router longer than calendar_feed's -- weather doesn't need to be that
(see app/widgets/calendar.py) -- both versions coexist until the fresh), reading/writing CalendarWidgetConfig (see
widget-system cutover lands.""" 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) cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.weather_enabled or not cfg.weather_cities: if not cfg.weather_enabled or not cfg.weather_cities:
return [] return []
@@ -578,42 +571,14 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result 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]: 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/ """Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
caching rationale, unchanged. Not yet used by any router (see cadence as event merging), reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py) -- both versions coexist until the app/widgets/calendar.py). [] if tasks are off, no source is set, or
widget-system cutover lands.""" 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) cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id: if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
return [] return []
@@ -653,48 +618,21 @@ def webdav_creds_for(user: User) -> tuple[str, str] | None:
return 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( def get_or_refresh_whiteboard_for_widget(
db: Session, frame: Frame, widget: Widget, force: bool = False db: Session, frame: Frame, widget: Widget, force: bool = False
) -> bytes | None: ) -> bytes | None:
"""Widget-scoped twin of get_or_refresh_whiteboard above -- same """Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
throttle/caching/force rationale, unchanged. Not yet used by any writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
router (see app/widgets/whiteboard.py) -- both versions coexist None if no whiteboard source is configured, credentials are missing
until the widget-system cutover lands.""" (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) cfg = db.get(WhiteboardWidgetConfig, widget.id)
if not cfg.url or not cfg.user_id: if not cfg.url or not cfg.user_id:
return None return None
+127 -219
View File
@@ -12,39 +12,32 @@ see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations from __future__ import annotations
import io
import logging import logging
import time import time
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response from fastapi.responses import FileResponse, RedirectResponse, Response
from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session 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 ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db from ..db import frame_locked, get_db
from ..firmware import firmware_path from ..firmware import firmware_path
from ..image_pipeline import render_frame, render_placeholder from ..image_pipeline import logical_render_size, render_panel, render_placeholder
from ..models import BatteryLog, Frame from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
from ..widgets import WIDGET_TYPES
from .common import ( from .common import (
BATTERY_HISTORY_MAX, BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX, BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT, RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK, RECHARGE_LOOKBACK,
build_manage_content, build_manage_content,
get_or_refresh_calendar_events,
get_or_refresh_tasks,
get_or_refresh_weather,
get_or_refresh_whiteboard,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
list_assets, photo_widgets_for_frame,
render_asset,
require_configured,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,11 +46,11 @@ router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes: def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a """What an unclaimed or widget-less frame displays instead of real
photo -- instructions with a QR, rendered at 200 so the device treats content -- instructions with a QR, rendered at 200 so the device
it as a perfectly normal image and never error-loops. The URLs are treats it as a perfectly normal image and never error-loops. The
built from the request's own base URL: whatever address the device URLs are built from the request's own base URL: whatever address the
reached us at is by definition an address that works on this device reached us at is by definition an address that works on this
network.""" network."""
base = str(request.base_url).rstrip("/") base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id: 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, manage=manage,
) )
return render_placeholder( 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, qr_url=base,
orientation=frame.orientation, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, 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: def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool) -> bytes:
url, key = immich_creds(frame) """The widget-system compositor: renders every widget on this frame
return bool(url and key and frame.album_id) 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_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
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,
is_normal_wake: bool) -> bytes: 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_widgets(db, frame, manage, is_normal_wake)
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,
)
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) def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
weather_cities = get_or_refresh_weather(db, frame) """Executes every (widget, action) binding assigned to this physical
# Only ever shown on the week view (see calendar_render._build_week) -- button, in order -- see models.FrameButtonAction and the button-
# gated here too so a disabled/other-view frame never pays for the assignment UI (a later phase). Each action runs to completion (its
# fetch, and so None (not just an empty list) reaches render_calendar own widget_locked span) before the next one starts -- never nested,
# to mean "no tasks slot at all", distinct from "slot reserved but since db.widget_locked's underlying lock isn't reentrant (see its own
# nothing outstanding right now". docstring) -- a button assigned several actions would deadlock
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None instantly if this looped any other way. One action failing
unexpectedly doesn't block the others, or the eventual re-render,
photo_inlay = None from happening -- the user pressed a physical button and expects
if inlay_wanted and _frame_configured(frame): *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: try:
client = immich_client_for(frame) action_fn(db, frame, widget)
assets = list_assets(client, frame.album_id) except Exception:
with frame_locked(db, frame.id) as locked: logger.exception(
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked)) "Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id
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,
}
@router.get("/frame/config") @router.get("/frame/config")
@@ -311,42 +207,47 @@ def _manage_flag(request: Request) -> bool:
def frame_image( def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db) request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
): ):
"""Returns the frame's current image. For photos mode: idempotent -- """Returns the frame's current image -- every widget on the frame
only actually advances to the next photo once refresh_interval_s has composited into one panel (see _render_widgets). Each widget's own
elapsed since the current one was set (see app/photo_queue.py) -- render is idempotent in whatever way makes sense for its type (e.g.
safe to call as often as the device wants, including after an a photo widget only actually advances once its own refresh interval
unplanned reboot, without skipping ahead in the album. An unclaimed/ has elapsed, see app/photo_queue.py) -- safe to call as often as the
unconfigured frame gets a rendered instruction placeholder (200, not device wants, including after an unplanned reboot, without skipping
an error) so a fresh device never error-loops. 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 ?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content. whatever this would have returned anyway -- see build_manage_content.
For calendar mode, this is also the "normal wake" that resets This is also the "normal wake" that resets any calendar widget's
calendar_browse_offset back to 0 (see _render_calendar_mode).""" browse position back to today (see app/widgets/calendar.py)."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None 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") return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/advance") @router.post("/frame/advance")
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): 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, """Forces an immediate move forward on whatever widget(s) the NEXT
or the next day/week/month in calendar mode -- ignoring button is assigned to (see models.FrameButtonAction) -- e.g. the next
refresh_interval_s. Used by the device's next-photo button.""" photo for a photo widget, or the next day/week/month for a calendar
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode) 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 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") @router.post("/frame/back")
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): 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 """The mirror of /frame/advance, for whatever widget(s) the BACK
a period in calendar mode. A no-op (still 200, unchanged) if there's button is assigned to. A no-op (still 200, unchanged) for any widget
nothing to go back to. Used by the device's back-photo button.""" with nothing to go back to. Used by the device's back-photo button."""
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode) _run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None 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): class BatteryReport(BaseModel):
@@ -441,18 +342,25 @@ def frame_firmware(frame: Frame = Depends(require_device)):
@router.get("/frame/share/{asset_id}") @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 """Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was (i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used. pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame -- Also scoped to the photo currently showing or queued on one of THIS
not any arbitrary Immich asset id -- as a second layer even a leaked frame's own photo widgets -- not any arbitrary Immich asset id -- as
token wouldn't bypass.""" a second layer even a leaked token wouldn't bypass."""
require_configured(frame) 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") raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame) client = immich_client_for(frame)
+37 -14
View File
@@ -20,9 +20,9 @@ from ..image_pipeline import (
PALETTE_LABELS, PALETTE_LABELS,
palette_to_hex, 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 ..quiet_hours import ALL_TIMEZONES
from .common import shell_context from .common import shell_context, widget_of_type
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") 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): if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame") raise HTTPException(404, "No such frame")
ctx = shell_context(request, db, user, active_frame=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) return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse) @router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)): 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( return _frame_page(
request, db, frame_id, "frame_photos.html", "photos", request, db, frame_id, "frame_photos.html", "photos",
display_mode_labels=DISPLAY_MODE_LABELS, 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 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 """Whose CalDAV calendar this frame's week-view task list currently
pulls from, and its label -- for showing "using <name>'s Chores pulls from, and its label -- for showing "using <name>'s Chores
list" to everyone linked, not just whoever set it. None if no list" to everyone linked, not just whoever set it. None if no
source is configured.""" 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 return None
user = db.get(User, frame.calendar_tasks_user_id) user = db.get(User, calendar_cfg.tasks_user_id)
if user is None: if user is None:
return None return None
label = frame.calendar_tasks_calendar_key label = calendar_cfg.tasks_calendar_key
for c in (user.calendar_caldav_calendars or []): 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 label = c.get("display_name") or label
break break
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label} 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) viewer = current_user(request, db)
frame = db.get(Frame, frame_id) frame = db.get(Frame, frame_id)
viewer_task_calendars = [] viewer_task_calendars = []
calendar_cfg = None
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame): 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:")] 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( return _frame_page(
request, db, frame_id, "frame_calendar.html", "calendar", request, db, frame_id, "frame_calendar.html", "calendar",
calendar_views=CALENDAR_VIEW_LABELS, 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, default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex, palette_to_hex=palette_to_hex,
viewer_task_calendars=viewer_task_calendars, 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 """Whose account this frame's whiteboard currently fetches with, for
showing "using <name>'s account" to everyone linked, not just showing "using <name>'s account" to everyone linked, not just
whoever set it. None if no source is configured.""" 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 return None
user = db.get(User, frame.whiteboard_user_id) user = db.get(User, whiteboard_cfg.user_id)
if user is None: if user is None:
return 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) @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) viewer = current_user(request, db)
frame = db.get(Frame, frame_id) frame = db.get(Frame, frame_id)
viewer_has_webdav_creds = False 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): if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
viewer_has_webdav_creds = bool( viewer_has_webdav_creds = bool(
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username) 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( return _frame_page(
request, db, frame_id, "frame_whiteboard.html", "whiteboard", 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, viewer_has_webdav_creds=viewer_has_webdav_creds,
) )
+23 -20
View File
@@ -18,9 +18,9 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours 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 ..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__) 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") @router.get("/api/m/{manage_token}/queue")
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)): 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) 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: with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg)) photo_queue.get_current(locked_pcfg, assets, locked_frame,
photo_queue.sync_queue_length(cfg, assets) in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
current = cfg.current_asset_id photo_queue.sync_queue_length(locked_pcfg, assets)
queue = list(cfg.queue) current = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue)
def entry(asset_id: str) -> dict: def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"} 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), frame: Frame = Depends(require_manage),
db: Session = Depends(get_db), 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: if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming 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] 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)): def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Advances the server-side current photo; the panel itself updates """Advances the server-side current photo; the panel itself updates
on the device's next wake (or its next-photo button).""" 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) 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: with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.advance_forced(cfg, assets, cfg) photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
return {"status": "saved"} return {"status": "saved"}
@router.post("/api/m/{manage_token}/back") @router.post("/api/m/{manage_token}/back")
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)): 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) 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: with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.back_forced(cfg, assets, cfg) photo_queue.back_forced(locked_pcfg, assets, locked_frame)
return {"status": "saved"} return {"status": "saved"}
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}") @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 -- """Thumbnails scoped to what this frame is actually showing/queuing --
the manage token must not become a general Immich proxy.""" 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") raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame) client = immich_client_for(frame)
try: try:
+4 -5
View File
@@ -1,7 +1,7 @@
// Calendar tab: view/week-start/photo-inlay settings, per-user opt-in, // Calendar tab: view/week-start settings, per-user opt-in, and the
// and the rendered preview. Extracted from frame_config.js when the // rendered preview. Extracted from frame_config.js when the Calendar
// Calendar card became its own tab (window.FRAME_API is set by the // card became its own tab (window.FRAME_API is set by the template;
// template; checkboxes are always sent explicitly as "true"/"false"). // checkboxes are always sent explicitly as "true"/"false").
// Week-view-only settings (days/layout/start-offset) only matter when // Week-view-only settings (days/layout/start-offset) only matter when
// View is actually "Week"; "Week starts on" also matters for Month, so // 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_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
+4 -32
View File
@@ -1,8 +1,8 @@
// Page-header controls shared by every per-frame page (Photos/ // Page-header controls shared by every per-frame page (Photos/
// Configuration/Calendar/Stats): the frame-name pencil-edit and the // Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit,
// mode selector, both now living outside the tab structure since they // living outside the tab structure since it applies regardless of which
// apply regardless of which tab is open. Depends on window.FRAME_API // tab is open. Depends on window.FRAME_API (set per-page) and
// (set per-page) and common.js's showStatus/apiError. // common.js's showStatus/apiError.
(function () { (function () {
var view = document.getElementById('frame-name-view'); var view = document.getElementById('frame-name-view');
@@ -55,31 +55,3 @@
if (e.key === 'Escape') closeEdit(); 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);
}
});
})();
-5
View File
@@ -514,8 +514,6 @@ code {
} }
.frame-name-edit button { margin-top: 0; } .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 { .control-banner {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -558,9 +556,6 @@ code {
.device-status-row { column-gap: 16px; } .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: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; } .mobile-bar { display: none; }
.sidebar-backdrop { display: none; } .sidebar-backdrop { display: none; }
@@ -1,4 +0,0 @@
<div class="mode-picker-row">
<span class="mode-picker-label">Mode</span>
{% include "_frame_mode_select.html" %}
</div>
@@ -1,5 +0,0 @@
<select id="frame-mode-select" class="frame-mode-select" title="Frame mode" aria-label="Frame mode">
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
<option value="whiteboard" {% if frame.mode == "whiteboard" %}selected{% endif %}>Whiteboard</option>
</select>
+2 -2
View File
@@ -2,8 +2,8 @@
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a> <a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a> <a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/calendar" <a href="/frames/{{ frame.id }}/calendar"
class="{% if active_tab == 'calendar' %}active{% endif %} {% if frame.mode != 'calendar' %}tab-disabled{% endif %}">Calendar</a> class="{% if active_tab == 'calendar' %}active{% endif %} {% if not has_calendar_widget %}tab-disabled{% endif %}">Calendar</a>
<a href="/frames/{{ frame.id }}/whiteboard" <a href="/frames/{{ frame.id }}/whiteboard"
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if frame.mode != 'whiteboard' %}tab-disabled{% endif %}">Whiteboard</a> class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if not has_whiteboard_widget %}tab-disabled{% endif %}">Whiteboard</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a> <a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav> </nav>
-1
View File
@@ -73,7 +73,6 @@
</div> </div>
</div> </div>
{% block device_status %}{% endblock %} {% block device_status %}{% endblock %}
{% block mode_picker %}{% endblock %}
{% block tabs %}{% endblock %} {% block tabs %}{% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+17 -25
View File
@@ -2,7 +2,6 @@
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
@@ -13,10 +12,9 @@
<button type="button" id="take-control" class="btn-inline">Take control</button> <button type="button" id="take-control" class="btn-inline">Take control</button>
</div> </div>
{% if frame.mode != 'calendar' %} {% if not has_calendar_widget %}
<div class="info-box">This frame is currently in <strong>Photos</strong> mode -- <div class="info-box">This frame doesn't have a <strong>Calendar</strong> widget on
settings below take effect once you switch it to <strong>Calendar</strong> mode screen yet -- settings below won't show up anywhere until one is added.</div>
using the selector at the top of the page.</div>
{% endif %} {% endif %}
<div class="layout"> <div class="layout">
@@ -27,7 +25,7 @@
<label>View <label>View
<select id="calendar_view"> <select id="calendar_view">
{% for value, label in calendar_views.items() %} {% for value, label in calendar_views.items() %}
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if calendar_cfg and calendar_cfg.view == value %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@@ -35,7 +33,7 @@
<label>Week starts on <label>Week starts on
<select id="calendar_week_start"> <select id="calendar_week_start">
{% for value, label in week_start_labels.items() %} {% for value, label in week_start_labels.items() %}
<option value="{{ value }}" {% if frame.calendar_week_start == value %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if calendar_cfg and calendar_cfg.week_start == value %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@@ -43,30 +41,24 @@
</div> </div>
<div id="calendar-week-days-row"> <div id="calendar-week-days-row">
<label>Days to show (Week view) <label>Days to show (Week view)
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ frame.calendar_week_days }}"> <input type="number" id="calendar_week_days" min="2" max="10" value="{{ calendar_cfg.week_days if calendar_cfg else 7 }}">
</label> </label>
</div> </div>
<div id="calendar-week-layout-row"> <div id="calendar-week-layout-row">
<label>Week view layout <label>Week view layout
<select id="calendar_week_layout"> <select id="calendar_week_layout">
<option value="horizontal" {% if frame.calendar_week_layout == "horizontal" %}selected{% endif %}>Days side by side</option> <option value="horizontal" {% if not calendar_cfg or calendar_cfg.week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
<option value="vertical" {% if frame.calendar_week_layout == "vertical" %}selected{% endif %}>Days stacked</option> <option value="vertical" {% if calendar_cfg and calendar_cfg.week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
</select> </select>
</label> </label>
</div> </div>
<div id="calendar-week-offset-row"> <div id="calendar-week-offset-row">
<label>Week view starts (days from today) <label>Week view starts (days from today)
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ frame.calendar_week_start_offset }}"> <input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ calendar_cfg.week_start_offset if calendar_cfg else 0 }}">
</label> </label>
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the <p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.</p> past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div> </div>
<div class="checkbox-row" id="calendar-inlay-row">
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
<label for="calendar_photo_inlay">Show a photo alongside the calendar</label>
</div>
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px;">
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
<button type="submit">Save</button> <button type="submit">Save</button>
</form> </form>
@@ -112,8 +104,8 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% if frame.calendar_fetch_summary %} {% if calendar_cfg and calendar_cfg.fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p> <p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ calendar_cfg.fetch_summary }}</p>
{% endif %} {% endif %}
</section> </section>
@@ -123,13 +115,13 @@
&amp; tomorrow), and Week views -- there's no room for it on Month.</p> &amp; tomorrow), and Week views -- there's no room for it on Month.</p>
<form id="weather-config-form"> <form id="weather-config-form">
<div class="checkbox-row"> <div class="checkbox-row">
<input type="checkbox" id="weather_enabled" {% if frame.calendar_weather_enabled %}checked{% endif %}> <input type="checkbox" id="weather_enabled" {% if calendar_cfg and calendar_cfg.weather_enabled %}checked{% endif %}>
<label for="weather_enabled">Show weather</label> <label for="weather_enabled">Show weather</label>
</div> </div>
<label>Units <label>Units
<select id="weather_units"> <select id="weather_units">
<option value="fahrenheit" {% if frame.calendar_weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option> <option value="fahrenheit" {% if not calendar_cfg or calendar_cfg.weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if frame.calendar_weather_units == "celsius" %}selected{% endif %}>Celsius</option> <option value="celsius" {% if calendar_cfg and calendar_cfg.weather_units == "celsius" %}selected{% endif %}>Celsius</option>
</select> </select>
</label> </label>
<button type="submit">Save</button> <button type="submit">Save</button>
@@ -139,7 +131,7 @@
<p class="sub">Every city shows on every day -- add more than one if <p class="sub">Every city shows on every day -- add more than one if
people split their time between places.</p> people split their time between places.</p>
<ul class="calendar-user-list" id="weather-city-list"> <ul class="calendar-user-list" id="weather-city-list">
{% for c in frame.calendar_weather_cities or [] %} {% for c in (calendar_cfg.weather_cities if calendar_cfg else []) or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;"> <li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span> <span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button> <button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
@@ -159,7 +151,7 @@
<p class="sub">Week view only -- takes the place of one day slot <p class="sub">Week view only -- takes the place of one day slot
instead of adding an extra one.</p> instead of adding an extra one.</p>
<div class="checkbox-row" id="tasks-enabled-row"> <div class="checkbox-row" id="tasks-enabled-row">
<input type="checkbox" id="tasks_enabled" {% if frame.calendar_tasks_enabled %}checked{% endif %}> <input type="checkbox" id="tasks_enabled" {% if calendar_cfg and calendar_cfg.tasks_enabled %}checked{% endif %}>
<label for="tasks_enabled">Show a task list</label> <label for="tasks_enabled">Show a task list</label>
</div> </div>
@@ -179,7 +171,7 @@
{% for c in viewer_task_calendars %} {% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;"> <li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}" <input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and frame.calendar_tasks_calendar_key == c.key %}checked{% endif %}> {% if tasks_source and tasks_source.user_id == user.id and calendar_cfg and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label> <label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li> </li>
{% endfor %} {% endfor %}
-1
View File
@@ -2,7 +2,6 @@
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+5 -6
View File
@@ -2,7 +2,6 @@
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
@@ -20,26 +19,26 @@
<form id="photos-form"> <form id="photos-form">
<label>Album <label>Album
<select id="album_id"> <select id="album_id">
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %} {% if photo_cfg and photo_cfg.album_id %}<option value="{{ photo_cfg.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
</select> </select>
</label> </label>
<label>Upcoming photos to show <label>Upcoming photos to show
<select id="queue_target_len"> <select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %} {% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option> <option value="{{ n }}" {% if photo_cfg and photo_cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>Order <label>Order
<select id="order"> <select id="order">
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option> <option value="sequential" {% if not photo_cfg or photo_cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option> <option value="shuffle" {% if photo_cfg and photo_cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select> </select>
</label> </label>
<label>Display mode <label>Display mode
<select id="display_mode"> <select id="display_mode">
{% for mode, label in display_mode_labels.items() %} {% for mode, label in display_mode_labels.items() %}
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option> <option value="{{ mode }}" {% if photo_cfg and photo_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
-1
View File
@@ -2,7 +2,6 @@
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+3 -5
View File
@@ -2,7 +2,6 @@
{% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %} {% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %} {% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %} {% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} {% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
@@ -13,10 +12,9 @@
<button type="button" id="take-control" class="btn-inline">Take control</button> <button type="button" id="take-control" class="btn-inline">Take control</button>
</div> </div>
{% if frame.mode != 'whiteboard' %} {% if not has_whiteboard_widget %}
<div class="info-box">This frame is currently in <strong>{{ frame.mode|capitalize }}</strong> mode -- <div class="info-box">This frame doesn't have a <strong>Whiteboard</strong> widget on
settings below take effect once you switch it to <strong>Whiteboard</strong> mode screen yet -- settings below won't show up anywhere until one is added.</div>
using the selector at the top of the page.</div>
{% endif %} {% endif %}
<div class="layout"> <div class="layout">
+7 -2
View File
@@ -6,14 +6,19 @@ optionally responds to named button actions."
Each module in this package exposes: Each module in this package exposes:
render(db, frame, widget, target_w, target_h) -> Image.Image render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image
An RGB image exactly target_w x target_h, unquantized -- the An RGB image exactly target_w x target_h, unquantized -- the
widget's content composed into its own region. Never returns widget's content composed into its own region. Never returns
packed panel bytes or raises for a foreseeable failure (a packed panel bytes or raises for a foreseeable failure (a
widget's own fetch hiccup shows a small placeholder instead) -- widget's own fetch hiccup shows a small placeholder instead) --
image_pipeline.render_panel composites every widget's own image_pipeline.render_panel composites every widget's own
render() result onto one shared canvas and quantizes/packs the render() result onto one shared canvas and quantizes/packs the
whole thing once (see its own docstring). whole thing once (see its own docstring). is_normal_wake
distinguishes an ordinary /frame/image GET from a button-
triggered re-render -- only app/widgets/calendar.py's render()
actually uses it (resetting browse_offset back to "today" on a
normal wake), but every module accepts it for one uniform call
signature regardless.
ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]] ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
Named button actions this widget type supports (e.g. "advance", Named button actions this widget type supports (e.g. "advance",
+13 -1
View File
@@ -34,7 +34,19 @@ from ..routers.common import (
ACTION_LABELS = {"advance": "Next period", "back": "Previous period"} ACTION_LABELS = {"advance": "Next period", "back": "Previous period"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image: def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake=True (an ordinary /frame/image GET, not a button
press) resets browse_offset back to "today" if it had drifted --
mirrors the old _render_calendar_mode's identical behavior. A button
press explicitly moved the browse position on purpose, so it passes
is_normal_wake=False to render its own already-updated offset instead
of immediately snapping back to 0."""
if is_normal_wake:
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
if locked_cfg.browse_offset != 0:
locked_cfg.browse_offset = 0
cfg = db.get(CalendarWidgetConfig, widget.id) cfg = db.get(CalendarWidgetConfig, widget.id)
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
+7 -1
View File
@@ -26,7 +26,13 @@ from ._shared import placeholder_image
ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"} ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image: def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- photos mode's advance timing is
already fully idempotent via get_current()'s own elapsed-time check,
unlike calendar mode's browse_offset (see app/widgets/calendar.py's
render()). Accepted anyway so every widget type's render() shares one
call signature regardless of which ones actually care."""
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id: if not cfg.album_id:
return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"]) return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"])
+5 -1
View File
@@ -22,7 +22,11 @@ from ._shared import placeholder_image
ACTION_LABELS = {"check_now": "Check for updates"} ACTION_LABELS = {"check_now": "Check for updates"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image: def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget) png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget)
if png_bytes is None: if png_bytes is None:
return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"]) return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"])
+177
View File
@@ -0,0 +1,177 @@
"""End-to-end HTTP tests for the widget-system cutover in
routers/device.py -- /frame/image, /frame/advance, /frame/back, and
/frame/share against real widget rows (via the migration-backfilled
frame #1, or a purpose-built second frame), a real TestClient, real
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
at the app.widgets.photos module boundary, same pattern as
test_widgets_photos.py) -- everything else in the pipeline is real.
This is the one place in the suite that actually exercises
routers/device.py's dispatch over HTTP; the render-size-invariant tests
exercise the renderers directly, and the widget unit tests exercise
app/widgets/*.py directly, but neither proves device.py's own wiring
(the compositor, the button-action runner, the manage-overlay hookup)
is correct -- that's what this file is for."""
from __future__ import annotations
import time
from app import widgets
from app.models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
PhotoWidgetConfig,
User,
Widget,
)
EXPECTED_BYTES = 800 * 480 // 2
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
def _mock_immich(monkeypatch):
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
from PIL import Image
source = Image.new("RGB", (100, 80), (10, 20, 30))
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
def test_unclaimed_frame_shows_placeholder(client, db_session):
frame = db_session.get(Frame, 1)
frame.owner_user_id = None
db_session.commit()
resp = client.get("/frame/image")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/frame/image")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.album_id == "" # never configured -- still rendered fine, as a placeholder region
def test_configured_photo_widget_renders_and_advances_via_button(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
_mock_immich(monkeypatch)
resp = client.get("/frame/image")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1"
resp = client.post("/frame/advance")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
resp = client.post("/frame/back")
assert resp.status_code == 200
db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" # back undid it
def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
db_session.commit()
_mock_immich(monkeypatch)
plain = client.get("/frame/image").content
with_manage = client.get("/frame/image?manage=1").content
assert len(with_manage) == EXPECTED_BYTES
assert with_manage != plain # the manage-QR overlay actually got composited in
def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_session, monkeypatch):
"""A second frame (not frame #1) with two independent widgets -- a
calendar widget and a photo widget, addressed directly the way the
migration's calendar_photo_inlay backfill shapes a frame -- exercised
end to end over HTTP, not just via the migration's own unit tests."""
_mock_immich(monkeypatch)
frame = Frame(
name="Two Widget Frame", device_id="aabbccddeeff", device_token="devtok-2",
manage_token="mtok-2", orientation="landscape", created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=4, h=5,
sort_order=0, created_at=time.time())
photo_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add_all([cal_widget, photo_widget])
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id, view="agenda", browse_offset=0))
db_session.add(PhotoWidgetConfig(widget_id=photo_widget.id, album_id="album-1"))
db_session.add_all([
FrameButtonAction(frame_id=frame.id, button="next", widget_id=cal_widget.id, action="advance"),
FrameButtonAction(frame_id=frame.id, button="back", widget_id=cal_widget.id, action="back"),
])
db_session.commit()
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
# NEXT is bound only to the calendar widget -- pressing it should
# move the calendar's browse_offset, not touch the photo widget.
resp = client.post(f"/frame/advance?id={frame.device_id}&token={frame.device_token}")
assert resp.status_code == 200
cal_cfg = db_session.get(CalendarWidgetConfig, cal_widget.id)
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert cal_cfg.browse_offset == 1
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
def test_frame_share_checks_widget_scoped_state(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.owner_user_id = db_session.query(User).filter_by(username="alice").one().id
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
cfg.current_asset_id = "asset-1"
cfg.queue = ["asset-2"]
frame.immich_url = "http://immich.example.com"
frame.immich_api_key = "key"
db_session.commit()
# Not showing/queued -- rejected before ever touching Immich
resp = client.get("/frame/share/asset-not-on-this-frame")
assert resp.status_code == 404
# Currently showing -- allowed through to the (mocked) Immich call
monkeypatch.setattr(
"app.routers.device.immich_client_for",
lambda frame: type("C", (), {"create_share_link": lambda self, asset_id, expires_in_s: "https://immich.example.com/share/abc"})(),
)
resp = client.get("/frame/share/asset-1", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
# Queued (not current) -- also allowed
resp = client.get("/frame/share/asset-2", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
+121
View File
@@ -0,0 +1,121 @@
"""routers/manage.py -- the no-login "scan to manage" surface -- against
the widget-scoped photo state it was just repointed at. No prior test
coverage existed for this router at all before this file; it's
exercised here specifically because of how much its photo-queue
endpoints changed in the widget-system cutover (frame.current_asset_id/
frame.queue -> the frame's photo widget's own PhotoWidgetConfig)."""
from __future__ import annotations
from app.models import Frame, PhotoWidgetConfig
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
def _mock_immich(monkeypatch):
"""manage.py imports immich_client_for/list_assets into its own
module namespace (`from .common import ...`) -- patching those
names, not app.widgets.photos' separate copies, since manage.py's
endpoints call its own bound names directly, never through
app/widgets/."""
monkeypatch.setattr("app.routers.manage.immich_client_for", lambda frame: object())
monkeypatch.setattr("app.routers.manage.list_assets", lambda client, album_id: _ASSETS)
def _configure_photo_widget(db_session):
from app.models import Widget
frame = db_session.get(Frame, 1)
# photo_widget_config_or_404 (see routers/common.py) checks Immich
# creds directly, not through the mocked immich_client_for below.
frame.immich_url = "http://immich.example.com"
frame.immich_api_key = "key"
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
return frame, widget
def test_manage_queue_requires_configured_photo_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp = client.get(f"/api/m/{frame.manage_token}/queue")
assert resp.status_code == 400
def test_manage_queue_returns_current_and_upcoming(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
_mock_immich(monkeypatch)
resp = client.get(f"/api/m/{frame.manage_token}/queue")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["current"]["id"] == "asset-1"
assert [u["id"] for u in data["upcoming"]] == ["asset-2", "asset-3"]
def test_manage_advance_and_back(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
_mock_immich(monkeypatch)
# establish a current photo first
client.get(f"/api/m/{frame.manage_token}/queue")
resp = client.post(f"/api/m/{frame.manage_token}/advance")
assert resp.status_code == 200
cfg = db_session.get(PhotoWidgetConfig, widget.id)
first_current = cfg.current_asset_id
assert first_current != ""
resp = client.post(f"/api/m/{frame.manage_token}/advance")
assert resp.status_code == 200
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id != first_current
resp = client.post(f"/api/m/{frame.manage_token}/back")
assert resp.status_code == 200
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id == first_current
def test_manage_promote(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
_mock_immich(monkeypatch)
client.get(f"/api/m/{frame.manage_token}/queue") # populate the queue
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert "asset-3" in cfg.queue
resp = client.post(f"/api/m/{frame.manage_token}/promote", json={"asset_id": "asset-3"})
assert resp.status_code == 200, resp.text
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.queue[0] == "asset-3"
def test_manage_thumbnail_scoped_to_showing_or_queued(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame, widget = _configure_photo_widget(db_session)
_mock_immich(monkeypatch)
client.get(f"/api/m/{frame.manage_token}/queue")
resp = client.get(f"/api/m/{frame.manage_token}/thumbnail/not-on-this-frame")
assert resp.status_code == 404
monkeypatch.setattr(
"app.routers.manage.immich_client_for",
lambda frame: type("C", (), {
"download_asset_thumbnail": lambda self, asset_id: (b"jpegbytes", "image/jpeg"),
})(),
)
resp = client.get(f"/api/m/{frame.manage_token}/thumbnail/asset-1")
assert resp.status_code == 200
assert resp.content == b"jpegbytes"
def test_unknown_manage_token_404s(client, db_session):
resp = client.get("/api/m/not-a-real-token/queue")
assert resp.status_code == 404
+81 -22
View File
@@ -6,7 +6,16 @@ not just a helper function's logic."""
from __future__ import annotations from __future__ import annotations
from app.models import Frame, FrameCalendar, User import time
from app.models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
User,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import csrf_headers, link_user, login, make_user from .conftest import csrf_headers, link_user, login, make_user
@@ -21,26 +30,53 @@ def _setup_two_linked_users(client, db_session) -> Frame:
return frame return frame
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
"""Frame #1's auto-migrated widget is a photos widget (its mode was
"photos" before the widget system existed) -- these tests need a
whiteboard widget too, which nothing creates yet until the
widget-placement UI (a later phase) ships, so it's added directly
here the same way the widget unit tests do."""
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _add_calendar_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
# --- whiteboard-source --- # --- whiteboard-source ---
def test_whiteboard_source_owner_can_set_it(client, db_session): def test_whiteboard_source_owner_can_set_it(client, db_session):
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
# alice is still logged in from /setup # alice is still logged in from /setup
resp = client.post("/api/frames/1/whiteboard-source", json={ resp = client.post("/api/frames/1/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard", "url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client)) }, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1) cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert frame.whiteboard_user_id is not None assert cfg.user_id is not None
assert frame.whiteboard_url == "https://cloud.example.com/dav/files/alice/board.whiteboard" assert cfg.url == "https://cloud.example.com/dav/files/alice/board.whiteboard"
def test_whiteboard_source_set_always_targets_the_caller(client, db_session): def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
"""bob has no way to point the frame at someone else's account -- """bob has no way to point the frame at someone else's account --
there's no target-user field in the request at all, so a "set" call there's no target-user field in the request at all, so a "set" call
from bob always attaches to bob, even if he pastes alice's URL.""" from bob always attaches to bob, even if he pastes alice's URL."""
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.cookies.clear() client.cookies.clear()
login(client, "bob") login(client, "bob")
@@ -49,13 +85,14 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
}, headers=csrf_headers(client)) }, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
bob_row = db_session.query(User).filter_by(username="bob").one() bob_row = db_session.query(User).filter_by(username="bob").one()
assert frame.whiteboard_user_id == bob_row.id cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert cfg.user_id == bob_row.id
def test_whiteboard_source_anyone_linked_can_clear(client, db_session): def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"}, client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"},
headers=csrf_headers(client)) headers=csrf_headers(client))
@@ -64,20 +101,22 @@ def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client)) resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1) cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert frame.whiteboard_url == "" assert cfg.url == ""
assert frame.whiteboard_user_id is None assert cfg.user_id is None
def test_whiteboard_source_rejects_non_http_url(client, db_session): def test_whiteboard_source_rejects_non_http_url(client, db_session):
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
_add_whiteboard_widget(db_session, frame)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"}, resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"},
headers=csrf_headers(client)) headers=csrf_headers(client))
assert resp.status_code == 400 assert resp.status_code == 400
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session): def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
_add_whiteboard_widget(db_session, frame)
make_user(db_session, "mallory") # exists, but never linked to frame 1 make_user(db_session, "mallory") # exists, but never linked to frame 1
client.cookies.clear() client.cookies.clear()
login(client, "mallory") login(client, "mallory")
@@ -87,10 +126,22 @@ def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
assert resp.status_code == 404 assert resp.status_code == 404
def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session):
"""Distinct from the unlinked-user 404 above -- this is a linked,
fully-permitted owner hitting the endpoint on a frame that simply
doesn't have a whiteboard widget yet (frame #1's auto-migrated
widget is a photos widget)."""
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"},
headers=csrf_headers(client))
assert resp.status_code == 404
# --- tasks-source --- # --- tasks-source ---
def test_tasks_source_set_always_targets_the_caller(client, db_session): def test_tasks_source_set_always_targets_the_caller(client, db_session):
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.cookies.clear() client.cookies.clear()
login(client, "bob") login(client, "bob")
@@ -99,13 +150,14 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one() bob_row = db_session.query(User).filter_by(username="bob").one()
frame = db_session.get(Frame, 1) cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert frame.calendar_tasks_user_id == bob_row.id assert cfg.tasks_user_id == bob_row.id
assert frame.calendar_tasks_calendar_key == "caldav:/some/tasks/" assert cfg.tasks_calendar_key == "caldav:/some/tasks/"
def test_tasks_source_anyone_linked_can_clear(client, db_session): def test_tasks_source_anyone_linked_can_clear(client, db_session):
_setup_two_linked_users(client, db_session) frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"}, client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"},
headers=csrf_headers(client)) headers=csrf_headers(client))
@@ -114,9 +166,16 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client)) resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1) cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert frame.calendar_tasks_user_id is None assert cfg.tasks_user_id is None
assert frame.calendar_tasks_calendar_key is None assert cfg.tasks_calendar_key is None
def test_tasks_source_404s_when_frame_has_no_calendar_widget(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
headers=csrf_headers(client))
assert resp.status_code == 404
# --- calendar-select --- # --- calendar-select ---
@@ -1,63 +1,72 @@
"""get_or_refresh_whiteboard's fetch throttle (and force=True bypassing """get_or_refresh_whiteboard_for_widget's fetch throttle (and force=True
it) plus the whiteboard-browse HTTP endpoint. whiteboard.fetch_and_render bypassing it) plus the whiteboard-browse HTTP endpoint.
is monkeypatched -- it talks to a real WebDAV server and the Node render whiteboard.fetch_and_render is monkeypatched -- it talks to a real
sidecar (see render-service/), neither of which this suite needs a real WebDAV server and the Node render sidecar (see render-service/), neither
copy of to verify the *throttle*/*permission* logic around it.""" of which this suite needs a real copy of to verify the *throttle*/
*permission* logic around it."""
from __future__ import annotations from __future__ import annotations
import time import time
from app import whiteboard from app import whiteboard
from app.models import Frame, User from app.models import Frame, User, Widget, WhiteboardWidgetConfig
from app.routers.common import get_or_refresh_whiteboard
from .conftest import csrf_headers, link_user, login, make_user from .conftest import csrf_headers, link_user, login, make_user
def _configure_whiteboard(db_session, frame: Frame, user: User) -> None: def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget:
frame.whiteboard_user_id = user.id widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
frame.whiteboard_url = "http://example.invalid/board.whiteboard" sort_order=1, created_at=time.time())
frame.whiteboard_cached_image = b"OLD_CACHED_PNG" db_session.add(widget)
frame.whiteboard_checked_at = time.time() # just refreshed -- well within the throttle db_session.flush()
db_session.add(WhiteboardWidgetConfig(
widget_id=widget.id, user_id=user.id, url="http://example.invalid/board.whiteboard",
cached_image=b"OLD_CACHED_PNG", checked_at=time.time(), # just refreshed -- well within the throttle
))
db_session.commit() db_session.commit()
return widget
def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch): def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch):
from app.routers.common import get_or_refresh_whiteboard_for_widget
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
alice = db_session.query(User).filter_by(username="alice").one() alice = db_session.query(User).filter_by(username="alice").one()
alice.webdav_username = "alice" alice.webdav_username = "alice"
alice.webdav_password = "secret" alice.webdav_password = "secret"
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
_configure_whiteboard(db_session, frame, alice) widget = _configure_whiteboard(db_session, frame, alice)
calls = [] calls = []
monkeypatch.setattr(whiteboard, "fetch_and_render", monkeypatch.setattr(whiteboard, "fetch_and_render",
lambda url, u, p: calls.append(1) or b"NEW_PNG") lambda url, u, p: calls.append(1) or b"NEW_PNG")
result = get_or_refresh_whiteboard(db_session, frame) result = get_or_refresh_whiteboard_for_widget(db_session, frame, widget)
assert result == b"OLD_CACHED_PNG" assert result == b"OLD_CACHED_PNG"
assert calls == [] assert calls == []
def test_force_bypasses_throttle_and_persists(client, db_session, monkeypatch): def test_force_bypasses_throttle_and_persists(client, db_session, monkeypatch):
from app.routers.common import get_or_refresh_whiteboard_for_widget
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
alice = db_session.query(User).filter_by(username="alice").one() alice = db_session.query(User).filter_by(username="alice").one()
alice.webdav_username = "alice" alice.webdav_username = "alice"
alice.webdav_password = "secret" alice.webdav_password = "secret"
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
_configure_whiteboard(db_session, frame, alice) widget = _configure_whiteboard(db_session, frame, alice)
calls = [] calls = []
monkeypatch.setattr(whiteboard, "fetch_and_render", monkeypatch.setattr(whiteboard, "fetch_and_render",
lambda url, u, p: calls.append(1) or b"NEW_PNG") lambda url, u, p: calls.append(1) or b"NEW_PNG")
result = get_or_refresh_whiteboard(db_session, frame, force=True) result = get_or_refresh_whiteboard_for_widget(db_session, frame, widget, force=True)
assert result == b"NEW_PNG" assert result == b"NEW_PNG"
assert len(calls) == 1 assert len(calls) == 1
db_session.refresh(frame) cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
assert frame.whiteboard_cached_image == b"NEW_PNG" assert cfg.cached_image == b"NEW_PNG"
def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session, monkeypatch): def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session, monkeypatch):
@@ -67,10 +76,10 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session
alice.webdav_password = "secret" alice.webdav_password = "secret"
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
# The preview endpoint runs whatever get_or_refresh_whiteboard returns # The preview endpoint runs whatever get_or_refresh_whiteboard_for_widget
# through PIL (Image.open) -- unlike the other tests in this file, # returns through PIL (Image.open) -- unlike the other tests in this
# the placeholder "cached" bytes need to be real, valid PNG data, not # file, the placeholder "cached" bytes need to be real, valid PNG
# just an arbitrary marker string. # data, not just an arbitrary marker string.
import io import io
from PIL import Image from PIL import Image
@@ -78,10 +87,14 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session
Image.new("RGB", (1, 1), (255, 255, 255)).save(buf, format="PNG") Image.new("RGB", (1, 1), (255, 255, 255)).save(buf, format="PNG")
tiny_png = buf.getvalue() tiny_png = buf.getvalue()
frame.whiteboard_user_id = alice.id widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
frame.whiteboard_url = "http://example.invalid/board.whiteboard" sort_order=1, created_at=time.time())
frame.whiteboard_cached_image = tiny_png db_session.add(widget)
frame.whiteboard_checked_at = time.time() db_session.flush()
db_session.add(WhiteboardWidgetConfig(
widget_id=widget.id, user_id=alice.id, url="http://example.invalid/board.whiteboard",
cached_image=tiny_png, checked_at=time.time(),
))
db_session.commit() db_session.commit()
calls = [] calls = []
+25
View File
@@ -100,3 +100,28 @@ def test_back_action_decrements_browse_offset(db_session):
widgets.calendar.ACTIONS["back"](db_session, frame, widget) widgets.calendar.ACTIONS["back"](db_session, frame, widget)
cfg = db_session.get(CalendarWidgetConfig, widget.id) cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.browse_offset == 2 assert cfg.browse_offset == 2
def test_normal_wake_resets_browse_offset_to_zero(db_session, monkeypatch):
"""A plain /frame/image GET (is_normal_wake=True, the default) should
snap browse_offset back to "today" if a previous button press had
moved it -- mirrors the old _render_calendar_mode's identical
behavior. A button-triggered render (is_normal_wake=False) must NOT
do this, or every button press would immediately erase its own
effect on the very next render."""
frame, widget = _make_widget(db_session, view="week", browse_offset=5)
_stub_fetches(monkeypatch)
widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=True)
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 0
def test_button_triggered_render_does_not_reset_browse_offset(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
_stub_fetches(monkeypatch)
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1
widgets.calendar.render(db_session, frame, widget, 400, 300, is_normal_wake=False)
assert db_session.get(CalendarWidgetConfig, widget.id).browse_offset == 1