Widget system Phase 1: per-type render/action modules
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:
@@ -381,6 +381,47 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
|||||||
return _transpose_and_pack(quantized, orientation)
|
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,
|
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
||||||
|
|||||||
+27
-13
@@ -85,11 +85,21 @@ def _top_up(cfg: Frame, assets: list[dict]) -> None:
|
|||||||
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
|
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
|
"""Unconditionally moves to the next photo, ignoring elapsed time, and
|
||||||
resets the interval clock from now. Used by the explicit next-photo
|
resets the interval clock from now. Used by the explicit next-photo
|
||||||
action (POST /frame/advance) and by get_current() once the refresh
|
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:
|
if cfg.current_asset_id:
|
||||||
# Recorded regardless of *why* this advance happened (a manual
|
# Recorded regardless of *why* this advance happened (a manual
|
||||||
# next-press or the timer just elapsing) -- back should be able
|
# 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.
|
# only asset is already current) -- keep showing what we have.
|
||||||
cfg.current_asset_id = assets[0]["id"]
|
cfg.current_asset_id = assets[0]["id"]
|
||||||
cfg.current_asset_set_at = time.time()
|
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
|
# Refill back up to queue_target_len now that current_asset_id has
|
||||||
# changed -- otherwise the queue is left one short until the *next*
|
# changed -- otherwise the queue is left one short until the *next*
|
||||||
# advance, since the pop above consumes one of the items _top_up just
|
# 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)
|
_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
|
"""Unconditionally moves to the previously-current photo, the mirror
|
||||||
image of advance_forced() -- pops the most recent entry off history,
|
image of advance_forced() -- pops the most recent entry off history,
|
||||||
pushes the photo it's replacing onto the front of queue (so pressing
|
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
|
since). Returns whether it actually moved -- False (history empty or
|
||||||
entirely stale) is a no-op, callers should still just display
|
entirely stale) is a no-op, callers should still just display
|
||||||
whatever's current rather than treating it as an error. Used by the
|
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}
|
valid_ids = {a["id"] for a in assets}
|
||||||
while cfg.history:
|
while cfg.history:
|
||||||
previous_id = cfg.history.pop()
|
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.queue.insert(0, cfg.current_asset_id)
|
||||||
cfg.current_asset_id = previous_id
|
cfg.current_asset_id = previous_id
|
||||||
cfg.current_asset_set_at = time.time()
|
cfg.current_asset_set_at = time.time()
|
||||||
cfg.stats_photos_displayed += 1
|
frame.stats_photos_displayed += 1
|
||||||
return True
|
return True
|
||||||
return False
|
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
|
"""Permanently excludes asset_id from this frame's rotation (see the
|
||||||
module docstring) -- doesn't touch Immich, just this frame's own
|
module docstring) -- doesn't touch Immich, just this frame's own
|
||||||
selection. Scrubs it out of queue and history too, so it can't
|
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
|
*not* through advance_forced(), since that would record the removed
|
||||||
photo in history, and going back to a photo you just explicitly
|
photo in history, and going back to a photo you just explicitly
|
||||||
removed doesn't make sense. Returns whether the current photo
|
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:
|
if asset_id not in cfg.excluded_asset_ids:
|
||||||
cfg.excluded_asset_ids.append(asset_id)
|
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.queue = [a for a in cfg.queue if a != asset_id]
|
||||||
cfg.history = [a for a in cfg.history 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]
|
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_id = remaining[0] if remaining else ""
|
||||||
cfg.current_asset_set_at = time.time()
|
cfg.current_asset_set_at = time.time()
|
||||||
cfg.stats_photos_displayed += 1
|
frame.stats_photos_displayed += 1
|
||||||
_top_up(cfg, assets)
|
_top_up(cfg, assets)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -180,7 +190,7 @@ def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
|
|||||||
_top_up(cfg, assets)
|
_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
|
"""Time-based, idempotent path used by GET /frame/image. Advances only
|
||||||
if the current photo is unset/invalid or refresh_interval_s has
|
if the current photo is unset/invalid or refresh_interval_s has
|
||||||
elapsed since it was set. Returns whether it changed anything, so the
|
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
|
ahead, while a wake that lands after the interval has elapsed still
|
||||||
advances exactly once, even after a long time offline.
|
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
|
in_quiet_hours suppresses *only* the elapsed-time trigger -- an
|
||||||
unset/invalid current photo still gets picked regardless, since
|
unset/invalid current photo still gets picked regardless, since
|
||||||
showing nothing is worse than showing something even at 3am. This
|
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)."""
|
window (see main.py's _effective_refresh_interval_s)."""
|
||||||
valid_ids = {a["id"] for a in assets}
|
valid_ids = {a["id"] for a in assets}
|
||||||
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
|
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)
|
stale = needs_pick or (time_elapsed and not in_quiet_hours)
|
||||||
if not stale:
|
if not stale:
|
||||||
return False
|
return False
|
||||||
advance_forced(cfg, assets)
|
advance_forced(cfg, assets, frame)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -253,10 +253,10 @@ def api_queue(
|
|||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
|
|
||||||
client = immich_client_for(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:
|
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)
|
photo_queue.sync_queue_length(cfg, assets)
|
||||||
snapshot = {
|
snapshot = {
|
||||||
"current_asset_id": cfg.current_asset_id,
|
"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()."""
|
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
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"}
|
return {"status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
@@ -403,9 +403,9 @@ def _current_asset_id(frame: Frame, db: Session) -> str:
|
|||||||
never advances early."""
|
never advances early."""
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
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
|
asset_id = cfg.current_asset_id
|
||||||
if not asset_id:
|
if not asset_id:
|
||||||
raise HTTPException(404, "No current photo")
|
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."""
|
whatever's currently saved."""
|
||||||
asset_id = _current_asset_id(frame, db)
|
asset_id = _current_asset_id(frame, db)
|
||||||
client = immich_client_for(frame)
|
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(
|
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=frame.display_mode, color_boost=frame.color_boost,
|
||||||
@@ -546,9 +546,9 @@ def _calendar_photo_inlay(frame: Frame, db: Session):
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
client = immich_client_for(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:
|
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
|
asset_id = locked.current_asset_id
|
||||||
if not asset_id:
|
if not asset_id:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ 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, quiet_hours, weather, whiteboard
|
||||||
from ..db import frame_locked
|
from ..db import frame_locked, widget_locked
|
||||||
from ..image_pipeline import render_frame
|
from ..image_pipeline import render_frame
|
||||||
from ..immich_client import ImmichClient
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@ def require_configured(frame: Frame) -> None:
|
|||||||
raise HTTPException(400, "No album configured yet")
|
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:
|
try:
|
||||||
assets = client.list_album_assets(frame.album_id)
|
assets = client.list_album_assets(album_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||||
if not assets:
|
if not assets:
|
||||||
@@ -91,18 +91,23 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
|||||||
return assets
|
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
|
"""The shared first half of rendering: download the Immich preview
|
||||||
and (only if display_mode needs it) its detected faces. Used by both
|
and (only if display_mode needs it) its detected faces. Used by both
|
||||||
render_asset (device-facing) and the web UI's rendered-preview
|
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:
|
try:
|
||||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||||
|
|
||||||
faces = None
|
faces = None
|
||||||
if frame.display_mode == "crop_faces":
|
if display_mode == "crop_faces":
|
||||||
try:
|
try:
|
||||||
faces = client.get_asset_faces(asset_id)
|
faces = client.get_asset_faces(asset_id)
|
||||||
except httpx.HTTPError as e:
|
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:
|
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,
|
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
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
|
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]:
|
def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
|
||||||
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
|
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
|
||||||
much longer than calendar_feed's -- weather doesn't need to be
|
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
|
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]:
|
def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
|
||||||
"""Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S,
|
"""Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S,
|
||||||
same cadence as event merging) -- [] if tasks are off, no source is
|
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
|
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:
|
def webdav_creds_for(user: User) -> tuple[str, str] | None:
|
||||||
"""(username, password) for `user`'s WebDAV access -- their own
|
"""(username, password) for `user`'s WebDAV access -- their own
|
||||||
dedicated webdav_username/password, or (if they opted in)
|
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_cached_image = png
|
||||||
locked.whiteboard_checked_at = now
|
locked.whiteboard_checked_at = now
|
||||||
return png
|
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
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dic
|
|||||||
if not _frame_configured(frame):
|
if not _frame_configured(frame):
|
||||||
return _setup_placeholder(frame, request, manage=manage)
|
return _setup_placeholder(frame, request, manage=manage)
|
||||||
client = immich_client_for(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:
|
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
|
asset_id = locked.current_asset_id
|
||||||
|
|
||||||
return render_asset(client, frame, asset_id, manage=manage)
|
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:
|
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
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
|
asset_id = locked.current_asset_id
|
||||||
|
|
||||||
return render_asset(client, frame, asset_id, manage=manage)
|
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:
|
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
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
|
asset_id = locked.current_asset_id
|
||||||
|
|
||||||
return render_asset(client, frame, asset_id, manage=manage)
|
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):
|
if inlay_wanted and _frame_configured(frame):
|
||||||
try:
|
try:
|
||||||
client = immich_client_for(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:
|
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
|
asset_id = locked.current_asset_id
|
||||||
if asset_id:
|
if asset_id:
|
||||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||||
|
|||||||
@@ -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)):
|
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
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)
|
photo_queue.sync_queue_length(cfg, assets)
|
||||||
current = cfg.current_asset_id
|
current = cfg.current_asset_id
|
||||||
queue = list(cfg.queue)
|
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)."""
|
on the device's next wake (or its next-photo button)."""
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
photo_queue.advance_forced(cfg, assets)
|
photo_queue.advance_forced(cfg, assets, cfg)
|
||||||
return {"status": "saved"}
|
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)):
|
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
client = immich_client_for(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:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
photo_queue.back_forced(cfg, assets)
|
photo_queue.back_forced(cfg, assets, cfg)
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -8,9 +8,11 @@ high-value regression guard: pure PIL rendering, no DB/HTTP/Node."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from app.calendar_render import CALENDAR_VIEWS, render_calendar
|
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
|
EXPECTED_BYTES = EPD_WIDTH * EPD_HEIGHT // 2
|
||||||
ORIENTATIONS = ["landscape", "landscape_flipped", "portrait", "portrait_flipped"]
|
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",
|
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
||||||
palette_rgb=None, timezone="UTC", week_days=3, week_start_offset=2)
|
palette_rgb=None, timezone="UTC", week_days=3, week_start_offset=2)
|
||||||
assert len(data) == EXPECTED_BYTES
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user