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:
simon 2026-05-19 00:04:57 +02:00
parent 0299ba44fd
commit c4696657a7
10 changed files with 652 additions and 13 deletions

View File

@ -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
```

View File

@ -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
View 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
View 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()
}
}
}

View File

@ -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
)

View File

@ -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=

View File

@ -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
View 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{},
}
}

View File

@ -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")
}

219
goTool/webui/index.html Normal file
View 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>