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
+100 -4
View File
@@ -19,7 +19,10 @@ type MasterView struct {
GitHash string `json:"git_hash"`
RunningPartition string `json:"running_partition,omitempty"`
Deadzone uint32 `json:"deadzone,omitempty"`
OK bool `json:"ok"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
@@ -37,7 +40,10 @@ type ClientView struct {
AccelY int32 `json:"accel_y"`
AccelZ int32 `json:"accel_z"`
AccelAgeMs uint32 `json:"accel_age_ms"`
AccelStream bool `json:"accel_stream"`
AccelStream bool `json:"accel_stream"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
}
type DashboardState struct {
@@ -62,8 +68,16 @@ func newWSHub() *wsHub {
func (h *wsHub) setState(st DashboardState) {
h.mu.Lock()
prev := h.state.Clients
st.Clients = preserveClientAccel(st.Clients, prev)
prev := h.state
st.Clients = preserveClientAccel(st.Clients, prev.Clients)
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
if !st.Master.Lipo1.Valid && !st.Master.Lipo2.Valid {
if prev.Master.Lipo1.Valid || prev.Master.Lipo2.Valid {
st.Master.Lipo1 = prev.Master.Lipo1
st.Master.Lipo2 = prev.Master.Lipo2
st.Master.BatteryAgeMs = prev.Master.BatteryAgeMs
}
}
h.state = st
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
@@ -156,6 +170,33 @@ func preserveClientAccel(newClients, oldClients []ClientView) []ClientView {
return out
}
func preserveClientBattery(newClients, oldClients []ClientView) []ClientView {
if len(oldClients) == 0 {
return newClients
}
oldByID := make(map[uint32]ClientView, len(oldClients))
for _, c := range oldClients {
oldByID[c.ID] = c
}
out := make([]ClientView, len(newClients))
for i, c := range newClients {
out[i] = c
if c.Lipo1.Valid || c.Lipo2.Valid {
continue
}
prev, ok := oldByID[c.ID]
if !ok {
continue
}
if prev.Lipo1.Valid || prev.Lipo2.Valid {
out[i].Lipo1 = prev.Lipo1
out[i].Lipo2 = prev.Lipo2
out[i].BatteryAgeMs = prev.BatteryAgeMs
}
}
return out
}
func anyClientAccelStream(clients []ClientView) bool {
for _, c := range clients {
if c.AccelStream {
@@ -294,6 +335,7 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
}
st.Clients = append(st.Clients, cv)
}
applyBatteryToState(link, &st)
if anyClientAccelStream(st.Clients) {
for i := range st.Clients {
if !st.Clients[i].AccelStream {
@@ -319,6 +361,60 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
return st
}
func applyBatteryToState(link *managedSerial, st *DashboardState) {
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
if err != nil {
log.Printf("battery poll: %v", err)
return
}
applyBatterySamplesToState(st, batterySamplesFromPB(bat.GetSamples()))
}
func (h *wsHub) mergeBattery(samples []batterySampleJSON) {
if len(samples) == 0 {
return
}
h.mu.Lock()
st := h.state
applyBatterySamplesToState(&st, samples)
st.UpdatedAt = time.Now().Format(time.RFC3339)
h.state = st
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.Unlock()
data, err := json.Marshal(st)
if err != nil {
return
}
for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, stop <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
if hub.clientCount() == 0 {
continue
}
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
if err != nil {
continue
}
hub.mergeBattery(batterySamplesFromPB(bat.GetSamples()))
}
}
}
func runAccelDashboardPoller(link *managedSerial, hub *wsHub, interval time.Duration, stop <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()