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:
2026-07-20 22:23:59 -04:00
parent 015993af00
commit f7eaadcbed
3 changed files with 242 additions and 21 deletions
+14
View File
@@ -48,6 +48,20 @@ class FrameConfig(BaseModel):
# current_asset_set_at timestamp pattern.
battery_percent: int = -1
battery_as_of: float = 0.0
# [timestamp, percent] pairs for the CURRENT discharge cycle only --
# reset whenever a report jumps up enough to indicate a recharge (see
# main.py). Feeds the "on battery for" and "estimated remaining"
# numbers in the web UI's Device panel.
battery_history: list = []
# Device liveness/telemetry: last_seen is touched by every /frame/*
# request; device_firmware_version comes from the X-Frame-Version
# header the device sends with its config poll.
last_seen: float = 0.0
device_firmware_version: str = ""
# Version parsed out of the most recently uploaded OTA image
# (POST /api/firmware); "" = none uploaded yet.
firmware_available_version: str = ""
def load() -> FrameConfig:
+146 -14
View File
@@ -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),
},
}
+82 -7
View File
@@ -113,6 +113,17 @@
<h2 class="section">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<h2 class="section">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
<h2 class="section">Firmware update</h2>
<p class="sub" id="firmware-available">
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
</div>
<h2 class="section">Upcoming</h2>
@@ -400,6 +411,76 @@
}
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = '#991b1b';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch('/api/firmware', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
async function loadQueue() {
const currentEl = document.getElementById('current-thumb');
try {
@@ -433,13 +514,7 @@
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
if (data.battery) {
const batteryLine = document.createElement('p');
batteryLine.className = 'sub';
const asOf = new Date(data.battery.as_of * 1000).toLocaleString();
batteryLine.textContent = `Battery: ${data.battery.percent}% (as of ${asOf})`;
currentEl.appendChild(batteryLine);
}
renderDeviceStatus(data.device);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';