Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns (mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc) docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup, with a raw-SQL backfill safety net for any frame that still somehow lacks a Widget. Also drops legacy_token_enabled and the shared MANAGEMENT_TOKEN fallback it gated in require_device/require_browser -- the per-frame manage_token/device_token flow (and the /m/ page) fully supersede it now; MANAGEMENT_TOKEN's only remaining role is the optional pre-setup claim gate. Confirmed with the maintainer that the deployed frame is already off the shared token before removing the server-side fallback. Firmware: the captive portal's "Access Token" field and its NVS/ build_url plumbing only ever mattered for pointing new firmware at an old pre-multi-frame server -- gone along with the server-side fallback it fed. Version bump to publish the change.
485 lines
24 KiB
Python
485 lines
24 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
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import FileResponse, 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 SessionLocal, frame_locked, get_db
|
|
from ..firmware import firmware_path
|
|
from ..global_actions import GLOBAL_ACTIONS
|
|
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
|
|
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
|
|
from ..widgets import WIDGET_TYPES
|
|
from .common import (
|
|
BATTERY_HISTORY_MAX,
|
|
BATTERY_LOG_MAX,
|
|
RECHARGE_JUMP_PCT,
|
|
RECHARGE_LOOKBACK,
|
|
build_manage_content,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
|
|
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, 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,
|
|
as_png=as_png,
|
|
capture_snapshot=capture_snapshot,
|
|
)
|
|
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,
|
|
as_png=as_png,
|
|
capture_snapshot=capture_snapshot,
|
|
)
|
|
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,
|
|
as_png=as_png,
|
|
capture_snapshot=capture_snapshot,
|
|
)
|
|
|
|
|
|
def _render_one_widget(frame_id: int, widget_id: int, orientation: str, panel_w: int, panel_h: int,
|
|
cell: tuple[int, int, int, int], is_normal_wake: bool,
|
|
) -> tuple[tuple[int, int, int, int], object] | None:
|
|
"""Renders exactly one widget on its own DB session, so several of
|
|
these can run concurrently in a thread pool -- see app/db.py's
|
|
module docstring: handlers already run multi-threaded (sync
|
|
handlers in FastAPI's threadpool, one process), and frame_locked/
|
|
widget_locked's per-frame threading.Lock is what makes that safe,
|
|
not anything about which Session object is in play. A SQLAlchemy
|
|
Session itself is never safe to share across threads, so each
|
|
concurrent render gets a fresh one rather than reusing the
|
|
request's. Most of a widget's render time is spent waiting on an
|
|
external call (Immich, a weather provider, CalDAV) with the DB
|
|
untouched, which is exactly the time this buys back."""
|
|
db = SessionLocal()
|
|
try:
|
|
frame = db.get(Frame, frame_id)
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None:
|
|
return None # deleted between the listing query and this fetch -- skip it, not a 500
|
|
module = WIDGET_TYPES.get(widget.widget_type)
|
|
if module is None:
|
|
return None # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
|
|
px, py, pw, ph = grid.cell_to_pixels(orientation, panel_w, panel_h, cell)
|
|
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
|
|
draw_widget_border(
|
|
img, widget.border_style, widget.border_thickness,
|
|
resolve_border_color(widget.border_color_index, frame.palette_rgb),
|
|
)
|
|
return (px, py, pw, ph), img
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
|
|
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
|
"""The widget-system compositor: renders every widget on this frame
|
|
into its own region (see app/grid.py for grid-cell -> pixel math),
|
|
draws that widget's own optional border directly onto its region
|
|
(models.Widget.border_style, a shared per-widget property no
|
|
widget_type module needs to know about) 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.
|
|
|
|
Widgets render concurrently (_render_one_widget, each on its own DB
|
|
session) rather than one at a time -- a layout with several
|
|
network-backed widgets (photos, weather, calendar) previously paid
|
|
their fetch latency serially, which could push a single /frame/*
|
|
response past the firmware's fixed HTTP timeout and show a
|
|
misleading "server failed" status screen even though the server
|
|
was simply still working. Futures are submitted in sort_order and
|
|
collected in that same order (not completion order) -- overlapping
|
|
widgets must still paint in the original z-order."""
|
|
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 = []
|
|
if all_widgets:
|
|
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
|
|
futures = [
|
|
pool.submit(
|
|
_render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
|
|
(widget.x, widget.y, widget.w, widget.h), is_normal_wake,
|
|
)
|
|
for widget in all_widgets
|
|
]
|
|
for future in futures:
|
|
result = future.result()
|
|
if result is not None:
|
|
regions.append(result)
|
|
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, as_png=as_png,
|
|
capture_snapshot=capture_snapshot,
|
|
)
|
|
|
|
|
|
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
|
|
is_normal_wake: bool, as_png: bool = False,
|
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, 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, as_png=as_png, capture_snapshot=capture_snapshot,
|
|
)
|
|
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
|
|
|
|
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
|
|
|
|
|
|
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
|
|
"""The web UI's live "how it's displaying" thumbnail (see
|
|
routers/api_frames.py's /preview endpoint) -- same compositor
|
|
/frame/image uses, just handed back as a small upright PNG instead of
|
|
packed native-panel bytes. Exported from here (rather than
|
|
duplicated) since this module already owns the full widget-
|
|
compositing pipeline; nothing about the /frame/* paths themselves
|
|
changes."""
|
|
return _render_frame_content(db, frame, request, manage=None, is_normal_wake=True, as_png=True)
|
|
|
|
|
|
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
|
|
)
|
|
|
|
|
|
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
|
|
"""The hold-triggered counterpart to _run_button_actions -- runs
|
|
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
|
|
Frame.next_hold_action/back_hold_action points to, if any (unset or
|
|
unrecognized is a silent no-op, same posture as an unbound short-
|
|
press button). See routers/device.py's frame_global_next/back."""
|
|
action = frame.next_hold_action if button == "next" else frame.back_hold_action
|
|
action_fn = GLOBAL_ACTIONS.get(action) if action else None
|
|
if action_fn is None:
|
|
return
|
|
try:
|
|
action_fn(db, frame)
|
|
except Exception:
|
|
logger.exception("Global hold action %r failed for frame %d", action, 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,
|
|
# Additive key -- old firmware's hand-rolled parser only ever
|
|
# extracts the keys it knows about, so this is safe for
|
|
# firmware that predates hold-for-global-action (see
|
|
# firmware/main/next_button.c, app/global_actions.py).
|
|
"hold_duration_ms": locked.hold_duration_ms,
|
|
}
|
|
# Per-frame token push: only until the device has authenticated
|
|
# with it once (device_token_ack) -- no reason to keep sending it
|
|
# on every wake once the device has it.
|
|
if 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"
|
|
|
|
|
|
def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
|
|
"""Persists exactly what a device-facing render just sent (upright
|
|
PNG, manage overlay included if present -- whatever's actually on the
|
|
panel) as this frame's "now displaying" snapshot, the frozen half of
|
|
the web UI's header preview pair (see api_frames.py's /now-displaying
|
|
endpoint and its always-live "up next" counterpart, /preview)."""
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.last_displayed_image = png_snapshot
|
|
locked.last_displayed_at = time.time()
|
|
|
|
|
|
@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).
|
|
|
|
Also records what's returned as this frame's "now displaying"
|
|
snapshot (see _record_last_displayed) -- every other device-facing
|
|
render endpoint below does the same."""
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
|
|
_record_last_displayed(db, frame, snapshot)
|
|
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, snapshot = _render_frame_content(
|
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
|
)
|
|
_record_last_displayed(db, frame, snapshot)
|
|
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, snapshot = _render_frame_content(
|
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
|
)
|
|
_record_last_displayed(db, frame, snapshot)
|
|
return Response(content=content, media_type="application/octet-stream")
|
|
|
|
|
|
@router.post("/frame/global-next")
|
|
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
|
|
instead of a short press -- runs Frame.next_hold_action (see
|
|
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
|
|
returns the whole panel same as /frame/advance. A separate endpoint
|
|
from /frame/advance (not a query flag on it) so the frozen short-press
|
|
path's behavior never has to account for the long-press case -- see
|
|
firmware/main/next_button.c for the short/long split."""
|
|
_run_global_action(db, frame, "next")
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content, snapshot = _render_frame_content(
|
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
|
)
|
|
_record_last_displayed(db, frame, snapshot)
|
|
return Response(content=content, media_type="application/octet-stream")
|
|
|
|
|
|
@router.post("/frame/global-back")
|
|
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""The mirror of /frame/global-next, for a held BACK button."""
|
|
_run_global_action(db, frame, "back")
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content, snapshot = _render_frame_content(
|
|
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
|
|
)
|
|
_record_last_displayed(db, frame, snapshot)
|
|
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")
|