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.
519 lines
25 KiB
Python
519 lines
25 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,
|
|
panel_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,
|
|
panel_type=frame.panel_type,
|
|
)
|
|
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,
|
|
panel_type=frame.panel_type,
|
|
)
|
|
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,
|
|
panel_type=frame.panel_type,
|
|
)
|
|
|
|
|
|
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, *panel_size(frame.panel_type))
|
|
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, panel_type=frame.panel_type,
|
|
)
|
|
|
|
|
|
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,
|
|
panel_type=frame.panel_type,
|
|
)
|
|
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)
|
|
|
|
|
|
# Maps a device's self-reported board (X-Frame-Board, CONFIG_FRAME_BOARD_
|
|
# NAME) to which EPD panel it drives -- the panel type is a property of
|
|
# the board's firmware, not something a person picks in the UI (see
|
|
# Frame.panel_type). Includes both the legacy bare names ("devkit",
|
|
# "xiao") already baked into fielded firmware and the current chip-
|
|
# qualified names ("devkit_esp32c6", "xiao_esp32c6") -- keep both
|
|
# indefinitely, since already-flashed devices can't be retroactively
|
|
# renamed and there's no cost to accepting either.
|
|
BOARD_PANEL_MAP = {
|
|
"devkit": "epd7in3e",
|
|
"xiao": "epd7in3e",
|
|
"devkit_esp32c6": "epd7in3e",
|
|
"xiao_esp32c6": "epd7in3e",
|
|
"ee02": "epd13in3e",
|
|
}
|
|
|
|
|
|
@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. The reported board also auto-sets
|
|
Frame.panel_type (see BOARD_PANEL_MAP) -- which EPD panel a frame
|
|
renders for is derived from what the hardware reports, never a manual
|
|
setting."""
|
|
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
|
|
mapped_panel = BOARD_PANEL_MAP.get(reported_board)
|
|
if mapped_panel and mapped_panel != locked.panel_type:
|
|
locked.panel_type = mapped_panel
|
|
|
|
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")
|