From c4696657a7872ef0018e98f82d831c0f467307ee Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 19 May 2026 00:04:57 +0200 Subject: [PATCH] Add goTool web dashboard with UART auto-reconnect. Serve polls the master over UART and pushes live state via WebSocket; reopens the serial port when the device is unplugged and comes back. Co-authored-by: Cursor --- goTool/README.md | 15 +++ goTool/client_api.go | 38 +++++-- goTool/cmd_serve.go | 69 +++++++++++++ goTool/dashboard.go | 156 ++++++++++++++++++++++++++++ goTool/go.mod | 1 + goTool/go.sum | 2 + goTool/main.go | 10 +- goTool/serial_link.go | 122 ++++++++++++++++++++++ goTool/serialport.go | 33 +++++- goTool/webui/index.html | 219 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 652 insertions(+), 13 deletions(-) create mode 100644 goTool/cmd_serve.go create mode 100644 goTool/dashboard.go create mode 100644 goTool/serial_link.go create mode 100644 goTool/webui/index.html diff --git a/goTool/README.md b/goTool/README.md index f1c64bd..7aa1ec7 100644 --- a/goTool/README.md +++ b/goTool/README.md @@ -26,6 +26,7 @@ go run . -port /dev/ttyUSB0 clients | `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW | | `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) | | `test` | — | Run an automated scenario (JSON configs under `testdata/`) | +| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) | `clients` requires slaves to have responded to master discover broadcasts first. @@ -43,6 +44,20 @@ With a complete bench config, `-port` is optional for `test` (uses `uart.master` See [`testdata/README.md`](testdata/README.md) for the JSON schema. +### Web dashboard + +Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5). + +```bash +go run . -port /dev/ttyUSB0 serve +go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s +make gotool-serve PORT=/dev/ttyUSB0 +``` + +Open [http://localhost:8080](http://localhost:8080) — shows master firmware info and the ESP-NOW client table from `CLIENT_INFO`. + +If the UART device is unplugged or the port disappears, `serve` keeps running and retries on each poll interval; the UI shows **UART off** until the port is available again. + ```bash go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42 ``` diff --git a/goTool/client_api.go b/goTool/client_api.go index d39ce68..1fef3a7 100644 --- a/goTool/client_api.go +++ b/goTool/client_api.go @@ -8,11 +8,23 @@ import ( "powerpod/gotool/pb" ) -func (s *serialPort) getVersion() (*pb.VersionResponse, error) { - payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION") +func (m *managedSerial) getVersion() (*pb.VersionResponse, error) { + payload, err := m.exchange(byte(pb.MessageType_VERSION), "VERSION") if err != nil { return nil, err } + return decodeVersionPayload(payload) +} + +func (m *managedSerial) listClients() ([]*pb.ClientInfo, error) { + payload, err := m.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") + if err != nil { + return nil, err + } + return decodeClientsPayload(payload) +} + +func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) { var msg pb.UartMessage if err := proto.Unmarshal(payload[1:], &msg); err != nil { return nil, fmt.Errorf("decode: %w", err) @@ -27,11 +39,7 @@ func (s *serialPort) getVersion() (*pb.VersionResponse, error) { return ver, nil } -func (s *serialPort) listClients() ([]*pb.ClientInfo, error) { - payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") - if err != nil { - return nil, err - } +func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) { var msg pb.UartMessage if err := proto.Unmarshal(payload[1:], &msg); err != nil { return nil, fmt.Errorf("decode: %w", err) @@ -46,6 +54,22 @@ func (s *serialPort) listClients() ([]*pb.ClientInfo, error) { return info.GetClients(), nil } +func (s *serialPort) getVersion() (*pb.VersionResponse, error) { + payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION") + if err != nil { + return nil, err + } + return decodeVersionPayload(payload) +} + +func (s *serialPort) listClients() ([]*pb.ClientInfo, error) { + payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO") + if err != nil { + return nil, err + } + return decodeClientsPayload(payload) +} + func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) { msg := &pb.UartMessage{ Type: pb.MessageType_ACCEL_DEADZONE, diff --git a/goTool/cmd_serve.go b/goTool/cmd_serve.go new file mode 100644 index 0000000..8e069f0 --- /dev/null +++ b/goTool/cmd_serve.go @@ -0,0 +1,69 @@ +package main + +import ( + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +//go:embed webui/* +var webUI embed.FS + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func runServe(portName string, baud int, args []string) error { + serveFlags := flag.NewFlagSet("serve", flag.ExitOnError) + addr := serveFlags.String("addr", ":8080", "HTTP listen address") + interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval") + if err := serveFlags.Parse(args); err != nil { + return err + } + if portName == "" { + return fmt.Errorf("serve requires -port (master UART)") + } + + link := newManagedSerial(portName, baud) + link.quiet = true + defer link.Close() + + hub := newWSHub() + stop := make(chan struct{}) + defer close(stop) + go runPoller(link, portName, hub, *interval, stop) + + mux := http.NewServeMux() + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("websocket upgrade: %v", err) + return + } + hub.register(conn) + defer hub.unregister(conn) + defer conn.Close() + + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }) + + ui, err := fs.Sub(webUI, "webui") + if err != nil { + return err + } + mux.Handle("/", http.FileServer(http.FS(ui))) + + log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, auto-reconnect)", + *addr, portName, baud, interval.String()) + return http.ListenAndServe(*addr, mux) +} diff --git a/goTool/dashboard.go b/goTool/dashboard.go new file mode 100644 index 0000000..fbee1ad --- /dev/null +++ b/goTool/dashboard.go @@ -0,0 +1,156 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type MasterView struct { + Version uint32 `json:"version"` + GitHash string `json:"git_hash"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type ClientView struct { + ID uint32 `json:"id"` + MAC string `json:"mac"` + Version uint32 `json:"version"` + 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, + } + + clients, err := link.listClients() + if err != nil { + st.SerialOK = false + st.SerialError = err.Error() + st.UARTConnected = link.IsConnected() + return st + } + + for _, c := range clients { + st.Clients = append(st.Clients, ClientView{ + ID: c.GetId(), + MAC: formatMAC(c.GetMac()), + Version: c.GetVersion(), + Available: c.GetAvailable(), + Used: c.GetUsed(), + LastPing: c.GetLastPing(), + LastSuccessPing: c.GetLastSuccessPing(), + }) + } + return st +} + +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() + } + } +} diff --git a/goTool/go.mod b/goTool/go.mod index 80decaf..406f670 100644 --- a/goTool/go.mod +++ b/goTool/go.mod @@ -3,6 +3,7 @@ module powerpod/gotool go 1.26.2 require ( + github.com/gorilla/websocket v1.5.3 go.bug.st/serial v1.6.4 google.golang.org/protobuf v1.36.11 ) diff --git a/goTool/go.sum b/goTool/go.sum index 76e832c..d63f03f 100644 --- a/goTool/go.sum +++ b/goTool/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= diff --git a/goTool/main.go b/goTool/main.go index 81d20b7..677f3e4 100644 --- a/goTool/main.go +++ b/goTool/main.go @@ -17,7 +17,8 @@ func usage() { fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n") fmt.Fprintf(os.Stderr, " deadzone get/set accelerometer deadzone (LSB)\n") fmt.Fprintf(os.Stderr, " unicast-test send ESP-NOW unicast test to one slave\n") - fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n\n") + fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n") + fmt.Fprintf(os.Stderr, " serve web dashboard (Bootstrap + WebSocket)\n\n") flag.PrintDefaults() } @@ -37,6 +38,13 @@ func main() { switch cmd { case "test", "autotest": runErr = runTest(*portName, *baud, flag.Args()[1:]) + case "serve", "web", "dashboard": + if *portName == "" { + fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd) + usage() + os.Exit(2) + } + runErr = runServe(*portName, *baud, flag.Args()[1:]) case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test": if *portName == "" { fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd) diff --git a/goTool/serial_link.go b/goTool/serial_link.go new file mode 100644 index 0000000..2e6ec50 --- /dev/null +++ b/goTool/serial_link.go @@ -0,0 +1,122 @@ +package main + +import ( + "fmt" + "log" + "sync" + "time" +) + +// managedSerial keeps the UART open and reconnects after I/O failures or unplug. +type managedSerial struct { + portName string + baud int + quiet bool + + mu sync.Mutex + sp *serialPort +} + +func newManagedSerial(portName string, baud int) *managedSerial { + return &managedSerial{ + portName: portName, + baud: baud, + } +} + +func (m *managedSerial) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + return m.closeLocked() +} + +func (m *managedSerial) IsConnected() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.sp != nil +} + +func (m *managedSerial) openLocked() error { + sp, err := openSerial(m.portName, m.baud) + if err != nil { + return err + } + sp.quiet = m.quiet + m.sp = sp + if !m.quiet { + log.Printf("UART %s connected (%d baud)", m.portName, m.baud) + } + return nil +} + +func (m *managedSerial) closeLocked() error { + if m.sp == nil { + return nil + } + err := m.sp.port.Close() + m.sp = nil + return err +} + +func (m *managedSerial) invalidateLocked(reason error) { + if m.sp == nil { + return + } + if !m.quiet { + log.Printf("UART %s disconnected: %v", m.portName, reason) + } + _ = m.closeLocked() +} + +func (m *managedSerial) withPort(fn func(*serialPort) error) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.sp == nil { + if err := m.openLocked(); err != nil { + return fmt.Errorf("%s: %w", m.portName, err) + } + } + + err := fn(m.sp) + if err != nil { + m.invalidateLocked(err) + } + return err +} + +func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) { + var resp []byte + err := m.withPort(func(sp *serialPort) error { + var e error + resp, e = sp.exchangePayloadLocked(payload, cmdName) + return e + }) + return resp, err +} + +func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) { + var resp []byte + err := m.withPort(func(sp *serialPort) error { + var e error + resp, e = sp.exchangeLocked(cmdID, cmdName) + return e + }) + return resp, err +} + +func disconnectedState(portName string, err error) DashboardState { + msg := "UART disconnected" + if err != nil { + msg = err.Error() + } + return DashboardState{ + UpdatedAt: time.Now().Format(time.RFC3339), + SerialPort: portName, + UARTConnected: false, + SerialOK: false, + SerialError: msg, + Master: MasterView{OK: false, Error: msg}, + Clients: []ClientView{}, + } +} diff --git a/goTool/serialport.go b/goTool/serialport.go index 96e82e6..090a71a 100644 --- a/goTool/serialport.go +++ b/goTool/serialport.go @@ -3,6 +3,7 @@ package main import ( "fmt" "log" + "sync" "time" "go.bug.st/serial" @@ -12,7 +13,9 @@ import ( const readTimeout = 3 * time.Second type serialPort struct { - port serial.Port + port serial.Port + mu sync.Mutex + quiet bool } func openSerial(portName string, baud int) (*serialPort, error) { @@ -39,6 +42,12 @@ func (s *serialPort) Close() error { } func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.exchangePayloadLocked(payload, cmdName) +} + +func (s *serialPort) exchangePayloadLocked(payload []byte, cmdName string) ([]byte, error) { if len(payload) == 0 { return nil, fmt.Errorf("empty payload") } @@ -47,7 +56,9 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er return nil, fmt.Errorf("encode frame: %w", err) } - log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame) + 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) } @@ -57,7 +68,9 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er return nil, fmt.Errorf("read response: %w", err) } - log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload) + if !s.quiet { + log.Printf("response payload (%d bytes): % x", len(respPayload), respPayload) + } if len(respPayload) == 0 { return nil, fmt.Errorf("empty response payload") } @@ -65,12 +78,20 @@ func (s *serialPort) exchangePayload(payload []byte, cmdName string) ([]byte, er } 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) } - log.Printf("sending %s command (%d bytes): % x", cmdName, len(frame), frame) + 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) } @@ -80,7 +101,9 @@ func (s *serialPort) exchange(cmdID byte, cmdName string) ([]byte, error) { return nil, fmt.Errorf("read response: %w", err) } - log.Printf("response payload (%d bytes): % x", len(payload), payload) + if !s.quiet { + log.Printf("response payload (%d bytes): % x", len(payload), payload) + } if len(payload) == 0 { return nil, fmt.Errorf("empty response payload") } diff --git a/goTool/webui/index.html b/goTool/webui/index.html new file mode 100644 index 0000000..68f4db1 --- /dev/null +++ b/goTool/webui/index.html @@ -0,0 +1,219 @@ + + + + + + Powerpod Dashboard + + + + + + + +
+
+
+
+
Master
+
+

+ + + + +
+
+
+ +
+
+
+ ESP-NOW Clients + +
+
+
+ + + + + + + + + + + + + + + + +
IDMACVerStatusLast pingLast OKUsed
+
+
+
+
+
+
+ + + +