powerpods/goTool/dashboard.go
simon 85aeab85c0 Add web dashboard configuration for master and slaves.
Expose deadzone and unicast-test via HTTP API and UI, reusing the same UART commands as the CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:10:33 +02:00

183 lines
3.9 KiB
Go

package main
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"powerpod/gotool/pb"
)
type MasterView struct {
Version uint32 `json:"version"`
GitHash string `json:"git_hash"`
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 pollDashboard(link *managedSerial, portName string) DashboardState {
st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName,
Clients: []ClientView{},
}
ver, err := link.getVersion()
if err != nil {
return disconnectedState(portName, err)
}
st.UARTConnected = true
st.SerialOK = true
st.Master = MasterView{
Version: ver.GetVersion(),
GitHash: ver.GetGitHash(),
OK: true,
}
if dz, err := readDeadzone(link, 0); err == nil {
st.Master.Deadzone = dz
}
clients, err := link.listClients()
if err != nil {
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 := readDeadzone(link, c.GetId()); err == nil {
cv.Deadzone = dz
}
st.Clients = append(st.Clients, cv)
}
return st
}
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 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
publish := func() {
st := pollDashboard(link, portName)
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()
}
}
}