Widget system Phase 2: full cutover to widget-based rendering
device.py's mode-keyed dispatch is replaced by a real compositor: load a frame's widgets, compute pixel rects via app/grid.py, render each through its widget module, and composite with render_panel. Physical NEXT/BACK buttons now execute each frame's assigned FrameButtonAction rows instead of one hardcoded per-mode action. api_frames.py, manage.py, and common.py's build_manage_content are repointed to read/write the frame's widget config rows instead of the old Frame columns, and every settings page (Photos/Calendar/ Whiteboard tabs) now pre-fills its form from the same widget config the write endpoints actually save to -- previously the read and write sides would have silently diverged. The old mode selector and photo-inlay checkbox are removed along with their now-inert wiring; arbitrary widget placement subsumes what the fixed inlay split did. Ships together with Phase 1 (per-type render/action modules) since splitting the read/write cutover across deploys would have left settings changes with no visible effect.
This commit is contained in:
+157
-219
@@ -16,11 +16,21 @@ from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
|
||||
from ..db import frame_locked, widget_locked
|
||||
from ..image_pipeline import render_frame
|
||||
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
|
||||
from ..db import widget_locked
|
||||
from ..image_pipeline import logical_render_size
|
||||
from ..immich_client import ImmichClient
|
||||
from ..models import BatteryLog, CalendarWidgetConfig, Frame, FrameCalendar, User, Widget, WhiteboardWidgetConfig
|
||||
from ..models import (
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameCalendar,
|
||||
PhotoWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,14 +83,6 @@ def immich_client_for(frame: Frame) -> ImmichClient:
|
||||
return ImmichClient(url, key)
|
||||
|
||||
|
||||
def require_configured(frame: Frame) -> None:
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
if not frame.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
|
||||
|
||||
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
|
||||
try:
|
||||
assets = client.list_album_assets(album_id)
|
||||
@@ -118,14 +120,6 @@ def fetch_source_and_faces(
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
|
||||
source, faces = fetch_source_and_faces(client, frame.display_mode, asset_id)
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength, manage=manage)
|
||||
|
||||
|
||||
def _avg_wake_interval_s(frame: Frame) -> float:
|
||||
"""Average wall-clock seconds between wakes: refresh_interval_s
|
||||
scaled up for however much of each day quiet hours removes from the
|
||||
@@ -355,30 +349,63 @@ def _format_taken_at(exif: dict) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _manage_content_asset_id(frame: Frame) -> str | None:
|
||||
"""Whether frame.current_asset_id refers to a photo actually visible
|
||||
right now, for whichever mode is active -- always true in photos
|
||||
mode; only true in calendar mode when that view's photo inlay is on
|
||||
(otherwise current_asset_id could be stale, left over from whenever
|
||||
photos mode last ran, and showing its location/date/share info on a
|
||||
manage overlay over a view with no visible photo at all would be
|
||||
actively misleading, not just unhelpful)."""
|
||||
relevant = frame.mode != "calendar" or frame.calendar_photo_inlay
|
||||
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
||||
def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
|
||||
"""The frame's first widget of this type, by placement order. Until
|
||||
the placement UI (a later phase) ships, every frame has at most one
|
||||
widget per type -- the auto-migrated default -- so callers needing
|
||||
"the photo widget" / "the calendar widget" / "the whiteboard widget"
|
||||
for what's still effectively a single-widget-per-type frame use this
|
||||
rather than querying Widget directly. None if the frame has no widget
|
||||
of this type."""
|
||||
return db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type)
|
||||
.order_by(Widget.sort_order)
|
||||
).first()
|
||||
|
||||
|
||||
def _manage_content_region(frame: Frame) -> tuple[int, int, int, int] | None:
|
||||
"""Where the photo behind _manage_content_asset_id actually landed in
|
||||
the logical canvas -- None (the whole canvas) in photos mode, or
|
||||
calendar_render.inlay_region(...) when a calendar view's photo inlay
|
||||
is what's showing. Needed so face labels (and, if ever added, other
|
||||
photo-relative overlay positioning) land on the actual inlaid photo
|
||||
instead of where a full-panel photo would have been."""
|
||||
if frame.mode == "calendar" and frame.calendar_photo_inlay:
|
||||
from ..calendar_render import inlay_region
|
||||
def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
|
||||
"""The frame's photo widget + its config, or a 400 if Immich creds or
|
||||
an album aren't set up yet. Immich creds are frame/owner-level, but
|
||||
album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
|
||||
manage.py, whose photo-related endpoints both need exactly this."""
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
widget = widget_of_type(db, frame, "photos")
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None
|
||||
if widget is None or not cfg.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
return widget, cfg
|
||||
|
||||
return inlay_region(frame.orientation)
|
||||
return None
|
||||
|
||||
def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]:
|
||||
return db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
|
||||
.order_by(Widget.sort_order)
|
||||
).all()
|
||||
|
||||
|
||||
def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None:
|
||||
"""The one photo widget the manage overlay's location/date/share-link
|
||||
boxes show info for -- unlike face labels (which generalize to every
|
||||
photo widget on screen, see build_manage_content), there's only one
|
||||
of each of these fixed panel corners to go around, so with more than
|
||||
one photo widget some single one has to be picked. Resolution rule:
|
||||
whichever photo widget the NEXT button's first assigned action
|
||||
targets, falling back to the first photo widget by placement order
|
||||
if none is button-assigned."""
|
||||
if not photo_widgets:
|
||||
return None
|
||||
next_actions = db.scalars(
|
||||
select(FrameButtonAction)
|
||||
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next")
|
||||
.order_by(FrameButtonAction.sort_order)
|
||||
).all()
|
||||
photo_widget_ids = {w.id for w in photo_widgets}
|
||||
for action in next_actions:
|
||||
if action.widget_id in photo_widget_ids:
|
||||
return next(w for w in photo_widgets if w.id == action.widget_id)
|
||||
return photo_widgets[0]
|
||||
|
||||
|
||||
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
@@ -387,47 +414,66 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
/frame/face-labels, both removed -- see the module docstring in
|
||||
manage_overlay.py) are now just internal calls made here, once,
|
||||
server-side, since compositing itself also moved server-side.
|
||||
management_url and battery_percent always apply; location/date/
|
||||
share-URL/face-labels only when there's a real current photo (see
|
||||
_manage_content_asset_id) -- absent otherwise, which
|
||||
manage_overlay.compose() already treats as "skip that region",
|
||||
exactly the graceful-degradation behavior the old firmware-fetched
|
||||
version had."""
|
||||
management_url and battery_percent always apply. location/date/
|
||||
share-URL come from one "primary" photo widget (see
|
||||
_primary_photo_widget -- there's only one of each of those fixed
|
||||
panel corners, so with more than one photo widget on screen some
|
||||
single one has to be picked); face labels generalize more simply,
|
||||
since manage_overlay.compose() already takes a flat list and draws
|
||||
each one independently -- every photo widget's own named faces get
|
||||
concatenated in, each positioned within that widget's own region
|
||||
(see face_labels.compute_face_labels' region param) rather than as
|
||||
if a photo filled the whole panel."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
content: dict = {
|
||||
"management_url": f"{base}/m/{frame.manage_token}",
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
asset_id = _manage_content_asset_id(frame)
|
||||
if not asset_id:
|
||||
photo_widgets = photo_widgets_for_frame(db, frame)
|
||||
if not photo_widgets:
|
||||
return content
|
||||
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
|
||||
return content
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{asset_id}"
|
||||
|
||||
if any((face.get("person") or {}).get("name") for face in faces):
|
||||
primary = _primary_photo_widget(db, frame, photo_widgets)
|
||||
primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
|
||||
if primary_cfg and primary_cfg.current_asset_id:
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
from ..face_labels import compute_face_labels
|
||||
|
||||
content["face_labels"] = compute_face_labels(
|
||||
preview_bytes, faces, frame.display_mode, frame.orientation,
|
||||
region=_manage_content_region(frame),
|
||||
)
|
||||
asset = client.get_asset(primary_cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
logger.warning(
|
||||
"Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e
|
||||
)
|
||||
else:
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
|
||||
|
||||
panel_w, panel_h = logical_render_size(frame.orientation)
|
||||
face_labels: list[dict] = []
|
||||
for widget in photo_widgets:
|
||||
cfg = db.get(PhotoWidgetConfig, widget.id)
|
||||
if not cfg.current_asset_id:
|
||||
continue
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
|
||||
faces = client.get_asset_faces(cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e)
|
||||
continue
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
continue # no Immich-identified person on this widget's current photo -- nothing to label
|
||||
|
||||
from ..face_labels import compute_face_labels
|
||||
|
||||
region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h))
|
||||
face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation,
|
||||
region=region))
|
||||
|
||||
if face_labels:
|
||||
content["face_labels"] = face_labels
|
||||
return content
|
||||
|
||||
|
||||
@@ -459,45 +505,21 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.
|
||||
return sources
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
||||
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
|
||||
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- same shape as the Gitea release-check throttle in api_frames.py's
|
||||
api_firmware_check. One shared cache for the whole merged result
|
||||
(every included user's events together), not per-user -- ICS feeds
|
||||
are small and this refetches at most every ~20 minutes regardless of
|
||||
how many are included, so per-user cache columns would add
|
||||
bookkeeping for a marginal benefit."""
|
||||
now = time.time()
|
||||
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return frame.calendar_cached_events, frame.calendar_fetch_summary
|
||||
|
||||
sources = calendar_sources_for_frame(db, frame)
|
||||
today = quiet_hours.local_date(frame)
|
||||
events, summary = calendar_feed.merge_events(
|
||||
sources,
|
||||
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
|
||||
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
|
||||
)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_cached_events = events
|
||||
locked.calendar_fetch_summary = summary
|
||||
locked.calendar_checked_at = now
|
||||
return events, summary
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
|
||||
"""Widget-scoped twin of get_or_refresh_calendar_events above,
|
||||
reading/writing CalendarWidgetConfig instead of Frame columns
|
||||
directly -- same throttle/caching rationale, unchanged. Not yet used
|
||||
by any router (see app/widgets/calendar.py, which this backs) --
|
||||
device.py's actual dispatch still calls the Frame-scoped version
|
||||
above until the widget-system cutover lands; both exist side by side
|
||||
until then. calendar_sources_for_frame stays frame_id-scoped (see
|
||||
FrameCalendar's own docstring) until a later phase re-keys it to
|
||||
widget_id, so every calendar widget on a frame currently shares the
|
||||
same "included calendars" set -- not a real limitation yet since
|
||||
nothing supports more than one calendar widget per frame end to end
|
||||
until that phase lands."""
|
||||
api_firmware_check -- reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py, which this backs). One shared cache for the
|
||||
whole merged result (every included user's events together), not
|
||||
per-user -- ICS feeds are small and this refetches at most every ~20
|
||||
minutes regardless of how many are included, so per-user cache
|
||||
columns would add bookkeeping for a marginal benefit.
|
||||
calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's
|
||||
own docstring) until a later phase re-keys it to widget_id, so every
|
||||
calendar widget on a frame currently shares the same "included
|
||||
calendars" set -- not a real limitation yet since nothing supports
|
||||
more than one calendar widget per frame end to end until that phase
|
||||
lands."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
now = time.time()
|
||||
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
@@ -517,44 +539,15 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget:
|
||||
return events, summary
|
||||
|
||||
|
||||
def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
|
||||
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
|
||||
much longer than calendar_feed's -- weather doesn't need to be
|
||||
that fresh). [] if weather's off or no cities are configured. A city
|
||||
whose refetch fails keeps its last-known days rather than going
|
||||
blank for one bad cycle -- calendar_render.py would otherwise show a
|
||||
real city as having no forecast at all just because one refresh hit
|
||||
a network hiccup."""
|
||||
if not frame.calendar_weather_enabled or not frame.calendar_weather_cities:
|
||||
return []
|
||||
now = time.time()
|
||||
if (frame.calendar_weather_cached is not None
|
||||
and now - frame.calendar_weather_checked_at < weather.CHECK_INTERVAL_S):
|
||||
return frame.calendar_weather_cached
|
||||
|
||||
previous_days = {c["label"]: c.get("days", {}) for c in (frame.calendar_weather_cached or [])}
|
||||
result = []
|
||||
for city in frame.calendar_weather_cities:
|
||||
try:
|
||||
days = weather.fetch_daily_forecast(
|
||||
city["latitude"], city["longitude"], frame.calendar_weather_units
|
||||
)
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||
days = previous_days.get(city["label"], {})
|
||||
result.append({"label": city["label"], "days": days})
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_weather_cached = result
|
||||
locked.calendar_weather_checked_at = now
|
||||
return result
|
||||
|
||||
|
||||
def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
||||
"""Widget-scoped twin of get_or_refresh_weather above -- same
|
||||
throttle/caching rationale, unchanged. Not yet used by any router
|
||||
(see app/widgets/calendar.py) -- both versions coexist until the
|
||||
widget-system cutover lands."""
|
||||
"""Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
|
||||
longer than calendar_feed's -- weather doesn't need to be that
|
||||
fresh), reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py). [] if weather's off or no cities are
|
||||
configured. A city whose refetch fails keeps its last-known days
|
||||
rather than going blank for one bad cycle -- calendar_render.py
|
||||
would otherwise show a real city as having no forecast at all just
|
||||
because one refresh hit a network hiccup."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
if not cfg.weather_enabled or not cfg.weather_cities:
|
||||
return []
|
||||
@@ -578,42 +571,14 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
||||
return result
|
||||
|
||||
|
||||
def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
|
||||
"""Frame-level throttled task-list cache (calendar_feed.CHECK_INTERVAL_S,
|
||||
same cadence as event merging) -- [] if tasks are off, no source is
|
||||
set, or the source user's CalDAV credentials/calendar_key have gone
|
||||
missing (e.g. they unlinked their account). A refetch failure keeps
|
||||
the last-known list rather than going blank for one bad cycle, same
|
||||
reasoning as get_or_refresh_weather."""
|
||||
if not frame.calendar_tasks_enabled or not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
|
||||
return []
|
||||
now = time.time()
|
||||
if (frame.calendar_tasks_cached is not None
|
||||
and now - frame.calendar_tasks_checked_at < calendar_feed.CHECK_INTERVAL_S):
|
||||
return frame.calendar_tasks_cached
|
||||
|
||||
user = db.get(User, frame.calendar_tasks_user_id)
|
||||
key = frame.calendar_tasks_calendar_key
|
||||
if user is None or not user.calendar_caldav_username or not key.startswith("caldav:"):
|
||||
return frame.calendar_tasks_cached or []
|
||||
href = key[len("caldav:"):]
|
||||
try:
|
||||
tasks = caldav_client.fetch_tasks(href, user.calendar_caldav_username, user.calendar_caldav_password)
|
||||
except caldav_client.CalDavError as e:
|
||||
logger.warning("Could not refresh tasks for frame %d: %s", frame.id, e)
|
||||
return frame.calendar_tasks_cached or []
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_tasks_cached = tasks
|
||||
locked.calendar_tasks_checked_at = now
|
||||
return tasks
|
||||
|
||||
|
||||
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
||||
"""Widget-scoped twin of get_or_refresh_tasks above -- same throttle/
|
||||
caching rationale, unchanged. Not yet used by any router (see
|
||||
app/widgets/calendar.py) -- both versions coexist until the
|
||||
widget-system cutover lands."""
|
||||
"""Throttled task-list cache (calendar_feed.CHECK_INTERVAL_S, same
|
||||
cadence as event merging), reading/writing CalendarWidgetConfig (see
|
||||
app/widgets/calendar.py). [] if tasks are off, no source is set, or
|
||||
the source user's CalDAV credentials/calendar_key have gone missing
|
||||
(e.g. they unlinked their account). A refetch failure keeps the
|
||||
last-known list rather than going blank for one bad cycle, same
|
||||
reasoning as get_or_refresh_weather_for_widget."""
|
||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||
if not cfg.tasks_enabled or not cfg.tasks_calendar_key or not cfg.tasks_user_id:
|
||||
return []
|
||||
@@ -653,48 +618,21 @@ def webdav_creds_for(user: User) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard(db: Session, frame: Frame, force: bool = False) -> bytes | None:
|
||||
"""Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- None if no whiteboard source is configured, credentials are
|
||||
missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
|
||||
or the most recent fetch/render failed and nothing was ever cached
|
||||
yet. A failure after a previous success keeps showing the last
|
||||
good render rather than going blank for one bad refresh cycle, same
|
||||
reasoning as get_or_refresh_weather/get_or_refresh_tasks. force=True
|
||||
(the web UI's "Refresh now" button) skips the throttle entirely --
|
||||
unlike a device's normal wake, a person clicking a button means do
|
||||
it right now, not eventually once the cache goes stale."""
|
||||
if not frame.whiteboard_url or not frame.whiteboard_user_id:
|
||||
return None
|
||||
now = time.time()
|
||||
if (not force and frame.whiteboard_cached_image is not None
|
||||
and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
creds = webdav_creds_for(user) if user else None
|
||||
if creds is None:
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
try:
|
||||
png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1])
|
||||
except whiteboard.WhiteboardRenderError as e:
|
||||
logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e)
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_cached_image = png
|
||||
locked.whiteboard_checked_at = now
|
||||
return png
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard_for_widget(
|
||||
db: Session, frame: Frame, widget: Widget, force: bool = False
|
||||
) -> bytes | None:
|
||||
"""Widget-scoped twin of get_or_refresh_whiteboard above -- same
|
||||
throttle/caching/force rationale, unchanged. Not yet used by any
|
||||
router (see app/widgets/whiteboard.py) -- both versions coexist
|
||||
until the widget-system cutover lands."""
|
||||
"""Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
|
||||
writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
|
||||
None if no whiteboard source is configured, credentials are missing
|
||||
(e.g. the owning user unlinked their WebDAV/CalDAV account), or the
|
||||
most recent fetch/render failed and nothing was ever cached yet. A
|
||||
failure after a previous success keeps showing the last good render
|
||||
rather than going blank for one bad refresh cycle, same reasoning as
|
||||
get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget.
|
||||
force=True (the web UI's "Refresh now" button) skips the throttle
|
||||
entirely -- unlike a device's normal wake, a person clicking a
|
||||
button means do it right now, not eventually once the cache goes
|
||||
stale."""
|
||||
cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
||||
if not cfg.url or not cfg.user_id:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user