device.py's mode-keyed dispatch is replaced by a real compositor: load a frame's widgets, compute pixel rects via app/grid.py, render each through its widget module, and composite with render_panel. Physical NEXT/BACK buttons now execute each frame's assigned FrameButtonAction rows instead of one hardcoded per-mode action. api_frames.py, manage.py, and common.py's build_manage_content are repointed to read/write the frame's widget config rows instead of the old Frame columns, and every settings page (Photos/Calendar/ Whiteboard tabs) now pre-fills its form from the same widget config the write endpoints actually save to -- previously the read and write sides would have silently diverged. The old mode selector and photo-inlay checkbox are removed along with their now-inert wiring; arbitrary widget placement subsumes what the fixed inlay split did. Ships together with Phase 1 (per-type render/action modules) since splitting the read/write cutover across deploys would have left settings changes with no visible effect.
373 lines
17 KiB
Python
373 lines
17 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 logging
|
|
import time
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import grid, mail, 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 logical_render_size, render_panel, render_placeholder
|
|
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget
|
|
from ..widgets import WIDGET_TYPES
|
|
from .common import (
|
|
BATTERY_HISTORY_MAX,
|
|
BATTERY_LOG_MAX,
|
|
RECHARGE_JUMP_PCT,
|
|
RECHARGE_LOOKBACK,
|
|
build_manage_content,
|
|
immich_client_for,
|
|
immich_creds,
|
|
photo_widgets_for_frame,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
|
|
"""What an unclaimed or widget-less frame displays instead of real
|
|
content -- 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!", "Add a widget for this frame at", base],
|
|
qr_url=base,
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
manage=manage,
|
|
)
|
|
|
|
|
|
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool) -> bytes:
|
|
"""The widget-system compositor: renders every widget on this frame
|
|
into its own region (see app/grid.py for grid-cell -> pixel math) and
|
|
hands the results to image_pipeline.render_panel for the single
|
|
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
|
|
per-mode RENDERERS dict -- a frame can now show several widgets at
|
|
once instead of exactly one mode owning the whole panel."""
|
|
all_widgets = db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
|
).all()
|
|
panel_w, panel_h = logical_render_size(frame.orientation)
|
|
regions = []
|
|
for widget in all_widgets:
|
|
module = WIDGET_TYPES.get(widget.widget_type)
|
|
if module is None:
|
|
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
|
px, py, pw, ph = grid.cell_to_pixels(
|
|
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
|
|
)
|
|
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
|
regions.append(((px, py, pw, ph), img))
|
|
return render_panel(
|
|
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
|
dither_strength=frame.dither_strength, manage=manage,
|
|
)
|
|
|
|
|
|
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
|
is_normal_wake: bool) -> bytes:
|
|
"""The top-level "what does this frame show right now" entry point.
|
|
An unclaimed frame or one with no widgets yet gets the setup
|
|
placeholder (needs `request` for its QR URLs -- only available on the
|
|
normal-wake path where a real request is on hand, never on an
|
|
advance/back button press); otherwise every widget on it gets
|
|
composited via _render_widgets. Individual widgets that are
|
|
themselves unconfigured show their own small placeholder within
|
|
their own region (see app/widgets/*.py) rather than blanking the
|
|
whole panel -- a partially-set-up multi-widget frame still shows
|
|
whatever IS configured."""
|
|
has_widgets = frame.owner_user_id is not None and (
|
|
db.scalars(select(Widget.id).where(Widget.frame_id == frame.id).limit(1)).first() is not None
|
|
)
|
|
if not has_widgets:
|
|
if request is None:
|
|
return render_placeholder(
|
|
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage
|
|
)
|
|
return _setup_placeholder(frame, request, manage=manage)
|
|
|
|
return _render_widgets(db, frame, manage, is_normal_wake)
|
|
|
|
|
|
def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
|
|
"""Executes every (widget, action) binding assigned to this physical
|
|
button, in order -- see models.FrameButtonAction and the button-
|
|
assignment UI (a later phase). Each action runs to completion (its
|
|
own widget_locked span) before the next one starts -- never nested,
|
|
since db.widget_locked's underlying lock isn't reentrant (see its own
|
|
docstring) -- a button assigned several actions would deadlock
|
|
instantly if this looped any other way. One action failing
|
|
unexpectedly doesn't block the others, or the eventual re-render,
|
|
from happening -- the user pressed a physical button and expects
|
|
*something* to happen even if one of several assigned widgets is
|
|
having a bad moment."""
|
|
actions = db.scalars(
|
|
select(FrameButtonAction)
|
|
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
|
|
.order_by(FrameButtonAction.sort_order)
|
|
).all()
|
|
for action_row in actions:
|
|
widget = db.get(Widget, action_row.widget_id)
|
|
if widget is None:
|
|
continue
|
|
module = WIDGET_TYPES.get(widget.widget_type)
|
|
action_fn = module.ACTIONS.get(action_row.action) if module else None
|
|
if action_fn is None:
|
|
continue
|
|
try:
|
|
action_fn(db, frame, widget)
|
|
except Exception:
|
|
logger.exception(
|
|
"Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id
|
|
)
|
|
|
|
|
|
@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 -- every widget on the frame
|
|
composited into one panel (see _render_widgets). Each widget's own
|
|
render is idempotent in whatever way makes sense for its type (e.g.
|
|
a photo widget only actually advances once its own refresh interval
|
|
has elapsed, see app/photo_queue.py) -- safe to call as often as the
|
|
device wants, including after an unplanned reboot, without skipping
|
|
ahead. An unclaimed frame or one with no widgets yet 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.
|
|
This is also the "normal wake" that resets any calendar widget's
|
|
browse position back to today (see app/widgets/calendar.py)."""
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content = _render_frame_content(db, frame, request, manage, is_normal_wake=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 on whatever widget(s) the NEXT
|
|
button is assigned to (see models.FrameButtonAction) -- e.g. the next
|
|
photo for a photo widget, or the next day/week/month for a calendar
|
|
widget -- then re-renders and returns the whole panel. Used by the
|
|
device's next-photo button."""
|
|
_run_button_actions(db, frame, "next")
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
|
return Response(content=content, 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, for whatever widget(s) the BACK
|
|
button is assigned to. A no-op (still 200, unchanged) for any widget
|
|
with nothing to go back to. Used by the device's back-photo button."""
|
|
_run_button_actions(db, frame, "back")
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
|
|
return Response(content=content, 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), db: Session = Depends(get_db)):
|
|
"""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 one of THIS
|
|
frame's own photo widgets -- not any arbitrary Immich asset id -- as
|
|
a second layer even a leaked token wouldn't bypass."""
|
|
url, key = immich_creds(frame)
|
|
if not url or not key:
|
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
|
|
|
photo_widgets = photo_widgets_for_frame(db, frame)
|
|
showing_or_queued = any(
|
|
asset_id == cfg.current_asset_id or asset_id in cfg.queue
|
|
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
|
|
)
|
|
if not showing_or_queued:
|
|
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)
|