powerpods/goTool/dashboard.go
simon a0f4a81a55 Add per-slave ESP-NOW OTA progress over UART and fix dashboard updates.
Expose OTA_SLAVE_PROGRESS on the master, track per-slave state during
distribution, run ESP-NOW OTA in a background task so the host can poll
while slaves update, and show master/slave progress in the dashboard
with table layout and faster WebSocket refresh during uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 21:07:46 +02:00

239 lines
5.4 KiB
Go

package main
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"powerpod/gotool/pb"
)
type MasterView struct {
Version uint32 `json:"version"`
GitHash string `json:"git_hash"`
RunningPartition string `json:"running_partition,omitempty"`
Deadzone uint32 `json:"deadzone,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
type ClientView struct {
ID uint32 `json:"id"`
MAC string `json:"mac"`
Version uint32 `json:"version"`
Deadzone uint32 `json:"deadzone,omitempty"`
Available bool `json:"available"`
Used bool `json:"used"`
LastPing uint32 `json:"last_ping"`
LastSuccessPing uint32 `json:"last_success_ping"`
}
type DashboardState struct {
UpdatedAt string `json:"updated_at"`
SerialPort string `json:"serial_port"`
UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"`
Master MasterView `json:"master"`
Clients []ClientView `json:"clients"`
}
type wsHub struct {
mu sync.RWMutex
clients map[*websocket.Conn]struct{}
state DashboardState
}
func newWSHub() *wsHub {
return &wsHub{clients: make(map[*websocket.Conn]struct{})}
}
func (h *wsHub) setState(st DashboardState) {
h.mu.Lock()
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 (h *wsHub) register(c *websocket.Conn) {
h.mu.Lock()
h.clients[c] = struct{}{}
snap := h.state
h.mu.Unlock()
if data, err := json.Marshal(snap); err == nil {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func (h *wsHub) unregister(c *websocket.Conn) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
func (h *wsHub) broadcastRaw(v any) {
h.mu.RLock()
conns := make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
h.mu.RUnlock()
data, err := json.Marshal(v)
if err != nil {
return
}
for _, c := range conns {
_ = c.WriteMessage(websocket.TextMessage, data)
}
}
func pollDashboard(link *managedSerial, portName string, last *DashboardState) DashboardState {
st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
Clients: []ClientView{},
}
ver, err := link.getVersionPoll()
if errors.Is(err, errUARTBusy) {
return pausedPollState(portName, last)
}
if err != nil {
return disconnectedState(portName, err)
}
st.UARTConnected = true
st.SerialOK = true
st.Master = MasterView{
Version: ver.GetVersion(),
GitHash: ver.GetGitHash(),
RunningPartition: ver.GetRunningPartition(),
OK: true,
}
if dz, err := readDeadzonePoll(link, 0); err == nil {
st.Master.Deadzone = dz
}
clients, err := link.listClientsPoll()
if err != nil {
if errors.Is(err, errUARTBusy) {
return pausedPollState(portName, last)
}
st.SerialOK = false
st.SerialError = err.Error()
st.UARTConnected = link.IsConnected()
return st
}
for _, c := range clients {
cv := ClientView{
ID: c.GetId(),
MAC: formatMAC(c.GetMac()),
Version: c.GetVersion(),
Available: c.GetAvailable(),
Used: c.GetUsed(),
LastPing: c.GetLastPing(),
LastSuccessPing: c.GetLastSuccessPing(),
}
if dz, err := readDeadzonePoll(link, c.GetId()); err == nil {
cv.Deadzone = dz
}
st.Clients = append(st.Clients, cv)
}
return st
}
func pausedPollState(portName string, last *DashboardState) DashboardState {
if last != nil && last.UARTConnected {
st := *last
st.UpdatedAt = time.Now().Format(time.RFC3339)
st.SerialPort = portName
st.SerialOK = true
st.SerialError = "Live-Polling pausiert (OTA läuft)"
return st
}
return disconnectedState(portName, errUARTBusy)
}
func readDeadzone(link *managedSerial, clientID uint32) (uint32, error) {
r, err := link.AccelDeadzone(&pb.AccelDeadzoneRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
return 0, err
}
if !r.GetSuccess() {
return 0, fmt.Errorf("deadzone read failed for client %d", clientID)
}
return r.GetDeadzone(), nil
}
func readDeadzonePoll(link *managedSerial, clientID uint32) (uint32, error) {
r, err := link.AccelDeadzonePoll(&pb.AccelDeadzoneRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
return 0, err
}
if !r.GetSuccess() {
return 0, fmt.Errorf("deadzone read failed for client %d", clientID)
}
return r.GetDeadzone(), nil
}
func formatMAC(mac []byte) string {
if len(mac) == 0 {
return ""
}
return hex.EncodeToString(mac)
}
func runPoller(link *managedSerial, portName string, hub *wsHub, interval time.Duration, stop <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
uartUp := false
var lastGood DashboardState
publish := func() {
st := pollDashboard(link, portName, &lastGood)
if st.UARTConnected && st.SerialOK {
lastGood = st
}
if st.UARTConnected && !uartUp {
log.Printf("UART %s connected", portName)
}
uartUp = st.UARTConnected
hub.setState(st)
}
publish()
for {
select {
case <-stop:
return
case <-ticker.C:
publish()
}
}
}