Tasks widgets could only ever point at one CalDAV task list (a radio- button picker, owner-only). Now they merge any number of included task lists across every linked user, same checkbox-inclusion + optional pinned-color shape a calendar widget already has for its calendars -- FrameTaskList mirrors FrameCalendar exactly, down to the same owner- adds/anyone-mutes permission split (api_widget_task_list_select/ api_widget_task_list_color). Reused calendar_render._event_colors/ _draw_color_bar as-is for the per-task color bar -- a task dict's owner_display_name/color_index is exactly that function's single- source fallback shape. Also added an opt-in "show tasks completed in the last 24 hours" toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks now accepts a completed_since cutoff and returns completed VTODOs (with their completion time) instead of silently dropping them, and _draw_tasks gives a completed task a filled checkbox + muted text instead of the normal empty-box/due-date row. Migration 18 splits the single-source TaskWidgetConfig columns (added by 17, splitting tasks out of the calendar widget in the first place) into frame_task_lists, carrying forward each widget's existing single source as its first included list -- same shape migration 9 used carrying forward frame_calendars' old single opt-in. Verified live in the browser (desktop + mobile): the new "Included task lists" + "Recently completed" dialog sections, the show_completed toggle actually persisting through a real HTTP round-trip, and no regression in the calendar widget's own "Included calendars" dialog. Full suite (192 tests, including new merge_tasks/config_save/migration coverage) passes.
676 lines
30 KiB
Python
676 lines
30 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, timezone
|
|
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, 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,
|
|
FrameButtonAction,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
TaskWidgetConfig,
|
|
User,
|
|
Widget,
|
|
WhiteboardWidgetConfig,
|
|
)
|
|
|
|
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 list_assets(client: ImmichClient, album_id: str) -> list[dict]:
|
|
try:
|
|
assets = client.list_album_assets(album_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
|
if not assets:
|
|
raise HTTPException(404, "Album has no photos")
|
|
return assets
|
|
|
|
|
|
def fetch_source_and_faces(
|
|
client: ImmichClient, display_mode: str, asset_id: str
|
|
) -> tuple[Image.Image, list[dict] | None]:
|
|
"""The shared first half of rendering: download the Immich preview
|
|
and (only if display_mode needs it) its detected faces. Used by the
|
|
web UI's rendered-preview endpoint (routers/api_widgets.py's
|
|
api_widget_preview_rendered). Takes display_mode directly (a photos
|
|
widget's own setting, see PhotoWidgetConfig) rather than a whole
|
|
Frame -- this function only ever needed that one attribute off it."""
|
|
try:
|
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
|
|
|
faces = None
|
|
if 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 _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 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 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
|
|
|
|
|
|
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:
|
|
"""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 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,
|
|
}
|
|
|
|
photo_widgets = photo_widgets_for_frame(db, frame)
|
|
if not photo_widgets:
|
|
return content
|
|
|
|
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:
|
|
asset = client.get_asset(primary_cfg.current_asset_id)
|
|
except httpx.HTTPError as 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
|
|
|
|
|
|
def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]:
|
|
"""Every calendar included on this calendar widget (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.widget_id == widget.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_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 -- 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."""
|
|
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_widget(db, widget)
|
|
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_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
|
"""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 []
|
|
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
|
|
|
|
|
|
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
|
|
|
|
|
def task_sources_for_widget(db: Session, widget: Widget) -> list[caldav_client.TaskSource]:
|
|
"""Every task list included on this tasks widget (FrameTaskList.
|
|
included) -- the exact set caldav_client.merge_tasks needs. CalDAV
|
|
only (calendar_key is always "caldav:<href>" -- no "ics" variant, a
|
|
plain ICS subscription has no VTODO collection), resolved against
|
|
the owning user's CalDAV account credentials."""
|
|
rows = db.execute(
|
|
select(FrameTaskList, User)
|
|
.join(User, User.id == FrameTaskList.user_id)
|
|
.where(FrameTaskList.widget_id == widget.id, FrameTaskList.included == True) # noqa: E712
|
|
).all()
|
|
sources = []
|
|
for ftl, u in rows:
|
|
if not ftl.calendar_key.startswith("caldav:") or not u.calendar_caldav_username:
|
|
continue
|
|
href = ftl.calendar_key[len("caldav:"):]
|
|
sources.append(caldav_client.TaskSource(
|
|
u.display_name or u.username, href, u.calendar_caldav_username, u.calendar_caldav_password,
|
|
color_index=ftl.color_index,
|
|
))
|
|
return sources
|
|
|
|
|
|
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
|
|
"""Throttled multi-list merge-fetch cache (calendar_feed.
|
|
CHECK_INTERVAL_S, same cadence as event merging), reading/writing
|
|
TaskWidgetConfig (see app/widgets/tasks.py). [] if no list is
|
|
included yet. Same posture as get_or_refresh_calendar_events_for_
|
|
widget (which this otherwise mirrors closely), not weather's own
|
|
per-city stale-cache fallback: a broken list just contributes
|
|
nothing to this cycle's merge (logged in fetch_summary) rather than
|
|
silently keeping its last-known tasks around."""
|
|
cfg = db.get(TaskWidgetConfig, widget.id)
|
|
now = time.time()
|
|
if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
|
return cfg.cached
|
|
|
|
sources = task_sources_for_widget(db, widget)
|
|
if not sources:
|
|
return []
|
|
completed_since = datetime.now(timezone.utc) - timedelta(hours=TASKS_COMPLETED_WINDOW_HOURS) \
|
|
if cfg.show_completed else None
|
|
tasks, summary = caldav_client.merge_tasks(sources, completed_since=completed_since)
|
|
if summary:
|
|
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, summary)
|
|
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
|
locked_cfg.cached = tasks
|
|
locked_cfg.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_for_widget(
|
|
db: Session, frame: Frame, widget: Widget, force: bool = False
|
|
) -> bytes | None:
|
|
"""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
|
|
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
|