From a7b6c6d77ad6e5dc1cf02ee60176f7b3bc6a1961 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 20 Jul 2026 23:21:20 -0400 Subject: [PATCH] Server: permanent battery history log + graph in the web UI battery_history stays cycle-scoped (reset on recharge, feeds the "on battery for"/estimate numbers), but nothing kept a permanent record -- added battery_log, appended on every report and never reset, capped at ~2 years of hourly reports as a sanity bound rather than a real limit. New GET /api/battery-log serves it; the web UI draws it as a plain canvas line chart (no chart library) under a new "Battery history" section, loaded once on page load. Also caught up server/README.md, which never documented the OTA firmware endpoints or the /api/queue response's current "device" shape from the earlier status-panel work. --- server/README.md | 40 ++++++++++++---- server/app/config.py | 6 +++ server/app/main.py | 9 ++++ server/app/templates/index.html | 81 +++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) diff --git a/server/README.md b/server/README.md index ba65268..f14afb4 100644 --- a/server/README.md +++ b/server/README.md @@ -82,8 +82,14 @@ algorithm itself -- it just streams the response straight to the panel. back -- it displaces the current photo onto the front of the upcoming queue rather than discarding it. Same response shape as `/frame/image`. Used by the device's back-photo button. -- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame - each wake alongside its reachability check +- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`, + polled by the frame each wake alongside its reachability check. + `firmware_version` is whatever's currently uploaded via + `POST /api/firmware` below (`null` if nothing's been uploaded) -- the + device compares it against its own running version + (`esp_app_get_description()->version`, sent as an `X-Frame-Version` + request header, stored as `device_firmware_version`) to decide whether + to OTA - `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... | null, "location_line2": ... | null, "taken_at": ... | null}` for the current photo (same idempotent current-photo semantics as @@ -109,13 +115,31 @@ algorithm itself -- it just streams the response straight to the panel. `app/face_labels.py`); `count: 0` if none are named. Used by the device manage button's escalated second menu level - `POST /frame/battery` -- `{"percent": 0-100}`; the device's last - battery reading, stored with a timestamp. Only sent when the device - is actually running on battery (see `firmware/README.md`'s Battery - section) -- a frame on mains power never reports + battery reading, stored with a timestamp plus two histories: a + per-discharge-cycle one (reset whenever a report jumps up enough to + look like a recharge) feeding the "on battery for"/estimate numbers, + and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2 + years at hourly reports) feeding the web UI's battery graph. Only sent + when the device is actually running on battery (see + `firmware/README.md`'s Battery section) -- a frame on mains power + never reports +- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the + full permanent battery history above; used by the web UI's "Battery + history" chart +- `POST /api/firmware` -- multipart upload (`file`) of a built + `espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects + anything that isn't a valid image for this project) and stores it as + the available firmware; devices pick it up via `GET /frame/config` + above on their next wake +- `GET /frame/firmware` -- streams back whatever was last uploaded via + `POST /api/firmware`, for the device's OTA fetch. 404 if nothing's + been uploaded yet - `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...], - "battery": {"percent": N, "as_of": ts} | null}`, each queue entry an - asset id + thumbnail URL; used by the config UI (which shows the - battery line under "Now displaying" when present) + "device": {"last_seen": ts | null, "overdue": bool, + "firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null, + "battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null, + "battery_estimate_s": N | null}}`, each queue entry an asset id + + thumbnail URL; used by the config UI's "Device" panel - `POST /api/queue/reorder` -- reorders the upcoming queue; body is `{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having changed server-side since the client's last fetch (e.g. a top-up/trim) diff --git a/server/app/config.py b/server/app/config.py index fc9f7a6..1bd4e26 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -53,6 +53,12 @@ class FrameConfig(BaseModel): # main.py). Feeds the "on battery for" and "estimated remaining" # numbers in the web UI's Device panel. battery_history: list = [] + # Every report ever received, never reset by a recharge -- the + # permanent record behind the web UI's battery history graph. Capped + # generously (not a real limit at realistic report rates, just a + # safety bound), unlike battery_history above which is deliberately + # scoped to one cycle. + battery_log: list = [] # Device liveness/telemetry: last_seen is touched by every /frame/* # request; device_firmware_version comes from the X-Frame-Version diff --git a/server/app/main.py b/server/app/main.py index 40e0f96..fbf46fc 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -36,6 +36,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token" # Battery-history / estimate tuning (see /frame/battery and _battery_estimate). BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports +BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- see FrameConfig.battery_log 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 @@ -303,6 +304,8 @@ def frame_battery(body: BatteryReport): cfg.battery_history = [] cfg.battery_history.append([now, body.percent]) cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:] + cfg.battery_log.append([now, body.percent]) + cfg.battery_log = cfg.battery_log[-BATTERY_LOG_MAX:] cfg.battery_percent = body.percent cfg.battery_as_of = now cfg.last_seen = now @@ -609,6 +612,12 @@ def api_queue(): } +@app.get("/api/battery-log", dependencies=[Depends(require_access_token)]) +def api_battery_log(): + cfg = config.load() + return {"log": cfg.battery_log} + + class QueueReorderRequest(BaseModel): queue: list[str] diff --git a/server/app/templates/index.html b/server/app/templates/index.html index 588a8a7..887f49c 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -117,6 +117,9 @@

Device

Loading...

+

Battery history

+

Loading...

+

Firmware update

{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame @@ -527,7 +530,85 @@ } } + function drawBatteryChart(log) { + const wrap = document.getElementById('battery-chart-wrap'); + if (!log || log.length < 2) { + wrap.innerHTML = '

Not enough data yet.

'; + return; + } + wrap.innerHTML = ''; + const width = wrap.clientWidth || 440; + const height = 180; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.style.border = '1px solid #e5e7eb'; + canvas.style.borderRadius = '4px'; + wrap.appendChild(canvas); + const ctx = canvas.getContext('2d'); + + const pad = { left: 28, right: 8, top: 10, bottom: 20 }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + + const times = log.map((p) => p[0]); + const minT = Math.min(...times); + const maxT = Math.max(...times); + const spanT = Math.max(1, maxT - minT); + + const x = (t) => pad.left + ((t - minT) / spanT) * plotW; + const y = (pct) => pad.top + (1 - pct / 100) * plotH; + + ctx.strokeStyle = '#e5e7eb'; + ctx.fillStyle = '#999'; + ctx.font = '10px system-ui, sans-serif'; + ctx.lineWidth = 1; + ctx.textAlign = 'left'; + [0, 25, 50, 75, 100].forEach((pct) => { + const yy = y(pct); + ctx.beginPath(); + ctx.moveTo(pad.left, yy); + ctx.lineTo(width - pad.right, yy); + ctx.stroke(); + ctx.fillText(String(pct), 2, yy + 3); + }); + + ctx.strokeStyle = '#2563eb'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + log.forEach((p, i) => { + const px = x(p[0]); + const py = y(p[1]); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + + const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + ctx.fillStyle = '#666'; + ctx.textAlign = 'left'; + ctx.fillText(fmt(minT), pad.left, height - 4); + ctx.textAlign = 'right'; + ctx.fillText(fmt(maxT), width - pad.right, height - 4); + } + + async function loadBatteryLog() { + const wrap = document.getElementById('battery-chart-wrap'); + try { + const resp = await fetch('/api/battery-log'); + if (!resp.ok) { + wrap.innerHTML = '

Could not load.

'; + return; + } + const data = await resp.json(); + drawBatteryChart(data.log); + } catch (e) { + wrap.innerHTML = '

Could not load.

'; + } + } + loadQueue(); + loadBatteryLog(); // Fast tick: re-renders "Last seen"/"On battery for" etc. from the // already-fetched device data every second, so they count up smoothly