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 <cursoragent@cursor.com>
This commit is contained in:
parent
0299ba44fd
commit
c4696657a7
@ -26,6 +26,7 @@ go run . -port /dev/ttyUSB0 clients
|
|||||||
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
| `clients` | `0x04` | Lists slaves registered on the master via ESP-NOW |
|
||||||
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
| `unicast-test` | `0x07` | Sends ESP-NOW unicast test to one slave (`-client`, `-seq`) |
|
||||||
| `test` | — | Run an automated scenario (JSON configs under `testdata/`) |
|
| `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.
|
`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.
|
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
|
```bash
|
||||||
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
go run . -port /dev/ttyUSB0 unicast-test -client 16 -seq 42
|
||||||
```
|
```
|
||||||
|
|||||||
@ -8,11 +8,23 @@ import (
|
|||||||
"powerpod/gotool/pb"
|
"powerpod/gotool/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
|
func (m *managedSerial) getVersion() (*pb.VersionResponse, error) {
|
||||||
payload, err := s.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
payload, err := m.exchange(byte(pb.MessageType_VERSION), "VERSION")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var msg pb.UartMessage
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
return nil, fmt.Errorf("decode: %w", err)
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
@ -27,11 +39,7 @@ func (s *serialPort) getVersion() (*pb.VersionResponse, error) {
|
|||||||
return ver, nil
|
return ver, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
|
func decodeClientsPayload(payload []byte) ([]*pb.ClientInfo, error) {
|
||||||
payload, err := s.exchange(byte(pb.MessageType_CLIENT_INFO), "CLIENT_INFO")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var msg pb.UartMessage
|
var msg pb.UartMessage
|
||||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||||
return nil, fmt.Errorf("decode: %w", err)
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
@ -46,6 +54,22 @@ func (s *serialPort) listClients() ([]*pb.ClientInfo, error) {
|
|||||||
return info.GetClients(), nil
|
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) {
|
func (s *serialPort) accelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||||
msg := &pb.UartMessage{
|
msg := &pb.UartMessage{
|
||||||
Type: pb.MessageType_ACCEL_DEADZONE,
|
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||||
|
|||||||
69
goTool/cmd_serve.go
Normal file
69
goTool/cmd_serve.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
156
goTool/dashboard.go
Normal file
156
goTool/dashboard.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ module powerpod/gotool
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
|||||||
@ -17,7 +17,8 @@ func usage() {
|
|||||||
fmt.Fprintf(os.Stderr, " clients registered ESP-NOW slaves on the master\n")
|
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, " 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, " 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()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +38,13 @@ func main() {
|
|||||||
switch cmd {
|
switch cmd {
|
||||||
case "test", "autotest":
|
case "test", "autotest":
|
||||||
runErr = runTest(*portName, *baud, flag.Args()[1:])
|
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":
|
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "unicast-test", "unicast_test":
|
||||||
if *portName == "" {
|
if *portName == "" {
|
||||||
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
||||||
|
|||||||
122
goTool/serial_link.go
Normal file
122
goTool/serial_link.go
Normal file
@ -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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
@ -12,7 +13,9 @@ import (
|
|||||||
const readTimeout = 3 * time.Second
|
const readTimeout = 3 * time.Second
|
||||||
|
|
||||||
type serialPort struct {
|
type serialPort struct {
|
||||||
port serial.Port
|
port serial.Port
|
||||||
|
mu sync.Mutex
|
||||||
|
quiet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func openSerial(portName string, baud int) (*serialPort, error) {
|
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) {
|
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 {
|
if len(payload) == 0 {
|
||||||
return nil, fmt.Errorf("empty payload")
|
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)
|
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 {
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
return nil, fmt.Errorf("write: %w", err)
|
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)
|
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 {
|
if len(respPayload) == 0 {
|
||||||
return nil, fmt.Errorf("empty response payload")
|
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) {
|
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})
|
frame, err := uartframe.EncodeFrame([]byte{cmdID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode frame: %w", err)
|
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 {
|
if _, err := s.port.Write(frame); err != nil {
|
||||||
return nil, fmt.Errorf("write: %w", err)
|
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)
|
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 {
|
if len(payload) == 0 {
|
||||||
return nil, fmt.Errorf("empty response payload")
|
return nil, fmt.Errorf("empty response payload")
|
||||||
}
|
}
|
||||||
|
|||||||
219
goTool/webui/index.html
Normal file
219
goTool/webui/index.html
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Powerpod Dashboard</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--pp-bg: #0f1419;
|
||||||
|
--pp-surface: #1a1f26;
|
||||||
|
--pp-surface-raised: #222831;
|
||||||
|
--pp-border: #3d4449;
|
||||||
|
--pp-text: #f0f3f5;
|
||||||
|
--pp-text-secondary: #c5cdd6;
|
||||||
|
--pp-text-muted: #9aa8b5;
|
||||||
|
--pp-heading: #ffffff;
|
||||||
|
--pp-accent: #8ec8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--pp-bg);
|
||||||
|
color: var(--pp-text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: var(--pp-surface) !important;
|
||||||
|
border-bottom: 1px solid var(--pp-border);
|
||||||
|
}
|
||||||
|
.navbar-brand { color: var(--pp-heading) !important; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--pp-surface);
|
||||||
|
border-color: var(--pp-border);
|
||||||
|
color: var(--pp-text);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
background: var(--pp-surface-raised);
|
||||||
|
border-color: var(--pp-border);
|
||||||
|
color: var(--pp-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted { color: var(--pp-text-muted) !important; }
|
||||||
|
|
||||||
|
dl dt.text-muted {
|
||||||
|
color: var(--pp-text-secondary) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
dl dd { color: var(--pp-text); }
|
||||||
|
|
||||||
|
.badge-online { background: #00a86b; color: #fff; }
|
||||||
|
.badge-offline { background: #5c6570; color: #f0f3f5; }
|
||||||
|
.badge.bg-secondary { background: #4a5560 !important; color: #f0f3f5; }
|
||||||
|
|
||||||
|
.mac {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-table {
|
||||||
|
--bs-table-color: var(--pp-text);
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-border-color: var(--pp-border);
|
||||||
|
--bs-table-hover-color: var(--pp-heading);
|
||||||
|
--bs-table-hover-bg: rgba(255, 255, 255, 0.04);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.pp-table thead th {
|
||||||
|
color: var(--pp-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--pp-border);
|
||||||
|
background: var(--pp-surface-raised);
|
||||||
|
}
|
||||||
|
.pp-table tbody td {
|
||||||
|
color: var(--pp-text);
|
||||||
|
border-color: var(--pp-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.pp-table tbody tr:hover td { color: var(--pp-heading); }
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: rgba(220, 53, 69, 0.2);
|
||||||
|
border-color: #e35d6a;
|
||||||
|
color: #ffb3ba;
|
||||||
|
}
|
||||||
|
.alert-warning {
|
||||||
|
background: rgba(255, 193, 7, 0.15);
|
||||||
|
border-color: #d4a012;
|
||||||
|
color: #ffe08a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body x-data="dashboard()" x-init="connect()">
|
||||||
|
<nav class="navbar navbar-dark mb-4">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">Powerpod</span>
|
||||||
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge rounded-pill me-1"
|
||||||
|
:class="state.uart_connected ? 'badge-online' : 'badge-offline'"
|
||||||
|
x-text="state.uart_connected ? 'UART' : 'UART off'"></span>
|
||||||
|
<span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'"
|
||||||
|
x-text="wsConnected ? 'WS' : 'WS off'"></span>
|
||||||
|
<small class="text-muted ms-2" x-text="state.updated_at || '—'"></small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container pb-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<section class="col-lg-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">Master</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p>
|
||||||
|
<template x-if="!state.uart_connected">
|
||||||
|
<div class="alert alert-warning py-2 mb-2"
|
||||||
|
x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div>
|
||||||
|
</template>
|
||||||
|
<template x-if="state.uart_connected && !state.serial_ok">
|
||||||
|
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div>
|
||||||
|
</template>
|
||||||
|
<template x-if="state.master?.ok">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted">Version</dt>
|
||||||
|
<dd class="col-7" x-text="state.master.version"></dd>
|
||||||
|
<dt class="col-5 text-muted">Git</dt>
|
||||||
|
<dd class="col-7 text-break" x-text="state.master.git_hash"></dd>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
|
<template x-if="state.master && !state.master.ok">
|
||||||
|
<div class="alert alert-warning py-2" x-text="state.master.error"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="col-lg-8">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>ESP-NOW Clients</span>
|
||||||
|
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table pp-table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>MAC</th>
|
||||||
|
<th>Ver</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last ping</th>
|
||||||
|
<th>Last OK</th>
|
||||||
|
<th>Used</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-if="!(state.clients || []).length">
|
||||||
|
<tr><td colspan="7" class="text-muted text-center py-4">No clients</td></tr>
|
||||||
|
</template>
|
||||||
|
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
|
||||||
|
<tr>
|
||||||
|
<td x-text="c.id"></td>
|
||||||
|
<td class="mac" x-text="formatMac(c.mac)"></td>
|
||||||
|
<td x-text="c.version"></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge rounded-pill"
|
||||||
|
:class="c.available ? 'badge-online' : 'badge-offline'"
|
||||||
|
x-text="c.available ? 'available' : 'inactive'"></span>
|
||||||
|
</td>
|
||||||
|
<td x-text="c.last_ping + ' ms'"></td>
|
||||||
|
<td x-text="c.last_success_ping + ' ms'"></td>
|
||||||
|
<td x-text="c.used ? 'yes' : 'no'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function dashboard() {
|
||||||
|
return {
|
||||||
|
state: { master: {}, clients: [] },
|
||||||
|
ws: null,
|
||||||
|
wsConnected: false,
|
||||||
|
connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = proto + '//' + location.host + '/ws';
|
||||||
|
const connect = () => {
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
this.ws.onopen = () => { this.wsConnected = true; };
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.wsConnected = false;
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
this.ws.onmessage = (e) => {
|
||||||
|
try { this.state = JSON.parse(e.data); } catch (_) {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
},
|
||||||
|
formatMac(hex) {
|
||||||
|
if (!hex || hex.length !== 12) return hex || '';
|
||||||
|
return hex.match(/.{2}/g).join(':');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user