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:
+96
-2
@@ -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 '…';
|
||||
|
||||
Reference in New Issue
Block a user