powerpods/goTool/serialport.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

132 lines
3.2 KiB
Go

package main
import (
"fmt"
"log"
"sync"
"time"
"go.bug.st/serial"
uartframe "powerpod/gotool/uart"
)
const readTimeout = 3 * time.Second
// batteryReadTimeout: master may query each slave over ESP-NOW (~400 ms each).
const batteryReadTimeout = 12 * time.Second
type serialPort struct {
port serial.Port
mu sync.Mutex
quiet bool
}
func openSerial(portName string, baud int) (*serialPort, error) {
mode := &serial.Mode{
BaudRate: baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
port, err := serial.Open(portName, mode)
if err != nil {
return nil, err
}
if err := port.SetReadTimeout(readTimeout); err != nil {
port.Close()
return nil, err
}
return &serialPort{port: port}, nil
}
func (s *serialPort) Close() error {
return s.port.Close()
}
func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.exchangePayloadLocked(payload, cmdName, readTimeout)
}
func (s *serialPort) exchangePayloadForBattery(payload []byte, cmdName string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.exchangePayloadLocked(payload, cmdName, batteryReadTimeout)
}
func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty payload")
}
frame, err := uartframe.EncodeFrame(payload)
if err != nil {
return nil, fmt.Errorf("encode frame: %w", err)
}
if !s.quiet {
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
}
if _, err := s.port.Write(frame); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
if timeout <= 0 {
timeout = readTimeout
}
if err := s.port.SetReadTimeout(timeout); err != nil {
return nil, fmt.Errorf("set read timeout: %w", err)
}
defer func() { _ = s.port.SetReadTimeout(readTimeout) }()
respPayload, err := uartframe.ReadFrame(s.port, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if !s.quiet {
log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload)
}
if len(respPayload) == 0 {
return nil, fmt.Errorf("empty response payload")
}
return respPayload, nil
}
func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.exchangeLocked(cmdID, cmdName)
}
func (s *serialPort) exchangeLocked(cmdID byte, cmdName string) ([]byte, error) {
frame, err := uartframe.EncodeFrame([]byte{cmdID})
if err != nil {
return nil, fmt.Errorf("encode frame: %w", err)
}
if !s.quiet {
log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame)
}
if _, err := s.port.Write(frame); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
payload, err := uartframe.ReadFrame(s.port, nil)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if !s.quiet {
log.Printf("response payload (%d bytes): % x", len(payload), payload)
}
if len(payload) == 0 {
return nil, fmt.Errorf("empty response payload")
}
if payload[0] != cmdID {
return nil, fmt.Errorf("unexpected command id 0x%02x (want 0x%02x)", payload[0], cmdID)
}
return payload, nil
}