Server: device status panel (last seen, battery history, runtime estimate) + firmware hosting for OTA
/api/queue now returns a "device" object: last_seen/overdue, running and available firmware versions, battery percent + on-battery duration + linear-fit remaining-time estimate (recharge cycles reset the history so estimates never span a charge). New POST /api/firmware (token-gated upload, validates the embedded esp_app_desc_t) and GET /frame/firmware (token-gated download) let a build be pushed to the device without touching it physically. GET /frame/config now accepts an X-Frame-Version header and returns the available firmware version, piggybacking the device's update check on a request it already makes every wake.
This commit is contained in:
+146
-14
@@ -9,8 +9,8 @@ import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, HTTPException, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
@@ -34,6 +34,26 @@ ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped"
|
||||
|
||||
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
|
||||
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
||||
|
||||
# "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 _touch_last_seen() -> None:
|
||||
"""Records that the device just made contact. Called by every
|
||||
/frame/* route -- a handful of extra config writes per wake cycle,
|
||||
which is nothing at hourly wakes."""
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.last_seen = time.time()
|
||||
config.save(cfg)
|
||||
|
||||
|
||||
def _token_valid(request: Request, cfg: config.FrameConfig) -> bool:
|
||||
"""No management_token configured (MANAGEMENT_TOKEN env var, see
|
||||
@@ -71,13 +91,25 @@ def health() -> dict:
|
||||
|
||||
|
||||
@app.get("/frame/config", dependencies=[Depends(require_access_token)])
|
||||
def frame_config():
|
||||
def frame_config(request: Request):
|
||||
"""Device-facing settings, polled by the frame alongside its
|
||||
reachability check. Always returns 200 with current settings
|
||||
(defaults if nothing's been saved yet) -- no Immich-configured gate,
|
||||
since this doubles as the "is the server up" signal."""
|
||||
cfg = config.load()
|
||||
return {"refresh_interval_s": cfg.refresh_interval_s}
|
||||
since this doubles as the "is the server up" signal. Also captures
|
||||
the device's running firmware version (X-Frame-Version header) and
|
||||
advertises the uploaded OTA image's version, so the device's update
|
||||
check costs zero extra round trips."""
|
||||
reported_version = request.headers.get("X-Frame-Version", "")
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.last_seen = time.time()
|
||||
if reported_version:
|
||||
cfg.device_firmware_version = reported_version
|
||||
config.save(cfg)
|
||||
return {
|
||||
"refresh_interval_s": cfg.refresh_interval_s,
|
||||
"firmware_version": cfg.firmware_available_version or None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@@ -190,6 +222,7 @@ def frame_image():
|
||||
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."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
@@ -209,6 +242,7 @@ def frame_advance():
|
||||
"""Forces an immediate advance to the next photo, ignoring
|
||||
refresh_interval_s, and resets the interval clock from now. Used by
|
||||
the device's next-photo button."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
@@ -231,6 +265,7 @@ def frame_back():
|
||||
unchanged) if there's no history to go back to -- same "always
|
||||
returns something displayable" contract as /frame/advance, rather
|
||||
than erroring. Used by the device's back-photo button."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
@@ -253,18 +288,103 @@ class BatteryReport(BaseModel):
|
||||
def frame_battery(body: BatteryReport):
|
||||
"""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 so the web UI can show
|
||||
both the level and how stale it is."""
|
||||
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."""
|
||||
if not 0 <= body.percent <= 100:
|
||||
raise HTTPException(400, "percent must be 0-100")
|
||||
now = time.time()
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + 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.
|
||||
cfg.battery_history = []
|
||||
cfg.battery_history.append([now, body.percent])
|
||||
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
|
||||
cfg.battery_percent = body.percent
|
||||
cfg.battery_as_of = time.time()
|
||||
cfg.battery_as_of = now
|
||||
cfg.last_seen = now
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
# ESP-IDF app images embed an esp_app_desc_t at byte offset 32 (24-byte
|
||||
# image header + 8-byte first-segment header): magic word, then version
|
||||
# (32 bytes, NUL-padded) at +16 and project name (32 bytes) at +48 --
|
||||
# verified against this project's real build artifact.
|
||||
APP_DESC_OFFSET = 32
|
||||
APP_DESC_MAGIC = 0xABCD5432
|
||||
EXPECTED_PROJECT_NAME = "espresso_frame"
|
||||
|
||||
|
||||
def _firmware_path():
|
||||
return config.CONFIG_PATH.parent / "firmware.bin"
|
||||
|
||||
|
||||
def _parse_app_version(data: bytes) -> str:
|
||||
"""Extracts the embedded version from an ESP-IDF app image, raising
|
||||
HTTPException(400) for anything that isn't this project's firmware."""
|
||||
if len(data) < APP_DESC_OFFSET + 80:
|
||||
raise HTTPException(400, "File is too small to be a firmware image")
|
||||
magic = int.from_bytes(data[APP_DESC_OFFSET : APP_DESC_OFFSET + 4], "little")
|
||||
if magic != APP_DESC_MAGIC:
|
||||
raise HTTPException(400, "Not an ESP-IDF application image")
|
||||
version = data[APP_DESC_OFFSET + 16 : APP_DESC_OFFSET + 48].split(b"\x00")[0].decode(errors="replace")
|
||||
project = data[APP_DESC_OFFSET + 48 : APP_DESC_OFFSET + 80].split(b"\x00")[0].decode(errors="replace")
|
||||
if project != EXPECTED_PROJECT_NAME:
|
||||
raise HTTPException(400, f"Image is for project '{project}', not '{EXPECTED_PROJECT_NAME}'")
|
||||
if not version:
|
||||
raise HTTPException(400, "Image has no embedded version")
|
||||
return version
|
||||
|
||||
|
||||
@app.post("/api/firmware", dependencies=[Depends(require_access_token)])
|
||||
def api_firmware_upload(file: UploadFile = File(...)):
|
||||
"""Uploads a firmware image for OTA. The version is parsed out of the
|
||||
image itself (esp_app_desc_t) rather than trusted from a filename or
|
||||
form field, and the project name is checked so an unrelated .bin
|
||||
can't be pushed to the frame by mistake."""
|
||||
data = file.file.read()
|
||||
version = _parse_app_version(data)
|
||||
_firmware_path().write_bytes(data)
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.firmware_available_version = version
|
||||
config.save(cfg)
|
||||
return {"status": "saved", "version": version, "size": len(data)}
|
||||
|
||||
|
||||
@app.get("/frame/firmware", dependencies=[Depends(require_access_token)])
|
||||
def frame_firmware():
|
||||
"""The uploaded OTA image, streamed to the device (esp_https_ota).
|
||||
404 until something has been uploaded."""
|
||||
_touch_last_seen()
|
||||
path = _firmware_path()
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No firmware uploaded")
|
||||
return FileResponse(path, media_type="application/octet-stream")
|
||||
|
||||
|
||||
def _battery_estimate_s(cfg: config.FrameConfig) -> int | None:
|
||||
"""Linear remaining-time estimate from the current discharge cycle's
|
||||
observed rate, or None when there's not enough signal to be honest
|
||||
about (too little time observed, or too little drop -- a flat line
|
||||
extrapolates to garbage)."""
|
||||
hist = cfg.battery_history
|
||||
if len(hist) < 2:
|
||||
return None
|
||||
first_ts, first_pct = hist[0]
|
||||
last_ts, last_pct = hist[-1]
|
||||
span = last_ts - first_ts
|
||||
drop = first_pct - last_pct
|
||||
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
|
||||
return None
|
||||
rate = drop / span # percent per second
|
||||
return int(last_pct / rate)
|
||||
|
||||
|
||||
LOCATION_LINE_MAX_LEN = 14
|
||||
|
||||
US_STATE_ABBR = {
|
||||
@@ -341,6 +461,7 @@ def frame_photo_info():
|
||||
asset id used to build the share-QR's target URL. Read-only, same
|
||||
idempotent current-photo semantics as /frame/image -- doesn't advance
|
||||
anything."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
@@ -381,6 +502,7 @@ def frame_share(asset_id: str):
|
||||
30-minute window starts when it's actually used. Also scoped to the
|
||||
photo currently showing or queued -- not any arbitrary Immich asset
|
||||
id -- as a second layer even a leaked token wouldn't bypass."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
@@ -408,6 +530,7 @@ def frame_face_labels():
|
||||
parser. Empty (count: 0) if no faces are named, or if anything about
|
||||
fetching them fails -- this is a "nice to have" addition to the
|
||||
overlay, not worth failing the whole menu over."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
@@ -466,14 +589,23 @@ def api_queue():
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
|
||||
|
||||
now = time.time()
|
||||
return {
|
||||
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
|
||||
"battery": (
|
||||
{"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
|
||||
if cfg.battery_percent >= 0
|
||||
else None
|
||||
),
|
||||
"device": {
|
||||
"last_seen": cfg.last_seen or None,
|
||||
"overdue": bool(cfg.last_seen and now - cfg.last_seen > cfg.refresh_interval_s * OVERDUE_FACTOR),
|
||||
"firmware_version": cfg.device_firmware_version or None,
|
||||
"firmware_available": cfg.firmware_available_version or None,
|
||||
"battery": (
|
||||
{"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
|
||||
if cfg.battery_percent >= 0
|
||||
else None
|
||||
),
|
||||
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
||||
"battery_estimate_s": _battery_estimate_s(cfg),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user