Server: permanent battery history log + graph in the web UI
Build and push server image / build-and-push (push) Successful in 35s
Build and push server image / build-and-push (push) Successful in 35s
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.
This commit is contained in:
+32
-8
@@ -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
|
back -- it displaces the current photo onto the front of the upcoming
|
||||||
queue rather than discarding it. Same response shape as
|
queue rather than discarding it. Same response shape as
|
||||||
`/frame/image`. Used by the device's back-photo button.
|
`/frame/image`. Used by the device's back-photo button.
|
||||||
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
|
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`,
|
||||||
each wake alongside its reachability check
|
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": ... |
|
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
|
||||||
null, "location_line2": ... | null, "taken_at": ... | null}` for the
|
null, "location_line2": ... | null, "taken_at": ... | null}` for the
|
||||||
current photo (same idempotent current-photo semantics as
|
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
|
`app/face_labels.py`); `count: 0` if none are named. Used by the
|
||||||
device manage button's escalated second menu level
|
device manage button's escalated second menu level
|
||||||
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
|
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
|
||||||
battery reading, stored with a timestamp. Only sent when the device
|
battery reading, stored with a timestamp plus two histories: a
|
||||||
is actually running on battery (see `firmware/README.md`'s Battery
|
per-discharge-cycle one (reset whenever a report jumps up enough to
|
||||||
section) -- a frame on mains power never reports
|
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": [...],
|
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
|
||||||
"battery": {"percent": N, "as_of": ts} | null}`, each queue entry an
|
"device": {"last_seen": ts | null, "overdue": bool,
|
||||||
asset id + thumbnail URL; used by the config UI (which shows the
|
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
|
||||||
battery line under "Now displaying" when present)
|
"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
|
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
|
||||||
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
|
`{"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)
|
changed server-side since the client's last fetch (e.g. a top-up/trim)
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ class FrameConfig(BaseModel):
|
|||||||
# main.py). Feeds the "on battery for" and "estimated remaining"
|
# main.py). Feeds the "on battery for" and "estimated remaining"
|
||||||
# numbers in the web UI's Device panel.
|
# numbers in the web UI's Device panel.
|
||||||
battery_history: list = []
|
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/*
|
# Device liveness/telemetry: last_seen is touched by every /frame/*
|
||||||
# request; device_firmware_version comes from the X-Frame-Version
|
# request; device_firmware_version comes from the X-Frame-Version
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
|||||||
|
|
||||||
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
||||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
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
|
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_SPAN_S = 2 * 3600 # need at least this much observed time...
|
||||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
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 = []
|
||||||
cfg.battery_history.append([now, body.percent])
|
cfg.battery_history.append([now, body.percent])
|
||||||
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
|
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_percent = body.percent
|
||||||
cfg.battery_as_of = now
|
cfg.battery_as_of = now
|
||||||
cfg.last_seen = 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):
|
class QueueReorderRequest(BaseModel):
|
||||||
queue: list[str]
|
queue: list[str]
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,9 @@
|
|||||||
<h2 class="section">Device</h2>
|
<h2 class="section">Device</h2>
|
||||||
<div id="device-status"><p class="sub">Loading...</p></div>
|
<div id="device-status"><p class="sub">Loading...</p></div>
|
||||||
|
|
||||||
|
<h2 class="section">Battery history</h2>
|
||||||
|
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||||
|
|
||||||
<h2 class="section">Firmware update</h2>
|
<h2 class="section">Firmware update</h2>
|
||||||
<p class="sub" id="firmware-available">
|
<p class="sub" id="firmware-available">
|
||||||
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
|
{% 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 = '<p class="sub">Not enough data yet.</p>';
|
||||||
|
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 = '<p class="sub">Could not load.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
drawBatteryChart(data.log);
|
||||||
|
} catch (e) {
|
||||||
|
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadQueue();
|
loadQueue();
|
||||||
|
loadBatteryLog();
|
||||||
|
|
||||||
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
|
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
|
||||||
// already-fetched device data every second, so they count up smoothly
|
// already-fetched device data every second, so they count up smoothly
|
||||||
|
|||||||
Reference in New Issue
Block a user