Widget system Phase 1: per-type render/action modules
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s

New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus
the WIDGET_TYPES registry) -- the widget-system analogue of
routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS,
generalized from "one mode owns the whole panel" to "each widget renders
into its own region and responds to named button actions." Each module
exposes render(db, frame, widget, target_w, target_h) -> Image.Image
(never raises -- a widget's own fetch hiccup falls back to a small
placeholder rather than taking the whole panel's render down) and an
ACTIONS registry for NEXT/BACK button assignment.

Supporting changes needed to give the widget modules something to call,
all mechanical/behavior-preserving for every existing caller:

- image_pipeline.py: render_panel(regions, ...) generalizes render_frame's
  tail (paste, enhance once, overlay once, quantize once, pack once) from
  one photo to N regions -- not a restructuring, since calendar mode's
  photo-inlay feature already pastes a second composed image onto the
  canvas before that single shared pipeline runs.
- photo_queue.py: advance_forced/back_forced/remove_from_rotation/
  get_current take an explicit `frame` param now that `cfg` won't always
  be the Frame itself once photo-queue state moves to PhotoWidgetConfig
  -- caught a real latent bug while doing this: get_current was reading
  refresh_interval_s off `cfg`, but that's a frame-level wake-cadence
  setting, not something that becomes per-widget, so it now reads that
  off `frame` explicitly instead.
- routers/common.py: list_assets/fetch_source_and_faces take album_id/
  display_mode directly instead of a whole Frame (both only ever read
  that one attribute off it); new get_or_refresh_*_for_widget siblings
  of the existing calendar/weather/tasks/whiteboard cache helpers, read/
  writing the new per-widget config tables -- the Frame-scoped originals
  are untouched and still what routers/device.py's actual dispatch calls
  until the Phase 2 cutover.

26 new tests (95 total): render_panel size/placement/orientation
coverage, and per-widget-type render/action tests (unconfigured ->
placeholder, a fetch failure -> placeholder not a crash, actions mutate
the right state). Full suite passes; diff-reviewed to confirm
device.py's actual RENDERERS dispatch and the old Frame-scoped
get_or_refresh_* bodies are unchanged, so this is safe to deploy on its
own despite being step 1 of a two-step cutover (see the project plan on
why the *next* step, not this one, has to ship atomically).
This commit is contained in:
2026-07-24 08:44:53 -04:00
parent 8bc0749b42
commit f48daa71c8
15 changed files with 861 additions and 45 deletions
+41
View File
@@ -381,6 +381,47 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
return _transpose_and_pack(quantized, orientation)
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
dither_strength: float = 1.0, manage: dict | None = None) -> bytes:
"""The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run
the same single shared pipeline over the result." Not a
restructuring: the calendar photo-inlay feature has always pasted a
second, independently-composed image onto the canvas before
`_enhance`/`_quantize` ran exactly once over the whole thing (see
calendar_render.py's _paste_inlay) -- this just generalizes that from
a fixed 1-2 region split to an arbitrary list.
Each region is (rect, image): rect is (x, y, w, h) in *logical*
(pre-rotation) canvas space -- the same space logical_render_size(
orientation) describes, and what app/grid.py's cell_to_pixels()
produces -- and image is an already-composed RGB image exactly w x h
in size (e.g. from compose_into() for a photo/whiteboard widget, or
calendar_render's own builder for a calendar widget). Regions are
expected not to overlap (see models.Widget's docstring on why) --
this function doesn't enforce that itself, callers/the placement API
do, since by the time rendering happens it's too late to do anything
but paste in whatever order they're given (later entries would just
paint over earlier ones).
Quantizing/dithering the *whole* composited canvas once, rather than
each region separately before pasting, is what keeps a 6-color
e-ink panel's dithering pattern consistent across a widget boundary
instead of showing a visible seam where two independently-dithered
regions meet."""
logical_w, logical_h = logical_render_size(orientation)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions:
canvas.paste(region_img.convert("RGB"), (x, y))
fitted = _enhance(canvas, color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
+27 -13
View File
@@ -85,11 +85,21 @@ def _top_up(cfg: Frame, assets: list[dict]) -> None:
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
def advance_forced(cfg: Frame, assets: list[dict], frame: Frame) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used by the explicit next-photo
action (POST /frame/advance) and by get_current() once the refresh
interval has elapsed -- always mutates cfg."""
interval has elapsed -- always mutates cfg.
`frame` is a separate reference to the owning Frame, for fields that
stay frame-level rather than moving onto a photo widget's own config
(currently just stats_photos_displayed) -- once a photo widget's
queue state lives on its own PhotoWidgetConfig row rather than
directly on Frame (see models.py), `cfg` and `frame` stop being the
same object; every existing caller today still passes the same Frame
for both, which is also why this stays a required (not optional)
param -- no implicit "guess which Frame owns this" fallback to get
wrong later."""
if cfg.current_asset_id:
# Recorded regardless of *why* this advance happened (a manual
# next-press or the timer just elapsing) -- back should be able
@@ -105,7 +115,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
# Refill back up to queue_target_len now that current_asset_id has
# changed -- otherwise the queue is left one short until the *next*
# advance, since the pop above consumes one of the items _top_up just
@@ -113,7 +123,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
def back_forced(cfg: Frame, assets: list[dict], frame: Frame) -> bool:
"""Unconditionally moves to the previously-current photo, the mirror
image of advance_forced() -- pops the most recent entry off history,
pushes the photo it's replacing onto the front of queue (so pressing
@@ -122,7 +132,7 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
since). Returns whether it actually moved -- False (history empty or
entirely stale) is a no-op, callers should still just display
whatever's current rather than treating it as an error. Used by the
back-photo button (POST /frame/back)."""
back-photo button (POST /frame/back). See advance_forced() on `frame`."""
valid_ids = {a["id"] for a in assets}
while cfg.history:
previous_id = cfg.history.pop()
@@ -132,12 +142,12 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
return True
return False
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str, frame: Frame) -> bool:
"""Permanently excludes asset_id from this frame's rotation (see the
module docstring) -- doesn't touch Immich, just this frame's own
selection. Scrubs it out of queue and history too, so it can't
@@ -146,10 +156,10 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
*not* through advance_forced(), since that would record the removed
photo in history, and going back to a photo you just explicitly
removed doesn't make sense. Returns whether the current photo
changed as a result."""
changed as a result. See advance_forced() on `frame`."""
if asset_id not in cfg.excluded_asset_ids:
cfg.excluded_asset_ids.append(asset_id)
cfg.stats_photos_removed += 1
frame.stats_photos_removed += 1
cfg.queue = [a for a in cfg.queue if a != asset_id]
cfg.history = [a for a in cfg.history if a != asset_id]
@@ -167,7 +177,7 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
cfg.current_asset_id = remaining[0] if remaining else ""
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
_top_up(cfg, assets)
return True
@@ -180,7 +190,7 @@ def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
_top_up(cfg, assets)
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bool = False) -> bool:
"""Time-based, idempotent path used by GET /frame/image. Advances only
if the current photo is unset/invalid or refresh_interval_s has
elapsed since it was set. Returns whether it changed anything, so the
@@ -190,6 +200,10 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
ahead, while a wake that lands after the interval has elapsed still
advances exactly once, even after a long time offline.
refresh_interval_s is read off `frame`, not `cfg` -- it's a device
wake-cadence setting shared by the whole panel, not something that
becomes per-widget (see advance_forced() on the cfg/frame split).
in_quiet_hours suppresses *only* the elapsed-time trigger -- an
unset/invalid current photo still gets picked regardless, since
showing nothing is worse than showing something even at 3am. This
@@ -200,9 +214,9 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
window (see main.py's _effective_refresh_interval_s)."""
valid_ids = {a["id"] for a in assets}
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
time_elapsed = (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
stale = needs_pick or (time_elapsed and not in_quiet_hours)
if not stale:
return False
advance_forced(cfg, assets)
advance_forced(cfg, assets, frame)
return True
+9 -9
View File
@@ -253,10 +253,10 @@ def api_queue(
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
snapshot = {
"current_asset_id": cfg.current_asset_id,
@@ -372,10 +372,10 @@ def api_queue_remove(
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
photo_queue.remove_from_rotation(cfg, assets, body.asset_id, cfg)
return {"status": "removed"}
@@ -403,9 +403,9 @@ def _current_asset_id(frame: Frame, db: Session) -> str:
never advances early."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
asset_id = cfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
@@ -435,7 +435,7 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
whatever's currently saved."""
asset_id = _current_asset_id(frame, db)
client = immich_client_for(frame)
source, faces = fetch_source_and_faces(client, frame, asset_id)
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=frame.display_mode, color_boost=frame.color_boost,
@@ -546,9 +546,9 @@ def _calendar_photo_inlay(frame: Frame, db: Session):
return None
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
return None
+133 -8
View File
@@ -17,10 +17,10 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
from ..db import frame_locked
from ..db import frame_locked, widget_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import BatteryLog, Frame, FrameCalendar, User
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, User, Widget, WhiteboardWidgetConfig
logger = logging.getLogger(__name__)
@@ -81,9 +81,9 @@ def require_configured(frame: Frame) -> None:
raise HTTPException(400, "No album configured yet")
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
try:
assets = client.list_album_assets(frame.album_id)
assets = client.list_album_assets(album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
if not assets:
@@ -91,18 +91,23 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
return assets
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
def fetch_source_and_faces(
client: ImmichClient, display_mode: str, asset_id: str
) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
and (only if display_mode needs it) its detected faces. Used by both
render_asset (device-facing) and the web UI's rendered-preview
endpoint (routers/api_frames.py) so they can't drift apart."""
endpoint (routers/api_frames.py) so they can't drift apart. Takes
display_mode directly (a photos widget's own setting, see
PhotoWidgetConfig) rather than a whole Frame -- this function only
ever needed that one attribute off it."""
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
faces = None
if frame.display_mode == "crop_faces":
if display_mode == "crop_faces":
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
@@ -114,7 +119,7 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
source, faces = fetch_source_and_faces(client, frame, asset_id)
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
return render_frame(source, faces=faces, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
@@ -480,6 +485,38 @@ def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict
return events, summary
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
"""Widget-scoped twin of get_or_refresh_calendar_events above,
reading/writing CalendarWidgetConfig instead of Frame columns
directly -- same throttle/caching rationale, unchanged. Not yet used
by any router (see app/widgets/calendar.py, which this backs) --
device.py's actual dispatch still calls the Frame-scoped version
above until the widget-system cutover lands; both exist side by side
until then. calendar_sources_for_frame stays frame_id-scoped (see
FrameCalendar's own docstring) until a later phase re-keys it to
widget_id, so every calendar widget on a frame currently shares the
same "included calendars" set -- not a real limitation yet since
nothing supports more than one calendar widget per frame end to end
until that phase lands."""
cfg = db.get(CalendarWidgetConfig, widget.id)
now = time.time()
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_events, cfg.fetch_summary
sources = calendar_sources_for_frame(db, frame)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
)
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached_events = events
locked_cfg.fetch_summary = summary
locked_cfg.checked_at = now
return events, summary
def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
much longer than calendar_feed's -- weather doesn't need to be
@@ -513,6 +550,34 @@ def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
return result
def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Widget-scoped twin of get_or_refresh_weather above -- same
throttle/caching rationale, unchanged. Not yet used by any router
(see app/widgets/calendar.py) -- both versions coexist until the
widget-system cutover lands."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.weather_enabled or not cfg.weather_cities:
return []
now = time.time()
if cfg.weather_cached is not None and now - cfg.weather_checked_at < weather.CHECK_INTERVAL_S:
return cfg.weather_cached
previous_days = {c["label"]: c.get("days", {}) for c in (cfg.weather_cached or [])}
result = []
for city in cfg.weather_cities:
try:
days = weather.fetch_daily_forecast(city["latitude"], city["longitude"], cfg.weather_units)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
days = previous_days.get(city["label"], {})
result.append({"label": city["label"], "days": days})
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.weather_cached = result
locked_cfg.weather_checked_at = now
return result
def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
"""Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S,
same cadence as event merging) -- [] if tasks are off, no source is
@@ -544,6 +609,35 @@ def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
return tasks
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Widget-scoped twin of get_or_refresh_tasks above -- same throttle/
caching rationale, unchanged. Not yet used by any router (see
app/widgets/calendar.py) -- both versions coexist until the
widget-system cutover lands."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
return []
now = time.time()
if cfg.tasks_cached is not None and now - cfg.tasks_checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.tasks_cached
user = db.get(User, cfg.tasks_user_id)
key = cfg.tasks_calendar_key
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
return cfg.tasks_cached or []
href = key[len("caldav:"):]
try:
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
except caldav_client.CalDavError as e:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, e)
return cfg.tasks_cached or []
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.tasks_cached = tasks
locked_cfg.tasks_checked_at = now
return tasks
def webdav_creds_for(user: User) -> tuple[str, str] | None:
"""(username, password) for `user`'s WebDAV access -- their own
dedicated webdav_username/password, or (if they opted in)
@@ -592,3 +686,34 @@ def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) ->
locked.whiteboard_cached_image = png
locked.whiteboard_checked_at = now
return png
def get_or_refresh_whiteboard_for_widget(
db: Session, frame: Frame, widget: Widget, force: bool = False
) -> bytes | None:
"""Widget-scoped twin of get_or_refresh_whiteboard above -- same
throttle/caching/force rationale, unchanged. Not yet used by any
router (see app/widgets/whiteboard.py) -- both versions coexist
until the widget-system cutover lands."""
cfg = db.get(WhiteboardWidgetConfig, widget.id)
if not cfg.url or not cfg.user_id:
return None
now = time.time()
if not force and cfg.cached_image is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_image
user = db.get(User, cfg.user_id)
creds = webdav_creds_for(user) if user else None
if creds is None:
return cfg.cached_image
try:
png = whiteboard.fetch_and_render(cfg.url, creds[0], creds[1])
except whiteboard.WhiteboardRenderError as e:
logger.warning("Could not refresh whiteboard for widget %d: %s", widget.id, e)
return cfg.cached_image
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached_image = png
locked_cfg.checked_at = now
return png
+8 -8
View File
@@ -97,10 +97,10 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dic
if not _frame_configured(frame):
return _setup_placeholder(frame, request, manage=manage)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
@@ -109,10 +109,10 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dic
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
photo_queue.advance_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
@@ -121,10 +121,10 @@ def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> byte
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
photo_queue.back_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
@@ -168,9 +168,9 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
if inlay_wanted and _frame_configured(frame):
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if asset_id:
jpeg_bytes = client.download_asset_preview(asset_id)
+6 -6
View File
@@ -48,10 +48,10 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.get_current(cfg, assets, cfg, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
current = cfg.current_asset_id
queue = list(cfg.queue)
@@ -89,9 +89,9 @@ def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends
on the device's next wake (or its next-photo button)."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.advance_forced(cfg, assets)
photo_queue.advance_forced(cfg, assets, cfg)
return {"status": "saved"}
@@ -99,9 +99,9 @@ def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.back_forced(cfg, assets)
photo_queue.back_forced(cfg, assets, cfg)
return {"status": "saved"}
+40
View File
@@ -0,0 +1,40 @@
"""Registry mapping a Widget's widget_type to its render/action module --
the widget-system's analogue of routers/device.py's old RENDERERS/
ADVANCE_RENDERERS/BACK_RENDERERS dicts, generalized from "one mode owns
the whole panel" to "each widget renders into its own region and
optionally responds to named button actions."
Each module in this package exposes:
render(db, frame, widget, target_w, target_h) -> Image.Image
An RGB image exactly target_w x target_h, unquantized -- the
widget's content composed into its own region. Never returns
packed panel bytes or raises for a foreseeable failure (a
widget's own fetch hiccup shows a small placeholder instead) --
image_pipeline.render_panel composites every widget's own
render() result onto one shared canvas and quantizes/packs the
whole thing once (see its own docstring).
ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
Named button actions this widget type supports (e.g. "advance",
"back", "check_now") -- see models.FrameButtonAction. Each
function mutates the widget's own state via db.widget_locked
internally; none return a value or re-render themselves --
whatever dispatches a button press (routers/device.py, once the
widget-system cutover lands) re-renders the whole panel once
after running every assigned action.
ACTION_LABELS: dict[str, str]
Human-readable labels for ACTIONS' keys, for the button-
assignment UI.
"""
from __future__ import annotations
from . import calendar, photos, whiteboard
WIDGET_TYPES = {
"photos": photos,
"calendar": calendar,
"whiteboard": whiteboard,
}
+30
View File
@@ -0,0 +1,30 @@
"""Tiny widget-region placeholder image, shared by every widget-type
module for the "not configured yet" / "temporarily unavailable" case --
deliberately much simpler than image_pipeline.render_placeholder (no QR
code, no full-panel-scale fonts): a widget's own region can be a small
fraction of the panel, so its placeholder needs to scale down with it.
"""
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
_BG = (245, 245, 245)
_FG = (90, 90, 90)
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
img = Image.new("RGB", (target_w, target_h), _BG)
draw = ImageDraw.Draw(img)
font_size = max(10, min(20, target_h // 8))
font = ImageFont.load_default(size=font_size)
line_h = font_size + 4
total_h = line_h * len(lines)
y = max(4, (target_h - total_h) // 2)
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
x = max(4, (target_w - line_w) // 2)
draw.text((x, y), line, fill=_FG, font=font)
y += line_h
return img
+68
View File
@@ -0,0 +1,68 @@
"""Calendar widget: merged-event agenda/week/month view into the
widget's own region -- the widget-system analogue of routers/device.py's
old _render_calendar_mode/_advance_calendar_mode/_back_calendar_mode.
Full-panel-only in this phase: calendar_render.py's layout math (font
sizes, margins, row heights) is still tuned for a full ~800x480 canvas,
not derived from an arbitrary target box -- see calendar_render._build.
render() below builds at the frame's own logical_render_size(orientation)
and resizes to whatever target box it's actually asked for, which is
correct today (the only calendar widget that exists pre-Phase-4a is the
single auto-migrated full-panel one) but not yet a real "small calendar
widget" layout -- that's a later phase's job (see the project's plan
file), not a shortcut being silently taken here.
"Photo inlay" (calendar mode's old half-and-half photo split) has no
widget-system equivalent -- place an independent photo widget alongside
instead; arbitrary placement is strictly more flexible than one fixed
split ever was."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from ..calendar_render import _build
from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget
from ..routers.common import (
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
)
ACTION_LABELS = {"advance": "Next period", "back": "Previous period"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
cfg = db.get(CalendarWidgetConfig, widget.id)
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
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (cfg.view == "week" and cfg.tasks_enabled) else None
)
img = _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, orientation=frame.orientation,
timezone=frame.timezone, photo_inlay=None, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout, tasks=tasks,
week_start_offset=cfg.week_start_offset,
)
if img.size != (target_w, target_h):
img = img.resize((target_w, target_h), Image.LANCZOS)
return img
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.browse_offset += 1
def _back(db: Session, frame: Frame, widget: Widget) -> None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.browse_offset -= 1
ACTIONS = {"advance": _advance, "back": _back}
+76
View File
@@ -0,0 +1,76 @@
"""Photos widget: composes one photo from Immich into the widget's own
region -- the widget-system analogue of routers/device.py's old
_render_photos_mode/_advance_photos_mode/_back_photos_mode, and
app/photo_queue.py's real client.
render() never raises -- an Immich hiccup for this one widget shouldn't
take down the whole panel's render just because one region out of
several couldn't be composed this cycle; it falls back to a small
placeholder instead, the same resilience calendar mode's old photo-inlay
already had (see routers/device.py's `except HTTPException: pass` around
its own inlay fetch)."""
from __future__ import annotations
from fastapi import HTTPException
from PIL import Image
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import widget_locked
from ..image_pipeline import compose_into
from ..models import Frame, PhotoWidgetConfig, Widget
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
from ._shared import placeholder_image
ACTION_LABELS = {"advance": "Next photo", "back": "Previous photo"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id:
return placeholder_image(target_w, target_h, ["Photos widget", "not configured yet"])
try:
client = immich_client_for(frame)
assets = list_assets(client, cfg.album_id)
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
photo_queue.get_current(locked_cfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
asset_id = locked_cfg.current_asset_id
if not asset_id:
return placeholder_image(target_w, target_h, ["No photos available"])
source, faces = fetch_source_and_faces(client, cfg.display_mode, asset_id)
except HTTPException as e:
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
return compose_into(source, faces, target_w, target_h, cfg.display_mode)
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id:
return
try:
client = immich_client_for(frame)
assets = list_assets(client, cfg.album_id)
except HTTPException:
return # nothing to advance to this cycle -- next button press tries again
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
photo_queue.advance_forced(locked_cfg, assets, locked_frame)
def _back(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id:
return
try:
client = immich_client_for(frame)
assets = list_assets(client, cfg.album_id)
except HTTPException:
return
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_cfg):
photo_queue.back_forced(locked_cfg, assets, locked_frame)
ACTIONS = {"advance": _advance, "back": _back}
+41
View File
@@ -0,0 +1,41 @@
"""Whiteboard widget: fetches/renders a Nextcloud Whiteboard (or any
WebDAV .whiteboard file) into the widget's own region -- the
widget-system analogue of routers/device.py's old
_render_whiteboard_mode/_advance_whiteboard_mode/_back_whiteboard_mode.
No real "next"/"back" concept for a static board (same as before the
widget system) -- both buttons map to the same "check now" action, a
forced re-fetch/re-render bypassing the normal throttle."""
from __future__ import annotations
import io
from PIL import Image
from sqlalchemy.orm import Session
from ..image_pipeline import compose_into
from ..models import Frame, Widget
from ..routers.common import get_or_refresh_whiteboard_for_widget
from ._shared import placeholder_image
ACTION_LABELS = {"check_now": "Check for updates"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int) -> Image.Image:
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget)
if png_bytes is None:
return placeholder_image(target_w, target_h, ["Whiteboard widget", "not configured yet"])
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
# letterbox, never cropped: unlike a photo, losing part of a
# whiteboard to a crop loses actual content, not just some background
# (see the old _render_whiteboard_mode's identical reasoning).
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
get_or_refresh_whiteboard_for_widget(db, frame, widget, force=True)
ACTIONS = {"check_now": _check_now}
+79 -1
View File
@@ -8,9 +8,11 @@ high-value regression guard: pure PIL rendering, no DB/HTTP/Node."""
from __future__ import annotations
import pytest
from PIL import Image
from app.calendar_render import CALENDAR_VIEWS, render_calendar
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_placeholder
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
EXPECTED_BYTES = EPD_WIDTH * EPD_HEIGHT // 2
ORIENTATIONS = ["landscape", "landscape_flipped", "portrait", "portrait_flipped"]
@@ -65,3 +67,79 @@ def test_calendar_render_size_with_week_start_offset():
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
palette_rgb=None, timezone="UTC", week_days=3, week_start_offset=2)
assert len(data) == EXPECTED_BYTES
# --- render_panel (the widget-system compositor) ---
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_panel_size_single_full_panel_region(orientation):
from app.image_pipeline import logical_render_size
w, h = logical_render_size(orientation)
region = Image.new("RGB", (w, h), (200, 0, 0))
data = render_panel([((0, 0, w, h), region)], orientation=orientation)
assert len(data) == EXPECTED_BYTES
def test_render_panel_size_multiple_non_overlapping_regions():
cols, rows = grid_dims("landscape")
left_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, (0, 0, cols // 2, rows))
right_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, (cols // 2, 0, cols - cols // 2, rows))
regions = [
(left_px, Image.new("RGB", left_px[2:], (200, 0, 0))),
(right_px, Image.new("RGB", right_px[2:], (0, 0, 200))),
]
data = render_panel(regions, orientation="landscape")
assert len(data) == EXPECTED_BYTES
def test_render_panel_empty_region_list_is_blank_but_correctly_sized():
"""No widgets on a frame yet (or all somehow filtered out) shouldn't
crash the compositor -- just a blank panel, same size invariant."""
data = render_panel([], orientation="landscape")
assert len(data) == EXPECTED_BYTES
def test_render_panel_pastes_regions_at_the_right_place():
"""Not just a size check -- confirms two regions actually land where
their rects say, not just that *something* the right size comes out."""
cols, rows = grid_dims("landscape")
left_rect = (0, 0, cols // 2, rows)
right_rect = (cols // 2, 0, cols - cols // 2, rows)
left_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, left_rect)
right_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, right_rect)
# Pure red vs pure blue, both already-palette colors, dither_strength=0
# so quantization can't introduce any blending/dithering noise --
# every pixel on each side should land on exactly the color it started as.
regions = [
(left_px, Image.new("RGB", left_px[2:], (255, 0, 0))),
(right_px, Image.new("RGB", right_px[2:], (0, 0, 255))),
]
data = render_panel(regions, orientation="landscape", dither_strength=0.0)
from app.image_pipeline import PANEL_CODES
def code_at(x, y):
i = (y * EPD_WIDTH + x) // 2
byte = data[i]
return (byte >> 4) if x % 2 == 0 else (byte & 0x0F)
red_code = PANEL_CODES[3] # DEFAULT_PALETTE_RGB index 3 = RED
blue_code = PANEL_CODES[4] # index 4 = BLUE
# Sample well inside each half, away from the boundary, at a y
# comfortably inside the panel.
assert code_at(50, 240) == red_code
assert code_at(750, 240) == blue_code
def test_render_panel_backfilled_full_panel_widget_matches_grid_full_panel_rect():
"""Sanity-links app.grid's full_panel_rect (what the migration backfill
uses for the single auto-migrated widget) to render_panel's own size
invariant, so a mismatch between the two would fail loudly here."""
rect = full_panel_rect("landscape")
px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, rect)
assert px == (0, 0, EPD_WIDTH, EPD_HEIGHT)
region = Image.new("RGB", px[2:], (10, 20, 30))
data = render_panel([(px, region)], orientation="landscape")
assert len(data) == EXPECTED_BYTES
+102
View File
@@ -0,0 +1,102 @@
"""app.widgets.calendar -- unit-level, no HTTP: constructs Widget/
CalendarWidgetConfig rows directly and monkeypatches the underlying
fetch calls (get_or_refresh_*_for_widget), which have their own
dedicated fetch/merge test coverage elsewhere (test_calendar_feed.py
etc.) -- this file is about the widget wiring itself: does render()
produce a correctly-sized image, do advance/back move browse_offset."""
from __future__ import annotations
import time
from app import widgets
from app.db import widget_locked
from app.models import CalendarWidgetConfig, Frame, Widget
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, **cfg_kwargs))
db_session.commit()
return frame, widget
def _stub_fetches(monkeypatch, events=None, weather=None, tasks=None):
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: (events or [], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: weather or [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: tasks or [])
def test_render_produces_a_correctly_sized_image(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda")
_stub_fetches(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
assert img.mode == "RGB"
def test_render_resizes_when_target_differs_from_full_panel(db_session, monkeypatch):
"""Phase-1 calendar widgets are still full-panel-layout internally
(see app/widgets/calendar.py's own module docstring) -- resizing to
fit whatever target box is asked for keeps render_panel's contract
(exact target_w x target_h) satisfied even before real small-widget
layout support lands."""
frame, widget = _make_widget(db_session, view="week")
_stub_fetches(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 250, 150)
assert img.size == (250, 150)
def test_weather_only_fetched_when_enabled(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda", weather_enabled=False)
calls = []
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: calls.append(1) or [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: [])
widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == []
def test_tasks_only_fetched_for_week_view_when_enabled(db_session, monkeypatch):
frame, widget = _make_widget(db_session, view="agenda", tasks_enabled=True) # not week view
calls = []
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
monkeypatch.setattr(widgets.calendar, "get_or_refresh_tasks_for_widget",
lambda db, frame, widget: calls.append(1) or [])
widgets.calendar.render(db_session, frame, widget, 400, 300)
assert calls == [] # agenda view -- tasks never shown, so never fetched
def test_advance_action_increments_browse_offset(db_session):
frame, widget = _make_widget(db_session, view="week", browse_offset=0)
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.browse_offset == 1
widgets.calendar.ACTIONS["advance"](db_session, frame, widget)
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.browse_offset == 2 # accumulates, doesn't reset-then-increment
def test_back_action_decrements_browse_offset(db_session):
frame, widget = _make_widget(db_session, view="week", browse_offset=3)
widgets.calendar.ACTIONS["back"](db_session, frame, widget)
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.browse_offset == 2
+116
View File
@@ -0,0 +1,116 @@
"""app.widgets.photos -- unit-level, no HTTP: constructs Widget/
PhotoWidgetConfig rows directly and monkeypatches the Immich-facing
calls (list_assets/fetch_source_and_faces/immich_client_for) rather than
standing up a fake Immich server, since this module's own logic (what it
does with whatever Immich returns) is what's under test, not Immich's
API shape -- already covered by ImmichClient's own tests if any, and by
this suite's HTTP-level permission-boundary tests elsewhere."""
from __future__ import annotations
import time
from PIL import Image
from app import widgets
from app.db import widget_locked
from app.models import Frame, PhotoWidgetConfig, Widget
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
def _make_widget(db_session, album_id="album-1") -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(PhotoWidgetConfig(widget_id=widget.id, album_id=album_id, queue_target_len=5))
db_session.commit()
return frame, widget
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session):
frame, widget = _make_widget(db_session, album_id="")
img = widgets.photos.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
assert img.mode == "RGB"
def test_render_configured_widget_composes_a_photo(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
source = Image.new("RGB", (100, 80), (10, 20, 30))
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
img = widgets.photos.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id == "asset-1" # get_current picked the first asset
def test_render_falls_back_to_placeholder_on_immich_failure(db_session, monkeypatch):
from fastapi import HTTPException
frame, widget = _make_widget(db_session)
def _raise(client, album_id):
raise HTTPException(502, "Could not reach Immich")
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", _raise)
img = widgets.photos.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300) # placeholder, not a crash
def test_advance_action_moves_to_next_photo(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
with widget_locked(db_session, frame.id, widget.id) as (_, _, cfg):
cfg.current_asset_id = "asset-1"
widgets.photos.ACTIONS["advance"](db_session, frame, widget)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id != "asset-1"
assert cfg.history == ["asset-1"]
def test_back_action_undoes_advance(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
# advance_forced only pushes the *previous* current photo onto history
# -- the very first advance from an empty current_asset_id has nothing
# to push, so a second advance is needed before there's anything for
# "back" to undo.
widgets.photos.ACTIONS["advance"](db_session, frame, widget)
first = db_session.get(PhotoWidgetConfig, widget.id).current_asset_id
widgets.photos.ACTIONS["advance"](db_session, frame, widget)
second = db_session.get(PhotoWidgetConfig, widget.id).current_asset_id
assert second != first
widgets.photos.ACTIONS["back"](db_session, frame, widget)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id == first
def test_advance_action_is_a_no_op_when_unconfigured(db_session):
frame, widget = _make_widget(db_session, album_id="")
widgets.photos.ACTIONS["advance"](db_session, frame, widget) # must not raise
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.current_asset_id == ""
def test_advance_uses_the_frame_stats_counter_not_the_widget_config():
"""advance_forced's frame/stats split (see app/photo_queue.py) means
stats_photos_displayed should land on the Frame row, never on
PhotoWidgetConfig (which has no such column at all)."""
assert not hasattr(PhotoWidgetConfig, "stats_photos_displayed")
+85
View File
@@ -0,0 +1,85 @@
"""app.widgets.whiteboard -- unit-level, no HTTP: constructs Widget/
WhiteboardWidgetConfig rows directly and monkeypatches the underlying
fetch/render call (get_or_refresh_whiteboard_for_widget, already covered
against a real WebDAV server + mocked sidecar in
test_whiteboard_refresh_and_browse.py) rather than re-testing that
throttle/fetch logic here."""
from __future__ import annotations
import io
import time
from PIL import Image
from app import widgets
from app.models import Frame, Widget, WhiteboardWidgetConfig
def _make_widget(db_session) -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard"))
db_session.commit()
return frame, widget
def _tiny_png() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (40, 20), (10, 20, 30)).save(buf, format="PNG")
return buf.getvalue()
def test_render_unconfigured_widget_returns_correctly_sized_placeholder(db_session):
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id)) # no url/user_id set
db_session.commit()
img = widgets.whiteboard.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_render_configured_widget_composes_the_fetched_png(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
png = _tiny_png()
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
lambda db, frame, widget, force=False: png)
img = widgets.whiteboard.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_falls_back_to_placeholder_when_nothing_cached_yet(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
lambda db, frame, widget, force=False: None)
img = widgets.whiteboard.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
def test_check_now_action_forces_a_refetch(db_session, monkeypatch):
frame, widget = _make_widget(db_session)
calls = []
def fake_refresh(db, frame, widget, force=False):
calls.append(force)
return _tiny_png()
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget", fake_refresh)
widgets.whiteboard.ACTIONS["check_now"](db_session, frame, widget)
assert calls == [True]
def test_both_buttons_map_to_check_now():
"""No real "next"/"back" concept for a static board -- both physical
buttons mean the same thing for a whiteboard widget."""
assert set(widgets.whiteboard.ACTIONS) == {"check_now"}