From f7eaadcbed28380cb1f4379ea999ee3700d80369 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 20 Jul 2026 22:23:59 -0400 Subject: [PATCH] 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. --- server/app/config.py | 14 +++ server/app/main.py | 160 +++++++++++++++++++++++++++++--- server/app/templates/index.html | 89 ++++++++++++++++-- 3 files changed, 242 insertions(+), 21 deletions(-) diff --git a/server/app/config.py b/server/app/config.py index 8f1d4da..fc9f7a6 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -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: diff --git a/server/app/main.py b/server/app/main.py index ca00287..40e0f96 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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), + }, } diff --git a/server/app/templates/index.html b/server/app/templates/index.html index 817a2fc..ac2db4e 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -113,6 +113,17 @@

Now displaying

Loading...

+ +

Device

+

Loading...

+ +

Firmware update

+

+ {% 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 %} +

+ +

Upcoming

@@ -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 = '

The frame hasn\'t checked in yet.

'; + 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 = '

Nothing displayed yet.

'; } - 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 = '

Could not load.

';