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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Version
+
+ - Git
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ MAC |
+ Ver |
+ Status |
+ Last ping |
+ Last OK |
+ Used |
+
+
+
+
+ | No clients |
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+