powerpods/goTool/battery_api.go
simon 3cb0b5bbe9 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>
2026-05-29 20:14:28 +02:00

121 lines
3.0 KiB
Go

package main
import (
"powerpod/gotool/pb"
)
const (
lipoMinMv = 3000
lipoMaxMv = 4200
)
type lipoReadingJSON struct {
Valid bool `json:"valid"`
VoltageMv uint32 `json:"voltage_mv"`
Percent int `json:"percent,omitempty"`
}
type batterySampleJSON struct {
ClientID uint32 `json:"client_id"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
AgeMs uint32 `json:"age_ms,omitempty"`
}
type batteryAPIRequest struct {
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
}
type batteryAPIResponse struct {
Type string `json:"type,omitempty"` // battery_status (WebSocket)
Success bool `json:"success"`
Samples []batterySampleJSON `json:"samples,omitempty"`
Error string `json:"error,omitempty"`
}
func lipoPercent(mv uint32) int {
if mv <= lipoMinMv {
return 0
}
if mv >= lipoMaxMv {
return 100
}
return int((mv - lipoMinMv) * 100 / (lipoMaxMv - lipoMinMv))
}
func lipoFromPBMsg(l *pb.LipoReading) lipoReadingJSON {
if l == nil {
return lipoReadingJSON{}
}
return lipoFromPB(l.GetValid(), l.GetVoltageMv())
}
func lipoFromPB(valid bool, mv uint32) lipoReadingJSON {
out := lipoReadingJSON{Valid: valid, VoltageMv: mv}
if valid {
out.Percent = lipoPercent(mv)
}
return out
}
func batterySamplesFromPB(samples []*pb.BatterySample) []batterySampleJSON {
out := make([]batterySampleJSON, 0, len(samples))
for _, s := range samples {
out = append(out, batterySampleJSON{
ClientID: s.GetClientId(),
Lipo1: lipoFromPBMsg(s.GetLipo1()),
Lipo2: lipoFromPBMsg(s.GetLipo2()),
AgeMs: s.GetAgeMs(),
})
}
return out
}
func applyBatteryStatus(link *managedSerial, in batteryAPIRequest) batteryAPIResponse {
resp, err := link.BatteryStatus(&pb.BatteryStatusRequest{
ClientId: in.ClientID,
AllClients: in.AllClients,
})
if err != nil {
return batteryAPIResponse{Error: err.Error()}
}
samples := batterySamplesFromPB(resp.GetSamples())
out := batteryAPIResponse{
Success: resp.GetSuccess() || len(samples) > 0,
Samples: samples,
}
if len(samples) == 0 && out.Error == "" {
out.Error = "battery status unavailable"
}
return out
}
func findBatterySample(samples []batterySampleJSON, clientID uint32) (batterySampleJSON, bool) {
for _, s := range samples {
if s.ClientID == clientID {
return s, true
}
}
return batterySampleJSON{}, false
}
// applyBatterySamplesToState merges UART/REST battery samples into dashboard views.
func applyBatterySamplesToState(st *DashboardState, samples []batterySampleJSON) {
if st == nil || len(samples) == 0 {
return
}
if m, ok := findBatterySample(samples, 0); ok {
st.Master.Lipo1 = m.Lipo1
st.Master.Lipo2 = m.Lipo2
st.Master.BatteryAgeMs = m.AgeMs
}
for i := range st.Clients {
if s, ok := findBatterySample(samples, st.Clients[i].ID); ok {
st.Clients[i].Lipo1 = s.Lipo1
st.Clients[i].Lipo2 = s.Lipo2
st.Clients[i].BatteryAgeMs = s.AgeMs
}
}
}