Build and push server image / build-and-push (push) Failing after 1m10s
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.
592 lines
27 KiB
Python
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
|