Files
espresso_frame/server/app/routers/common.py
T
tfaour 644fdefa66
Build and push server image / build-and-push (push) Failing after 1m10s
Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV)
New third mode alongside photos/calendar: fetches a .whiteboard file
over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and
renders it via a small Node.js sidecar using Excalidraw's own real
export code (@excalidraw/utils + @resvg/resvg-js, no headless browser),
since a .whiteboard file turns out to be Excalidraw scene JSON, not an
image. The sidecar runs as a second process inside this same container
(Dockerfile installs Node, start.sh backgrounds it before exec'ing
uvicorn) rather than a separate docker-compose service -- lightweight,
stateless, reachable only at 127.0.0.1 from the Python process, nothing
worth independently scaling.

The rendered PNG is treated exactly like a photo from there on --
composed/quantized through the existing image_pipeline (letterboxed,
never cropped) rather than a second parallel rendering pipeline.

WebDAV credentials support the common "it's actually the same Nextcloud
account as my CalDAV" case (an explicit opt-in checkbox, not silently
inferred) while still working with any WebDAV server generically.
Frame-level source (URL + owning account) follows the same owner-
controls-their-own-data permission split as calendar sources and the
week view's task list: only the account owner can point a frame at it,
anyone linked can clear it.

Honest limitation: this environment has no Node.js/npm, so
render-service/ is written carefully against each library's documented
API (verified via the npm registry, including transitive dependency
licenses after the CalDAV/AGPL surprise earlier this session) but has
never actually been executed. First real docker build is the first
true test -- see render-service/README.md.
2026-07-23 17:02:08 -04:00

592 lines
27 KiB
Python

"""Helpers shared by the device and browser routers."""
from __future__ import annotations
import io
import logging
import os
import statistics
import time
from datetime import datetime, timedelta
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
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
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import BatteryLog, Frame, FrameCalendar, User
logger = logging.getLogger(__name__)
FRAME_MODES = ("photos", "calendar", "whiteboard")
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery was recharged
# How many of the most recent reports make up that baseline. A lone noisy
# reading (ADC/regulator glitch -- see firmware/main/battery.c) can still
# dip or spike a single report; comparing against just the one immediately
# previous report meant that a normal reading right after a noisy dip
# looked like a 5%+ jump and falsely registered as a recharge. Comparing
# against the max of the last few reports instead means an actual recharge
# still needs to clear all of them, while a single stray low one doesn't
# get to set the bar.
RECHARGE_LOOKBACK = 3
BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered
MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
# Modified z-score cutoff (Iglewicz & Hoaglin's standard figure) for
# _reject_outlier_drops -- see that function's docstring for why a
# single noisy reading needs rejecting at the per-wake-drop level, not
# just at the recharge-detection level.
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
OVERDUE_FACTOR = 1.5
def immich_creds(frame: Frame) -> tuple[str, str]:
"""Which Immich this frame renders from. Owner's creds once the frame
is claimed (Phase B+); env vars as the operator-level fallback (the
pre-redesign source of truth); the frame's own staging columns last
(populated by the config.json migration for exactly the case where
the old file held creds but the env no longer does)."""
owner = frame.owner
if owner is not None and owner.immich_url and owner.immich_api_key:
return owner.immich_url, owner.immich_api_key
env_url = os.environ.get("IMMICH_URL", "")
env_key = os.environ.get("IMMICH_API_KEY", "")
if env_url and env_key:
return env_url, env_key
return frame.immich_url, frame.immich_api_key
def immich_client_for(frame: Frame) -> ImmichClient:
url, key = immich_creds(frame)
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, frame: Frame) -> list[dict]:
try:
assets = client.list_album_assets(frame.album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
if not assets:
raise HTTPException(404, "Album has no photos")
return assets
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
and (only if display_mode needs it) its detected faces. Used by both
render_asset (device-facing) and the web UI's rendered-preview
endpoint (routers/api_frames.py) so they can't drift apart."""
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
faces = None
if frame.display_mode == "crop_faces":
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
# A faces lookup hiccup shouldn't block showing a photo at
# all -- just fall back to a plain center-crop this cycle.
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
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, 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
wake schedule entirely -- fewer wakes/day, not a cheaper wake. This
is what lets battery_estimate_s convert a per-wake drop rate into a
remaining-time estimate that reacts to both settings immediately,
rather than only after enough new history accumulates under them."""
active_day_s = max(1, 86400 - quiet_hours.quiet_span_s(frame))
interval_s = max(1, frame.refresh_interval_s)
wakes_per_day = max(1, active_day_s // interval_s)
return 86400 / wakes_per_day
def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, float]]:
"""Drops (weight, drop_pct) pairs whose drop is a wild outlier
relative to the rest of the recent steps. A single noisy ADC/
regulator glitch (see firmware/main/battery.c) corrupts one of the
two steps around it, whichever way it reads: a glitch that dips low
then recovers makes the step INTO it a spurious huge drop (the step
back out is an increase, already excluded above as a "recharge");
one that spikes high then settles makes the step OUT OF it the
spurious one instead (the step into it is the excluded "recharge").
Either way, one bad reading survives the recharge filter looking
like an ordinary, legitimately huge drop and swings the whole
remaining-time estimate on its own.
Uses a MAD-based modified z-score (robust to a small number of
extreme values in a way a plain mean/stdev z-score isn't -- a single
huge outlier inflates the stdev itself, which just hides the outlier
from a stdev-based test) rather than a fixed percent-point cutoff, so
it adapts to how noisy a given frame's own sensor actually is
instead of guessing one global threshold for every install."""
drops = [drop for _, drop in steps]
median = statistics.median(drops)
abs_devs = [abs(d - median) for d in drops]
mad = statistics.median(abs_devs)
if mad == 0:
# The standard median-based MAD degenerates to exactly 0 as soon
# as more than half the steps share the median exactly -- and
# real battery data is small integer percents, so "most wakes
# cost exactly 1%" ties are the norm, not an edge case. That's
# precisely the shape a single spliced-in glitch among a steady
# discharge rate has (18 steps at "1", one at "26"), so treating
# MAD==0 as "no spread, nothing to reject" would let exactly the
# outlier this function exists for sail straight through. Fall
# back to mean absolute deviation instead, which only reaches 0
# when every single step is identical.
mad = statistics.mean(abs_devs)
if mad == 0:
return steps # every step really is identical -- nothing to reject
kept = [
(weight, drop) for weight, drop in steps
if abs(0.6745 * (drop - median) / mad) <= OUTLIER_MODIFIED_Z_THRESHOLD
]
return kept or steps # never filter down to nothing
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
"""Remaining-time estimate from a recency-weighted average of the
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
rows of the permanent battery_log table -- not just the current
discharge cycle's battery_history, which resets to empty on every
recharge and so often doesn't hold enough signal on its own even
though the frame has plenty of history overall.
Consecutive reports are assumed to be consecutive wakes (firmware
reports battery on every wake while on battery), so each step's
(prev_percent - next_percent) is that wake's cost. A step where
percent went *up* is a recharge, not negative drain, and is skipped
entirely rather than folded in as a weird outlier; a flat step
(0% change) still counts as a real, cheap wake -- excluding those
would systematically overstate the per-wake cost by only counting
the wakes that happened to tick the percentage down. The remaining
steps then get one more pass, _reject_outlier_drops, to catch the
single-noisy-reading case that "percent went up" alone can't (see
that function's docstring). Steps are weighted linearly by recency
(step i of n gets weight i, 1-indexed) so a recent change in usage
pattern shows up quickly instead of being washed out by a long flat
history.
The resulting %/wake rate is then converted to wall-clock time using
the frame's *current* refresh_interval_s and quiet-hours settings
(see _avg_wake_interval_s), not whatever cadence produced the
historical data -- so halving refresh_interval_s roughly halves the
estimate immediately (not exactly halves: quiet hours removes a
fixed wake-free window from every day regardless of interval, which
is the "other things going on" that keeps the scaling sublinear)."""
if frame.battery_percent < 0:
return None
rows = db.execute(
select(BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
.order_by(BatteryLog.ts.desc())
.limit(BATTERY_ESTIMATE_SAMPLE_COUNT)
).scalars().all()
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
return None
percents = list(reversed(rows)) # chronological order
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
for i in range(1, len(percents)):
prev_pct, next_pct = percents[i - 1], percents[i]
if next_pct > prev_pct:
continue # recharge (or a swap) -- not a discharge sample
steps.append((i, prev_pct - next_pct)) # later steps (larger i) weigh more
if len(steps) < MIN_ESTIMATE_SAMPLES:
return None
steps = _reject_outlier_drops(steps)
weight_total = sum(weight for weight, _ in steps)
if weight_total <= 0:
return None
avg_drop_per_wake = sum(weight * drop for weight, drop in steps) / weight_total
if avg_drop_per_wake <= 0:
return None # flat -- no honest rate to extrapolate
remaining_wakes = frame.battery_percent / avg_drop_per_wake
return int(remaining_wakes * _avg_wake_interval_s(frame))
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
active_nav: str | None = None) -> dict:
"""Template context every app-shell (sidebar) page needs: the user's
frame list with an online indicator, the active highlights, and the
session's CSRF token. Import here (not auth) keeps the router
modules' template plumbing in one place."""
import time as _time
from .. import quiet_hours
from ..auth import current_session, user_frames
session = current_session(request, db)
frames = user_frames(db, user)
now = _time.time()
for f in frames:
# Same "not overdue" definition the Device panel uses.
gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR
f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap)
return {
"request": request,
"user": user,
"csrf_token": session.csrf_token if session else None,
"sidebar_frames": frames,
"active_frame": active_frame,
"active_nav": active_nav,
}
def valid_http_url(url: str) -> bool:
"""http(s)-only URL check -- generalized from what was api_frames.py's
frame-specific _valid_repo_url, now shared by two call sites (the
Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
# --- Location/date-taken text for the manage overlay (see build_manage_content) ---
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
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 _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
return inlay_region(frame.orientation)
return None
def build_manage_content(db: Session, frame: Frame, request) -> dict:
"""Gathers everything manage_overlay.compose() needs -- what used to
be two separate device-facing endpoints (/frame/photo-info,
/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."""
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:
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):
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),
)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
"""Every calendar included on this frame (FrameCalendar.included) --
the exact set calendar_feed.merge_events needs. A calendar_key of
"ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
resolves against the href itself, authenticated with the owner's
CalDAV account credentials (see caldav_client.py)."""
rows = db.execute(
select(FrameCalendar, User)
.join(User, User.id == FrameCalendar.user_id)
.where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
).all()
sources = []
for fc, u in rows:
name = u.display_name or u.username
if fc.calendar_key == "ics":
if u.calendar_ics_url:
sources.append(calendar_feed.CalendarSource(
name, "ics", u.calendar_ics_url, color_index=fc.color_index
))
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
href = fc.calendar_key[len("caldav:"):]
sources.append(calendar_feed.CalendarSource(
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password,
color_index=fc.color_index,
))
return sources
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> 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_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_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 webdav_creds_for(user: User) -> tuple[str, str] | None:
"""(username, password) for `user`'s WebDAV access -- their own
dedicated webdav_username/password, or (if they opted in)
calendar_caldav_username/password reused from their CalDAV account
(see models.py's User docstring on webdav_reuse_caldav_creds). None
if neither is actually set up."""
if user.webdav_reuse_caldav_creds:
if user.calendar_caldav_username:
return user.calendar_caldav_username, user.calendar_caldav_password
return None
if user.webdav_username:
return user.webdav_username, user.webdav_password
return None
def get_or_refresh_whiteboard(db: Session, frame: Frame) -> 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."""
if not frame.whiteboard_url or not frame.whiteboard_user_id:
return None
now = time.time()
if (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