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 |
|
||||
| `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
|
||||
```
|
||||
|
||||
@ -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,
|
||||
|
||||
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
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
go.bug.st/serial v1.6.4
|
||||
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/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=
|
||||
|
||||
@ -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)
|
||||
|
||||
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 (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
@ -13,6 +14,8 @@ const readTimeout = 3 * time.Second
|
||||
|
||||
type serialPort struct {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if !s.quiet {
|
||||
log.Printf("response payload (%d bytes): % x", len(payload), payload)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
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