Files
espresso_frame/server/app/routers/device.py
T
tfaour f48daa71c8
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s
Widget system Phase 1: per-type render/action modules
New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus
the WIDGET_TYPES registry) -- the widget-system analogue of
routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS,
generalized from "one mode owns the whole panel" to "each widget renders
into its own region and responds to named button actions." Each module
exposes render(db, frame, widget, target_w, target_h) -> Image.Image
(never raises -- a widget's own fetch hiccup falls back to a small
placeholder rather than taking the whole panel's render down) and an
ACTIONS registry for NEXT/BACK button assignment.

Supporting changes needed to give the widget modules something to call,
all mechanical/behavior-preserving for every existing caller:

- image_pipeline.py: render_panel(regions, ...) generalizes render_frame's
  tail (paste, enhance once, overlay once, quantize once, pack once) from
  one photo to N regions -- not a restructuring, since calendar mode's
  photo-inlay feature already pastes a second composed image onto the
  canvas before that single shared pipeline runs.
- photo_queue.py: advance_forced/back_forced/remove_from_rotation/
  get_current take an explicit `frame` param now that `cfg` won't always
  be the Frame itself once photo-queue state moves to PhotoWidgetConfig
  -- caught a real latent bug while doing this: get_current was reading
  refresh_interval_s off `cfg`, but that's a frame-level wake-cadence
  setting, not something that becomes per-widget, so it now reads that
  off `frame` explicitly instead.
- routers/common.py: list_assets/fetch_source_and_faces take album_id/
  display_mode directly instead of a whole Frame (both only ever read
  that one attribute off it); new get_or_refresh_*_for_widget siblings
  of the existing calendar/weather/tasks/whiteboard cache helpers, read/
  writing the new per-widget config tables -- the Frame-scoped originals
  are untouched and still what routers/device.py's actual dispatch calls
  until the Phase 2 cutover.

26 new tests (95 total): render_panel size/placement/orientation
coverage, and per-widget-type render/action tests (unconfigured ->
placeholder, a fetch failure -> placeholder not a crash, actions mutate
the right state). Full suite passes; diff-reviewed to confirm
device.py's actual RENDERERS dispatch and the old Frame-scoped
get_or_refresh_* bodies are unchanged, so this is safe to deploy on its
own despite being step 1 of a two-step cutover (see the project plan on
why the *next* step, not this one, has to ship atomically).
2026-07-24 08:44:53 -04:00

465 lines
20 KiB
Python

"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
into deployed firmware -- so multi-frame support changes only how the
calling frame is resolved (see auth.require_device), never the paths or
response key names the deployed flat parser depends on
("refresh_interval_s", "firmware_version").
manage=1 is the one addition: appended by firmware's manage button to
whichever of these three GET/POST requests it was already about to make
(see firmware/main/frame_client.c's fetch_and_display -- it no longer
does its own overlay fetching/compositing, that's all server-side now,
see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations
import io
import logging
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import calendar_render, mail, photo_queue, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..firmware import firmware_path
from ..image_pipeline import render_frame, render_placeholder
from ..models import BatteryLog, Frame
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK,
build_manage_content,
get_or_refresh_calendar_events,
get_or_refresh_tasks,
get_or_refresh_weather,
get_or_refresh_whiteboard,
immich_client_for,
immich_creds,
list_assets,
render_asset,
require_configured,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
built from the request's own base URL: whatever address the device
reached us at is by definition an address that works on this
network."""
base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id:
claim_url = f"{base}/claim?device_id={frame.device_id}"
return render_placeholder(
["This frame isn't claimed yet", "Scan to link it to your account:"],
qr_url=claim_url,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
qr_url=base,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
def _frame_configured(frame: Frame) -> bool:
url, key = immich_creds(frame)
return bool(url and key and frame.album_id)
# --- photos mode ---
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request, manage=manage)
client = immich_client_for(frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets, locked)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
# --- calendar mode ---
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
from .common import calendar_sources_for_frame
if not calendar_sources_for_frame(db, frame):
return render_placeholder(
["This frame's calendar isn't set up yet",
"Add a calendar in Settings, then include it on",
"this frame's Configuration -> Calendar card."],
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
)
with frame_locked(db, frame.id) as locked:
if is_normal_wake and locked.calendar_browse_offset != 0:
locked.calendar_browse_offset = 0
browse_offset = locked.calendar_browse_offset
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
week_start = locked.calendar_week_start
week_days = locked.calendar_week_days
week_layout = locked.calendar_week_layout
week_start_offset = locked.calendar_week_start_offset
inlay_wanted = locked.calendar_photo_inlay
events, summary = get_or_refresh_calendar_events(db, frame)
weather_cities = get_or_refresh_weather(db, frame)
# Only ever shown on the week view (see calendar_render._build_week) --
# gated here too so a disabled/other-view frame never pays for the
# fetch, and so None (not just an empty list) reaches render_calendar
# to mean "no tasks slot at all", distinct from "slot reserved but
# nothing outstanding right now".
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
photo_inlay = None
if inlay_wanted and _frame_configured(frame):
try:
client = immich_client_for(frame)
assets = list_assets(client, frame.album_id)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, locked, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if asset_id:
jpeg_bytes = client.download_asset_preview(asset_id)
import io
from PIL import Image
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
except HTTPException:
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
return calendar_render.render_calendar(
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
week_days=week_days, week_layout=week_layout, tasks=tasks, week_start_offset=week_start_offset,
)
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""NEXT in calendar mode: moves the displayed period forward one step
(day for agenda, week for week view, month for month view) from
wherever it currently is -- not from "today" -- so repeated presses
walk further forward. See Frame.calendar_browse_offset."""
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset += 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset -= 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
# --- whiteboard mode ---
def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
"""Fetches (throttled, see get_or_refresh_whiteboard) and renders the
frame's configured .whiteboard file. The rendered PNG is treated
exactly like a photo from here on -- run through the same
render_frame composition/quantization pipeline as photos mode,
letterboxed (never cropped: unlike a photo, losing part of a
whiteboard to a crop loses actual content, not just some background)
-- rather than a second parallel image pipeline just for this mode."""
png_bytes = get_or_refresh_whiteboard(db, frame)
if png_bytes is None:
return render_placeholder(
["This frame's whiteboard isn't set up yet",
"Add a WebDAV/Nextcloud whiteboard file URL on",
"this frame's Whiteboard tab."],
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
)
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
return render_frame(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox", manage=manage,
)
def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""NEXT in whiteboard mode: there's no "next" concept for a single
static board, so this instead forces an immediate re-fetch/re-render
bypassing the throttle -- a "check now" button for "someone just
updated the board, show it right away" rather than waiting out
calendar_feed.CHECK_INTERVAL_S."""
with frame_locked(db, frame.id) as locked:
locked.whiteboard_checked_at = 0.0
return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""Same "check now" behavior as _advance_whiteboard_mode -- there's
no separate "back" concept for a single static board either."""
return _advance_whiteboard_mode(db, frame, manage)
RENDERERS = {
"photos": _render_photos_mode,
"calendar": _render_calendar_mode,
"whiteboard": _render_whiteboard_mode,
}
ADVANCE_RENDERERS = {
"photos": _advance_photos_mode,
"calendar": _advance_calendar_mode,
"whiteboard": _advance_whiteboard_mode,
}
BACK_RENDERERS = {
"photos": _back_photos_mode,
"calendar": _back_calendar_mode,
"whiteboard": _back_whiteboard_mode,
}
@router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings -- no
Immich-configured gate, since this doubles as the "is the server up"
signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs
zero extra round trips."""
reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked:
if locked.stats_first_seen == 0:
locked.stats_first_seen = time.time()
locked.stats_device_wakes += 1
if reported_version:
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
locked.stats_ota_updates_applied += 1
locked.device_firmware_version = reported_version
if reported_board:
locked.device_board_variant = reported_board
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
# compatible with its 256-byte parse buffer), and only until the
# device has authenticated with the token once (device_token_ack).
if locked.device_id is not None and not locked.device_token_ack:
response["device_token"] = locked.device_token
return response
def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1"
@router.get("/frame/image")
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Returns the frame's current image. For photos mode: idempotent --
only actually advances to the next photo once refresh_interval_s has
elapsed since the current one was set (see app/photo_queue.py) --
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops.
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
For calendar mode, this is also the "normal wake" that resets
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = renderer(db, frame, request, manage, True)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate move forward -- the next photo in photos mode,
or the next day/week/month in calendar mode -- ignoring
refresh_interval_s. Used by the device's next-photo button."""
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/advance -- back a photo in photos mode, back
a period in calendar mode. A no-op (still 200, unchanged) if there's
nothing to go back to. Used by the device's back-photo button."""
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
@router.post("/frame/battery")
def frame_battery(
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Battery level reported by the device (only when running on battery
-- it stays silent on mains, where the charging voltage would read
misleadingly full). Stored with a timestamp plus a per-discharge-
cycle history that feeds the Device panel's "on battery for" and
"estimated remaining" numbers; every report also lands in the
permanent battery_log table behind the history chart."""
if not 0 <= body.percent <= 100:
raise HTTPException(400, "percent must be 0-100")
now = time.time()
should_alert = False
alert_email = ""
alert_frame_name = ""
with frame_locked(db, frame.id) as locked:
locked.stats_battery_reports += 1
# See RECHARGE_LOOKBACK: compared against the max of the last few
# reports, not just the single previous one, so a lone noisy dip
# can't make the next normal reading look like a recharge.
recent = locked.battery_history[-RECHARGE_LOOKBACK:]
recent_max = max((pct for _, pct in recent), default=None)
if recent_max is not None and body.percent >= recent_max + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge -- and let a
# low-battery alert fire again next time it actually gets low.
locked.battery_history = []
locked.stats_recharge_cycles += 1
locked.battery_alert_sent = False
locked.battery_history.append([now, body.percent])
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
locked.battery_percent = body.percent
locked.battery_as_of = now
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
# Safety bound, not a real limit at realistic report rates --
# mirrors the old JSON list's cap.
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
if count is not None and count >= BATTERY_LOG_MAX:
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
BatteryLog.ts
).limit(count + 1 - BATTERY_LOG_MAX)
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
# Once per discharge cycle (see the recharge reset above), not
# once per report -- a frame idling at 4% would otherwise get an
# email every wake.
if (
locked.battery_alert_threshold_pct >= 0
and body.percent <= locked.battery_alert_threshold_pct
and not locked.battery_alert_sent
and locked.owner is not None
and locked.owner.email
):
should_alert = True
alert_email = locked.owner.email
alert_frame_name = locked.name or f"Frame {locked.id}"
if should_alert:
# Network I/O outside the lock, same convention as everywhere
# else in this file -- then a short re-lock to record that it
# went out, only on actual success (an SMTP hiccup should let
# the next report's still-below-threshold reading try again
# rather than silently giving up for the rest of the cycle).
settings = get_server_settings(db)
sent = mail.send_email(
settings, alert_email, f"{alert_frame_name}: battery low",
f"{alert_frame_name}'s battery is at {body.percent}%.",
)
if sent:
with frame_locked(db, frame.id) as locked:
locked.battery_alert_sent = True
return {"status": "saved"}
@router.get("/frame/firmware")
def frame_firmware(frame: Frame = Depends(require_device)):
"""The frame's staged OTA image, streamed to the device
(esp_https_ota). 404 until something has been uploaded/fetched."""
path = firmware_path(frame.id)
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame --
not any arbitrary Immich asset id -- as a second layer even a leaked
token wouldn't bypass."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)