Server: permanent battery history log + graph in the web UI
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:
2026-07-20 23:21:20 -04:00
parent 594b0bd513
commit a7b6c6d77a
4 changed files with 128 additions and 8 deletions
+6
View File
@@ -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
+9
View File
@@ -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]
+81
View File
@@ -117,6 +117,9 @@
<h2 class="section">Device</h2>
<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>
<p class="sub" id="firmware-available">
{% 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();
loadBatteryLog();
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
// already-fetched device data every second, so they count up smoothly