Add LiPo battery monitoring with ESP-NOW cache and dashboard API.

Slaves report pack voltages every 30s; the master caches them for fast
BATTERY_STATUS reads. goTool exposes REST/WebSocket and shows values in
the dashboard, with a nanopb fix so optional lipo submessages encode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 20:14:28 +02:00
co-authored by Cursor
parent eb67a46158
commit 3cb0b5bbe9
30 changed files with 1618 additions and 150 deletions
+96 -2
View File
@@ -200,6 +200,10 @@
<dd class="col-7" x-text="state.master.running_partition || '—'"></dd>
<dt class="col-5 text-muted">Deadzone</dt>
<dd class="col-7" x-text="state.master.deadzone != null ? state.master.deadzone + ' LSB' : '—'"></dd>
<dt class="col-5 text-muted">LiPo 1</dt>
<dd class="col-7" x-text="formatLipo(state.master?.lipo1)"></dd>
<dt class="col-5 text-muted">LiPo 2</dt>
<dd class="col-7" x-text="formatLipo(state.master?.lipo2)"></dd>
</dl>
</template>
<template x-if="state.master && !state.master.ok">
@@ -280,13 +284,14 @@
<th>Status</th>
<th>Deadzone</th>
<th>Accel (LSB)</th>
<th>Akku</th>
<th>Stream</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<template x-if="!(state.clients || []).length">
<tr><td colspan="8" class="text-muted text-center py-4">No clients</td></tr>
<tr><td colspan="9" class="text-muted text-center py-4">No clients</td></tr>
</template>
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
<tr>
@@ -305,6 +310,7 @@
x-text="formatAccel(c)"
:title="accelTitle(c)"></span>
</td>
<td class="small" x-text="formatLipoPair(c)" :title="lipoTitle(c)"></td>
<td>
<button type="button"
class="btn btn-sm"
@@ -576,9 +582,14 @@
connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws';
if (this._batteryTimer) clearInterval(this._batteryTimer);
this._batteryTimer = setInterval(() => this.refreshBattery(), 5000);
const connect = () => {
this.ws = new WebSocket(url);
this.ws.onopen = () => { this.wsConnected = true; };
this.ws.onopen = () => {
this.wsConnected = true;
this.refreshBattery();
};
this.ws.onclose = () => {
this.wsConnected = false;
setTimeout(connect, 2000);
@@ -590,7 +601,13 @@
this.applyOTAProgress(msg);
return;
}
if (msg.type === 'battery_status') {
if (msg.samples?.length) this.applyBatterySamples(msg.samples);
return;
}
const prev = this.state;
this.state = msg;
this.preserveBatteryInState(prev, this.state);
if (msg.master?.deadzone != null) {
this.masterDz = msg.master.deadzone;
}
@@ -604,10 +621,87 @@
};
connect();
},
preserveBatteryInState(prev, next) {
if (!prev || !next) return;
const keepLipo = (oldL, newL) => {
if (newL?.valid) return newL;
if (oldL?.valid) return oldL;
return newL ?? oldL;
};
const keepAge = (oldAge, newAge, hasValid) => {
if (hasValid && newAge != null) return newAge;
if (oldAge != null && !hasValid) return oldAge;
return newAge ?? oldAge;
};
if (next.master) {
const pm = prev.master || {};
const l1 = keepLipo(pm.lipo1, next.master.lipo1);
const l2 = keepLipo(pm.lipo2, next.master.lipo2);
next.master.lipo1 = l1;
next.master.lipo2 = l2;
next.master.battery_age_ms = keepAge(
pm.battery_age_ms, next.master.battery_age_ms, !!(l1?.valid || l2?.valid));
}
if (!Array.isArray(next.clients)) return;
const prevById = Object.fromEntries((prev.clients || []).map((c) => [c.id, c]));
next.clients = next.clients.map((c) => {
const p = prevById[c.id];
if (!p) return c;
const l1 = keepLipo(p.lipo1, c.lipo1);
const l2 = keepLipo(p.lipo2, c.lipo2);
return {
...c,
lipo1: l1,
lipo2: l2,
battery_age_ms: keepAge(p.battery_age_ms, c.battery_age_ms, !!(l1?.valid || l2?.valid))
};
});
},
applyBatterySamples(samples) {
if (!samples?.length) return;
for (const s of samples) {
if (s.client_id === 0) {
if (!this.state.master) this.state.master = {};
this.state.master.lipo1 = s.lipo1;
this.state.master.lipo2 = s.lipo2;
this.state.master.battery_age_ms = s.age_ms;
continue;
}
const c = (this.state.clients || []).find((x) => x.id === s.client_id);
if (c) {
c.lipo1 = s.lipo1;
c.lipo2 = s.lipo2;
c.battery_age_ms = s.age_ms;
}
}
},
async refreshBattery() {
if (!this.state?.uart_connected) return;
try {
const r = await fetch('/api/battery?all_clients=1');
if (!r.ok) return;
const data = await r.json();
if (data.samples?.length) this.applyBatterySamples(data.samples);
} catch (_) {}
},
formatMac(hex) {
if (!hex || hex.length !== 12) return hex || '';
return hex.match(/.{2}/g).join(':');
},
formatLipo(l) {
if (!l?.valid) return '—';
const v = (l.voltage_mv / 1000).toFixed(2);
return l.percent != null ? `${v} V (${l.percent}%)` : `${v} V`;
},
formatLipoPair(c) {
return `1: ${this.formatLipo(c?.lipo1)} · 2: ${this.formatLipo(c?.lipo2)}`;
},
lipoTitle(c) {
if (!c?.lipo1?.valid && !c?.lipo2?.valid) return 'Keine ADC-Daten (Cache ~30 s)';
let t = `LiPo1 ${c.lipo1?.voltage_mv ?? '—'} mV, LiPo2 ${c.lipo2?.voltage_mv ?? '—'} mV`;
if (c.battery_age_ms != null) t += `, Alter ${c.battery_age_ms} ms`;
return t;
},
formatAccel(c) {
if (!c?.accel_stream) return '—';
if (!c?.accel_valid) return '…';