Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
802 lines
37 KiB
Python
802 lines
37 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, panel_size
|
|
from ..immich_client import ImmichClient
|
|
from ..models import (
|
|
BatteryLog,
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
TaskWidgetConfig,
|
|
User,
|
|
Widget,
|
|
WeatherWidgetConfig,
|
|
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
|
|
# Readings considered on each side of a given reading when
|
|
# _smooth_percents looks for local outliers. Needs to be at least half
|
|
# the length of the longest bad-reading burst a noisy divider produces
|
|
# (observed up to ~4 consecutive corrupted reports on one frame) so the
|
|
# good neighbors still outnumber the bad ones in the window.
|
|
BATTERY_SMOOTHING_WINDOW = 4
|
|
|
|
# "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 _smooth_percents(percents: list[int]) -> list[float]:
|
|
"""Replaces any reading that's a wild outlier against its own local
|
|
neighborhood with that neighborhood's median, before per-wake drop
|
|
steps are ever built from the series.
|
|
|
|
_reject_outlier_drops (above) only catches a bad reading by how much
|
|
it distorts the *steps* immediately on either side of it -- which is
|
|
exactly what one isolated glitch does, but a 1M-ohm divider (see
|
|
firmware/main/battery.c) doesn't always misfire in isolation: several
|
|
consecutive reports can drift or glitch together (a multi-minute
|
|
crawl from 68 up into the high 70s with nothing charging, or a run of
|
|
several ~40 reports spliced into an otherwise flat ~53 run). A step
|
|
computed *between* two bad readings in the same burst looks like an
|
|
ordinary small change, not an outlier, so it sails through
|
|
_reject_outlier_drops untouched.
|
|
|
|
A Hampel identifier catches that instead: each reading is compared to
|
|
the median of its own local window (not the whole series), using the
|
|
same MAD-based modified z-score as _reject_outlier_drops so this
|
|
adapts to how noisy a given frame's sensor actually is rather than a
|
|
fixed percent-point cutoff. A window of BATTERY_SMOOTHING_WINDOW
|
|
reports on each side tolerates a bad burst up to that long while
|
|
still being outvoted by the surrounding good readings."""
|
|
n = len(percents)
|
|
smoothed = list(percents)
|
|
for i in range(n):
|
|
lo = max(0, i - BATTERY_SMOOTHING_WINDOW)
|
|
hi = min(n, i + BATTERY_SMOOTHING_WINDOW + 1)
|
|
neighborhood = percents[lo:hi]
|
|
median = statistics.median(neighborhood)
|
|
abs_devs = [abs(v - median) for v in neighborhood]
|
|
# Unlike _reject_outlier_drops, no mean-of-abs-devs fallback here:
|
|
# a burst can be a big enough share of this small a window that
|
|
# the mean itself gets dragged up by the very values being
|
|
# tested, hiding them. A flat 1-percentage-point floor -- this
|
|
# project's smallest real unit of noise -- keeps the test from
|
|
# dividing by zero without being skewed by the burst it's
|
|
# checking.
|
|
mad = statistics.median(abs_devs) or 1
|
|
if abs(0.6745 * (percents[i] - median) / mad) > OUTLIER_MODIFIED_Z_THRESHOLD:
|
|
smoothed[i] = median
|
|
return smoothed
|
|
|
|
|
|
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. Raw percents go
|
|
through _smooth_percents first, which corrects readings (including
|
|
short bursts of them) that are wild outliers against their own local
|
|
neighborhood -- see that function's docstring for why that catches
|
|
noise shapes _reject_outlier_drops can't. 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 whatever
|
|
single-noisy-reading shape survives smoothing (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
|
|
percents = _smooth_percents(percents)
|
|
|
|
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)
|
|
|
|
# Unlike location/date-taken (a single fixed corner, so tied to the
|
|
# one "primary" widget above), the share link covers every photo
|
|
# widget's current photo (see manage.manage_share) -- so it only
|
|
# needs *some* photo widget to have a current photo, not specifically
|
|
# the primary one, and doesn't depend on the EXIF fetch above
|
|
# succeeding.
|
|
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
|
|
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
|
|
|
|
panel_w, panel_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
|
|
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
|
|
|
|
|
|
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
|
|
|
|
|
|
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
|
|
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
|
|
weather widget (see app/widgets/weather.py) -- reads/writes
|
|
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
|
|
hourly/daily need a single configured location (city_latitude/
|
|
city_longitude); multi_city needs cfg.cities. None if not configured
|
|
yet, so render() falls back to a placeholder -- same convention as
|
|
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
|
|
button) bypasses the throttle entirely.
|
|
|
|
A single-location mode's fetch failure keeps the last-known cached
|
|
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
|
|
multi_city fetch fails per-city (like get_or_refresh_weather_for_
|
|
widget's calendar-strip counterpart) so one broken city doesn't blank
|
|
the others."""
|
|
cfg = db.get(WeatherWidgetConfig, widget.id)
|
|
if cfg.mode == "multi_city":
|
|
if not cfg.cities:
|
|
return None
|
|
elif cfg.city_latitude is None or cfg.city_longitude is None:
|
|
return None
|
|
|
|
now = time.time()
|
|
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
|
|
return cfg.cached
|
|
|
|
if cfg.mode == "multi_city":
|
|
previous = {c["label"]: c for c in (cfg.cached or [])}
|
|
result = []
|
|
for city in cfg.cities:
|
|
try:
|
|
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
|
|
d = next(iter(today.values())) if today else previous.get(city["label"], {})
|
|
except weather.WeatherFetchError as e:
|
|
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
|
d = previous.get(city["label"], {})
|
|
result.append({
|
|
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
|
|
"category": d.get("category", "cloudy"),
|
|
})
|
|
else:
|
|
try:
|
|
if cfg.mode == "current":
|
|
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
|
|
elif cfg.mode == "hourly":
|
|
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
|
hours=HOURLY_FETCH_HOURS)
|
|
else: # "daily"
|
|
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
|
cfg.daily_days)
|
|
except weather.WeatherFetchError as e:
|
|
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
|
|
return cfg.cached
|
|
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
|
locked_cfg.cached = result
|
|
locked_cfg.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
|