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:
@@ -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
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user