diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a87fa5f..432fe91 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -109,7 +109,7 @@ sequenceDiagram
└─────────────────────────────────────────────────────────────┘
```
-**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX. OTA und Dashboard (`gotool serve`) bleiben UART-only. Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (8–16 ms) mit `esp_timestamp_us`.
+**Host UART vs BLE:** Nur einer aktiv (DIP8). Gleiches Framing (`0xAA`…`0xCC`) und dieselbe `cmd_queue`. BLE: NUS-ähnliche GATT-Char, Write=RX, Notify=TX. `gotool serve` verbindet UART oder BLE über Dashboard/REST/WS (`managedHost`); **OTA nur UART**. Bei BLE-Host und aktivem Accel-Stream pusht `ble_stream` periodisch `BLE_STREAM_TICK` (8–16 ms) mit `esp_timestamp_us` (Live-Pfad bevorzugt Tick statt CACHE_STATUS-Poll).
---
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index ee8a32d..b3d1b32 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -55,7 +55,7 @@ Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves
| LiPo ADC 1 | 1 | `board_input.c` |
| LiPo ADC 2 | 11 | `board_input.c` |
-**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool: `-port …` oder `-ble` / `-ble-name Powerpod`. OTA und `gotool serve` nur über UART. Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16).
+**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA nur UART** (DIP8=0). Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16) speisen Live-Accel im Dashboard/API.
**ESP-IDF-Log (Debug):** `esp_log_*` geht auf **UART0** (Standard-Konsole, typisch USB am Dev-Board, **115200** Baud, `CONFIG_ESP_CONSOLE_UART_NUM=0`). Das ist **getrennt** vom Host-UART1 — goTool liest keine `esp_log`-Ausgabe. Ohne angeschlossenes Debug-Kabel werden aktivierte Logs trotzdem formatiert und an UART0 gesendet (CPU-Overhead bleibt); nur `ESP_LOG_NONE` / Level-Filter vermeiden die Arbeit.
diff --git a/goTool/README.md b/goTool/README.md
index 01eda4f..1d919c9 100644
--- a/goTool/README.md
+++ b/goTool/README.md
@@ -61,16 +61,18 @@ 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).
+Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA remains UART-only.
```bash
+go run . serve
go run . -port /dev/ttyUSB0 serve
-go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s
-go run . -port /dev/ttyUSB0 serve -api-addr :8081 -accel-interval 16ms
+go run . -ble serve
+go run . serve -addr :8080 -interval 2s
+go run . serve -api-addr :8081 -accel-interval 16ms
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`.
+Open [http://localhost:8080](http://localhost:8080) — Host-Verbindung panel (UART ports / BLE scan), master firmware info, ESP-NOW client table.
**Tap (dashboard):** two independent controls per slave:
@@ -81,11 +83,11 @@ Open [http://localhost:8080](http://localhost:8080) — shows master firmware in
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
-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.
+If the host disconnects, `serve` keeps running; the badge shows **off** until you reconnect from the UI or via REST/WS.
### HTTP / WebSocket API
-`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same UART as the dashboard.
+`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same `managedHost` as the dashboard (UART or BLE).
| Doc | Content |
|-----|---------|
diff --git a/goTool/api_host.go b/goTool/api_host.go
new file mode 100644
index 0000000..242b716
--- /dev/null
+++ b/goTool/api_host.go
@@ -0,0 +1,140 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "time"
+)
+
+type hostAPIResponse struct {
+ Transport string `json:"transport"`
+ Endpoint string `json:"endpoint"`
+ Connected bool `json:"connected"`
+ BleName string `json:"ble_name,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type hostConnectRequest struct {
+ Transport string `json:"transport"` // uart|ble
+ Port string `json:"port,omitempty"`
+ Address string `json:"address,omitempty"`
+ Name string `json:"name,omitempty"`
+ Baud int `json:"baud,omitempty"`
+}
+
+type bleScanRequest struct {
+ TimeoutS float64 `json:"timeout_s"`
+ NameFilter string `json:"name_filter"`
+}
+
+type bleScanResponse struct {
+ Devices []bleScanDevice `json:"devices"`
+ Error string `json:"error,omitempty"`
+}
+
+type serialPortsResponse struct {
+ Ports []string `json:"ports"`
+ Error string `json:"error,omitempty"`
+}
+
+func mountHostAPI(mux *http.ServeMux, link *managedHost) {
+ mux.HandleFunc("/api/host", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ st := link.HostStatus()
+ writeJSON(w, http.StatusOK, hostAPIResponse{
+ Transport: st["transport"].(string),
+ Endpoint: st["endpoint"].(string),
+ Connected: st["connected"].(bool),
+ BleName: st["ble_name"].(string),
+ })
+ })
+
+ mux.HandleFunc("/api/host/connect", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body hostConnectRequest
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "invalid JSON"})
+ return
+ }
+ var err error
+ switch body.Transport {
+ case hostTransportUART:
+ err = link.ConnectUART(body.Port, body.Baud)
+ case hostTransportBLE:
+ err = link.ConnectBLE(body.Address, body.Name)
+ default:
+ writeJSON(w, http.StatusBadRequest, hostAPIResponse{Error: "transport must be uart or ble"})
+ return
+ }
+ st := link.HostStatus()
+ resp := hostAPIResponse{
+ Transport: st["transport"].(string),
+ Endpoint: st["endpoint"].(string),
+ Connected: st["connected"].(bool),
+ BleName: st["ble_name"].(string),
+ }
+ if err != nil {
+ resp.Error = err.Error()
+ resp.Connected = false
+ writeJSON(w, http.StatusBadRequest, resp)
+ return
+ }
+ writeJSON(w, http.StatusOK, resp)
+ })
+
+ mux.HandleFunc("/api/host/disconnect", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ _ = link.Disconnect()
+ st := link.HostStatus()
+ writeJSON(w, http.StatusOK, hostAPIResponse{
+ Transport: st["transport"].(string),
+ Endpoint: st["endpoint"].(string),
+ Connected: false,
+ })
+ })
+
+ mux.HandleFunc("/api/serial/ports", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ ports, err := listSerialPorts()
+ if err != nil {
+ writeJSON(w, http.StatusOK, serialPortsResponse{Ports: []string{}, Error: err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, serialPortsResponse{Ports: ports})
+ })
+
+ mux.HandleFunc("/api/ble/scan", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body bleScanRequest
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ timeout := time.Duration(body.TimeoutS * float64(time.Second))
+ if timeout <= 0 {
+ timeout = 5 * time.Second
+ }
+ // Disconnect active BLE before scan (BlueZ adapter contention).
+ if link.Transport() == hostTransportBLE {
+ _ = link.Disconnect()
+ }
+ devs, err := scanBLEDevices(timeout, body.NameFilter)
+ if err != nil {
+ writeJSON(w, http.StatusOK, bleScanResponse{Devices: []bleScanDevice{}, Error: err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, bleScanResponse{Devices: devs})
+ })
+}
diff --git a/goTool/api_serve.go b/goTool/api_serve.go
index 6106024..e3b1211 100644
--- a/goTool/api_serve.go
+++ b/goTool/api_serve.go
@@ -93,6 +93,7 @@ type otaAPIResponse struct {
}
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
+ mountHostAPI(mux, link)
mountLiveStreamAPI(mux, hub)
mountAccelStreamAPI(mux, link, hub, streamCtl)
mountTapAPI(mux, link, hub, tapCtl)
@@ -157,6 +158,10 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
}
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
+ if link.Transport() != hostTransportUART || !link.IsConnected() {
+ writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "OTA requires UART host (DIP8=0)"})
+ return
+ }
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
return
diff --git a/goTool/api_stream.go b/goTool/api_stream.go
index 4792304..26fbfff 100644
--- a/goTool/api_stream.go
+++ b/goTool/api_stream.go
@@ -39,6 +39,9 @@ type InputClientSample struct {
type InputStreamMessage struct {
Type string `json:"type"` // "hello" | "input"
Serial string `json:"serial_port,omitempty"`
+ Transport string `json:"transport,omitempty"`
+ Endpoint string `json:"endpoint,omitempty"`
+ Connected *bool `json:"connected,omitempty"`
IntervalMs int `json:"interval_ms,omitempty"`
PreFetchMs int `json:"pre_fetch_ms,omitempty"`
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
@@ -51,6 +54,33 @@ type InputStreamMessage struct {
Error string `json:"error,omitempty"`
}
+// HostStatusMessage is the reply to get_host / host_connect / host_disconnect.
+type HostStatusMessage struct {
+ Type string `json:"type"` // "host_status"
+ Transport string `json:"transport"`
+ Endpoint string `json:"endpoint"`
+ Connected bool `json:"connected"`
+ BleName string `json:"ble_name,omitempty"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+}
+
+// SerialPortsMessage is the reply to list_serial_ports.
+type SerialPortsMessage struct {
+ Type string `json:"type"` // "serial_ports"
+ Ports []string `json:"ports"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+}
+
+// BleScanResultMessage is the reply to ble_scan.
+type BleScanResultMessage struct {
+ Type string `json:"type"` // "ble_scan_result"
+ Devices []bleScanDevice `json:"devices"`
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+}
+
// StreamStatusMessage is the reply to set_stream / get_stream (this connection).
type StreamStatusMessage struct {
Type string `json:"type"` // "stream_status"
@@ -116,12 +146,23 @@ type accelWSCommand struct {
DoubleTap *bool `json:"double_tap"`
Triple *bool `json:"triple"`
AllClients bool `json:"all_clients"`
+ // Host connect / BLE scan
+ Transport string `json:"transport"`
+ Port string `json:"port"`
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Baud int `json:"baud"`
+ TimeoutS float64 `json:"timeout_s"`
+ NameFilter string `json:"name_filter"`
}
type APIInfoResponse struct {
Name string `json:"name"`
Version string `json:"version"`
SerialPort string `json:"serial_port"`
+ Transport string `json:"transport"`
+ Endpoint string `json:"endpoint"`
+ Connected bool `json:"connected"`
WebSocket string `json:"websocket"`
DefaultIntervalMs int `json:"default_interval_ms"`
DefaultPreFetchMs int `json:"default_pre_fetch_ms"`
@@ -196,7 +237,7 @@ func clampPreFetch(d time.Duration) time.Duration {
return d
}
-func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber {
+func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *wsSubscriber {
sub := &wsSubscriber{
conn: conn,
receiveInput: false,
@@ -207,14 +248,20 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
h.clients[conn] = sub
h.mu.Unlock()
+ st := link.HostStatus()
+ connected := st["connected"].(bool)
hello := InputStreamMessage{
Type: "hello",
- Serial: portName,
+ Serial: st["endpoint"].(string),
+ Transport: st["transport"].(string),
+ Endpoint: st["endpoint"].(string),
+ Connected: &connected,
IntervalMs: int(h.defaultInterval / time.Millisecond),
PreFetchMs: int(h.defaultPreFetch / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs,
Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
Commands: []string{
+ "get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
"list_clients",
"set_stream", "get_stream",
"set_input_stream", "get_input_stream",
@@ -454,6 +501,29 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
continue
}
+ // Prefer BLE_STREAM_TICK when on BLE (firmware push).
+ if link.Transport() == hostTransportBLE {
+ payload, err := link.NextStreamTick(20 * time.Millisecond)
+ if err == nil {
+ cache, cerr := cacheStatusFromBLEStreamPayload(payload)
+ if cerr == nil {
+ hub.ingestTapFromCache(cache)
+ hub.mu.RLock()
+ clients := hub.inputClientsFromCacheLocked(cache, time.Now())
+ hub.mu.RUnlock()
+ hub.deliverInput(InputStreamMessage{
+ Type: "input",
+ T: time.Now().UnixNano(),
+ Success: true,
+ Clients: clients,
+ })
+ pending = nil
+ continue
+ }
+ }
+ // Fall through to CACHE_STATUS poll if no tick yet.
+ }
+
needRead, needDeliver, waitPreFetch := hub.streamTiming(now)
if needRead && pending == nil {
@@ -634,6 +704,58 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
}
switch cmd.Type {
+ case "get_host":
+ writeHostStatus(conn, hostStatusFromLink(link, true, ""))
+
+ case "list_serial_ports":
+ ports, err := listSerialPorts()
+ msg := SerialPortsMessage{Type: "serial_ports", Ports: ports, Success: err == nil}
+ if err != nil {
+ msg.Error = err.Error()
+ msg.Ports = []string{}
+ }
+ writeWSJSON(conn, msg)
+
+ case "ble_scan":
+ timeout := time.Duration(cmd.TimeoutS * float64(time.Second))
+ if timeout <= 0 {
+ timeout = 5 * time.Second
+ }
+ if link.Transport() == hostTransportBLE {
+ _ = link.Disconnect()
+ }
+ devs, err := scanBLEDevices(timeout, cmd.NameFilter)
+ msg := BleScanResultMessage{Type: "ble_scan_result", Devices: devs, Success: err == nil}
+ if err != nil {
+ msg.Error = err.Error()
+ msg.Devices = []bleScanDevice{}
+ }
+ writeWSJSON(conn, msg)
+
+ case "host_connect":
+ var err error
+ switch cmd.Transport {
+ case hostTransportUART:
+ err = link.ConnectUART(cmd.Port, cmd.Baud)
+ case hostTransportBLE:
+ err = link.ConnectBLE(cmd.Address, cmd.Name)
+ default:
+ writeHostStatus(conn, HostStatusMessage{
+ Type: "host_status",
+ Error: "transport must be uart or ble",
+ })
+ return
+ }
+ if err != nil {
+ writeHostStatus(conn, hostStatusFromLink(link, false, err.Error()))
+ return
+ }
+ writeHostStatus(conn, hostStatusFromLink(link, true, ""))
+
+ case "host_disconnect":
+ _ = link.Disconnect()
+ writeHostStatus(conn, hostStatusFromLink(link, true, ""))
+
case "list_clients":
clients, err := link.listClientsPoll()
if err != nil {
@@ -791,13 +913,38 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
default:
writeStreamStatus(conn, StreamStatusMessage{
Type: "stream_status",
- Error: "unknown type (list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
+ Error: "unknown type (get_host, list_serial_ports, ble_scan, host_connect, host_disconnect, list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
})
}
}
-func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, portName string, hub *accelStreamHub) {
- sub := hub.register(conn, portName)
+func hostStatusFromLink(link *managedSerial, success bool, errMsg string) HostStatusMessage {
+ st := link.HostStatus()
+ return HostStatusMessage{
+ Type: "host_status",
+ Transport: st["transport"].(string),
+ Endpoint: st["endpoint"].(string),
+ Connected: st["connected"].(bool),
+ BleName: st["ble_name"].(string),
+ Success: success && errMsg == "",
+ Error: errMsg,
+ }
+}
+
+func writeHostStatus(conn *websocket.Conn, msg HostStatusMessage) {
+ writeWSJSON(conn, msg)
+}
+
+func writeWSJSON(conn *websocket.Conn, msg any) {
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return
+ }
+ _ = conn.WriteMessage(websocket.TextMessage, data)
+}
+
+func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
+ sub := hub.register(conn, link)
defer hub.unregister(conn)
defer conn.Close()
@@ -811,6 +958,7 @@ func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl
}
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) {
+ _ = portName
defMs := int(defaultInterval / time.Millisecond)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" {
@@ -821,17 +969,21 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
+ st := link.HostStatus()
writeJSON(w, http.StatusOK, APIInfoResponse{
Name: "powerpod-external-api",
Version: "1",
- SerialPort: portName,
+ SerialPort: st["endpoint"].(string),
+ Transport: st["transport"].(string),
+ Endpoint: st["endpoint"].(string),
+ Connected: st["connected"].(bool),
WebSocket: "/ws",
DefaultIntervalMs: defMs,
DefaultPreFetchMs: defaultPreFetchMs,
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs,
- Description: "WebSocket: set_input_stream + set_stream for input (accel + tap); set_tap_notify configures slave tap kinds",
+ Description: "WebSocket: host_connect/ble_scan + set_input_stream + set_stream for input (accel + tap)",
})
})
@@ -841,7 +993,7 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
log.Printf("api websocket upgrade: %v", err)
return
}
- serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub)
+ serveExternalWS(conn, link, dash, ctl, tapCtl, hub)
})
}
diff --git a/goTool/ble_link.go b/goTool/ble_link.go
index f3cca01..36f0146 100644
--- a/goTool/ble_link.go
+++ b/goTool/ble_link.go
@@ -37,15 +37,6 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
}
- svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
- if err != nil {
- return nil, err
- }
- chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
- if err != nil {
- return nil, err
- }
-
log.Printf("BLE scanning for %q…", name)
var foundAddr bluetooth.Address
var found bool
@@ -53,7 +44,7 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
time.Sleep(15 * time.Second)
_ = adapter.StopScan()
}()
- err = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
+ err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
if result.LocalName() != name {
return
}
@@ -67,9 +58,57 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
if !found {
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
}
+ return connectBLEDevice(foundAddr, settle)
+}
- log.Printf("BLE connecting…")
- device, err := adapter.Connect(foundAddr, bluetooth.ConnectionParams{})
+func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
+ adapter := bluetooth.DefaultAdapter
+ if err := adapter.Enable(); err != nil {
+ return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
+ }
+ if mac, err := bluetooth.ParseMAC(addrStr); err == nil {
+ var addr bluetooth.Address
+ addr.Set(mac.String())
+ return connectBLEDevice(addr, settle)
+ }
+ // Fallback: scan until address string matches
+ log.Printf("BLE resolving address %s via scan…", addrStr)
+ var foundAddr bluetooth.Address
+ var found bool
+ go func() {
+ time.Sleep(15 * time.Second)
+ _ = adapter.StopScan()
+ }()
+ err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
+ if result.Address.String() != addrStr {
+ return
+ }
+ foundAddr = result.Address
+ found = true
+ _ = a.StopScan()
+ })
+ if err != nil {
+ return nil, fmt.Errorf("scan for address: %w", err)
+ }
+ if !found {
+ return nil, fmt.Errorf("BLE address %s not found", addrStr)
+ }
+ return connectBLEDevice(foundAddr, settle)
+}
+
+func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, error) {
+ svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
+ if err != nil {
+ return nil, err
+ }
+ chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
+ if err != nil {
+ return nil, err
+ }
+
+ adapter := bluetooth.DefaultAdapter
+ log.Printf("BLE connecting to %s…", addr.String())
+ device, err := adapter.Connect(addr, bluetooth.ConnectionParams{})
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
diff --git a/goTool/cache_status.go b/goTool/cache_status.go
index fc0d6b4..c0361a8 100644
--- a/goTool/cache_status.go
+++ b/goTool/cache_status.go
@@ -1,6 +1,12 @@
package main
-import "powerpod/gotool/pb"
+import (
+ "fmt"
+
+ "powerpod/gotool/pb"
+
+ "google.golang.org/protobuf/proto"
+)
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
// (for dashboard / WebSocket accel push).
@@ -47,3 +53,23 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
}
return out
}
+
+// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
+// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
+func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
+ if len(payload) < 2 {
+ return nil, fmt.Errorf("BLE stream tick too short")
+ }
+ if payload[0] != byte(pb.MessageType_BLE_STREAM_TICK) {
+ return nil, fmt.Errorf("unexpected BLE tick type 0x%02x", payload[0])
+ }
+ var msg pb.UartMessage
+ if err := proto.Unmarshal(payload[1:], &msg); err != nil {
+ return nil, err
+ }
+ tick := msg.GetBleStreamTick()
+ if tick == nil {
+ return nil, fmt.Errorf("missing ble_stream_tick")
+ }
+ return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
+}
diff --git a/goTool/client_api.go b/goTool/client_api.go
index 4f22b4c..a1d5057 100644
--- a/goTool/client_api.go
+++ b/goTool/client_api.go
@@ -64,16 +64,14 @@ func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, erro
}
func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
- var resp *pb.BatteryStatusResponse
- err := m.withPort(func(sp *serialPort) error {
- var e error
- resp, e = sp.batteryStatus(req)
- return e
- })
- return resp, err
+ return m.batteryStatusExchange(false, req)
}
func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
+ return m.batteryStatusExchange(true, req)
+}
+
+func (m *managedSerial) batteryStatusExchange(poll bool, req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_STATUS,
Payload: &pb.UartMessage_BatteryStatusRequest{
@@ -85,23 +83,18 @@ func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.Bat
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...)
- respPayload, err := m.batteryStatusPayloadPoll(payload)
+ var respPayload []byte
+ if poll {
+ respPayload, err = m.exchangePayloadMode(true, payload, "BATTERY_STATUS", batteryReadTimeout)
+ } else {
+ respPayload, err = m.exchangePayloadMode(false, payload, "BATTERY_STATUS", batteryReadTimeout)
+ }
if err != nil {
return nil, err
}
return decodeBatteryStatusPayload(respPayload)
}
-func (m *managedSerial) batteryStatusPayloadPoll(payload []byte) ([]byte, error) {
- var resp []byte
- err := m.withPortPoll(func(sp *serialPort) error {
- var e error
- resp, e = sp.exchangePayloadForBattery(payload, "BATTERY_STATUS")
- return e
- })
- return resp, err
-}
-
func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_STATUS,
@@ -122,11 +115,11 @@ func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatterySta
}
func (m *managedSerial) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
- return m.accelStreamVia(m.withPort, req)
+ return m.accelStreamExchange(false, req)
}
func (m *managedSerial) AccelStreamPoll(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
- return m.accelStreamVia(m.withPortPoll, req)
+ return m.accelStreamExchange(true, req)
}
// SetAccelStream enables or disables the ESP-NOW accel stream for one slave (master UART).
@@ -154,24 +147,41 @@ func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
}
func (m *managedSerial) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
- return m.tapNotifyVia(m.withPort, req)
+ return m.tapNotifyExchange(false, req)
}
func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
- return m.tapNotifyVia(m.withPortPoll, req)
+ return m.tapNotifyExchange(true, req)
}
-func (m *managedSerial) tapNotifyVia(
- portFn func(func(*serialPort) error) error,
- req *pb.TapNotifyRequest,
-) (*pb.TapNotifyResponse, error) {
- var resp *pb.TapNotifyResponse
- err := portFn(func(sp *serialPort) error {
- var e error
- resp, e = sp.TapNotify(req)
- return e
- })
- return resp, err
+func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_TAP_NOTIFY,
+ Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
+ var respPayload []byte
+ if poll {
+ respPayload, err = m.exchangePayloadPoll(payload, "TAP_NOTIFY")
+ } else {
+ respPayload, err = m.exchangePayload(payload, "TAP_NOTIFY")
+ }
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetTapNotifyResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing tap_notify_response")
+ }
+ return r, nil
}
func (m *managedSerial) readCacheStatusPoll() (*pb.CacheStatusResponse, error) {
@@ -204,69 +214,138 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
return r, nil
}
-func (m *managedSerial) accelStreamVia(
- portFn func(func(*serialPort) error) error,
- req *pb.AccelStreamRequest,
-) (*pb.AccelStreamResponse, error) {
- var resp *pb.AccelStreamResponse
- err := portFn(func(sp *serialPort) error {
- var e error
- resp, e = sp.AccelStream(req)
- return e
- })
- return resp, err
+func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_ACCEL_STREAM,
+ Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
+ var respPayload []byte
+ if poll {
+ respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_STREAM")
+ } else {
+ respPayload, err = m.exchangePayload(payload, "ACCEL_STREAM")
+ }
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetAccelStreamResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing accel_stream_response")
+ }
+ return r, nil
}
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
- return m.accelDeadzoneVia(m.withPort, req)
+ return m.accelDeadzoneExchange(false, req)
}
func (m *managedSerial) AccelDeadzonePoll(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
- return m.accelDeadzoneVia(m.withPortPoll, req)
+ return m.accelDeadzoneExchange(true, req)
}
func (m *managedSerial) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
- return m.tapConfigVia(m.withPort, req)
+ return m.tapConfigExchange(false, req)
}
func (m *managedSerial) TapConfigPoll(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
- return m.tapConfigVia(m.withPortPoll, req)
+ return m.tapConfigExchange(true, req)
}
-func (m *managedSerial) tapConfigVia(
- portFn func(func(*serialPort) error) error,
- req *pb.TapConfigRequest,
-) (*pb.TapConfigResponse, error) {
- var resp *pb.TapConfigResponse
- err := portFn(func(sp *serialPort) error {
- var e error
- resp, e = sp.TapConfig(req)
- return e
- })
- return resp, err
+func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_TAP_CONFIG,
+ Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
+ var respPayload []byte
+ if poll {
+ respPayload, err = m.exchangePayloadPoll(payload, "TAP_CONFIG")
+ } else {
+ respPayload, err = m.exchangePayload(payload, "TAP_CONFIG")
+ }
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetTapConfigResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing tap_config_response")
+ }
+ return r, nil
}
-func (m *managedSerial) accelDeadzoneVia(
- portFn func(func(*serialPort) error) error,
- req *pb.AccelDeadzoneRequest,
-) (*pb.AccelDeadzoneResponse, error) {
- var resp *pb.AccelDeadzoneResponse
- err := portFn(func(sp *serialPort) error {
- var e error
- resp, e = sp.AccelDeadzone(req)
- return e
- })
- return resp, err
+func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_ACCEL_DEADZONE,
+ Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
+ var respPayload []byte
+ if poll {
+ respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_DEADZONE")
+ } else {
+ respPayload, err = m.exchangePayload(payload, "ACCEL_DEADZONE")
+ }
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetAccelDeadzoneResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing accel_deadzone_response")
+ }
+ return r, nil
}
func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
- var resp *pb.EspNowUnicastTestResponse
- err := m.withPort(func(sp *serialPort) error {
- var e error
- resp, e = sp.EspnowUnicastTest(clientID, seq)
- return e
- })
- return resp, err
+ req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_ESPNOW_UNICAST_TEST,
+ Payload: &pb.UartMessage_EspnowUnicastTestRequest{
+ EspnowUnicastTestRequest: req,
+ },
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
+ respPayload, err := m.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetEspnowUnicastTestResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing espnow_unicast_test_response")
+ }
+ return r, nil
}
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
@@ -520,26 +599,15 @@ func (s *serialPort) echoPing(clientID uint32) (*EchoPingResult, error) {
}
func (m *managedSerial) FindMe(clientID uint32) error {
- return m.withPort(func(sp *serialPort) error {
- return runFindMeClient(sp, clientID)
- })
+ return hostFindMe(m, clientID)
}
func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
- var resp *pb.SetLogLevelResponse
- err := m.withPort(func(sp *serialPort) error {
- var e error
- resp, e = runSetLogLevelClient(sp, write, level)
- return e
- })
- return resp, err
+ return hostSetLogLevel(m, write, level)
}
func (m *managedSerial) Restart(clientID uint32) error {
- err := m.withPort(func(sp *serialPort) error {
- return runRestartClient(sp, clientID)
- })
- if err != nil {
+ if err := hostRestart(m, clientID); err != nil {
return err
}
if clientID == 0 {
@@ -618,13 +686,7 @@ func (s *serialPort) EchoPing(clientID uint32) (*EchoPingResult, error) {
}
func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) {
- var result *EchoPingResult
- err := m.withPort(func(sp *serialPort) error {
- var e error
- result, e = sp.echoPing(clientID)
- return e
- })
- return result, err
+ return hostEchoPing(m, clientID)
}
func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
@@ -632,13 +694,7 @@ func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgres
}
func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
- var resp *pb.LedRingProgressResponse
- err := m.withPort(func(sp *serialPort) error {
- var e error
- resp, e = sp.LedRing(req)
- return e
- })
- return resp, err
+ return hostLedRing(m, req)
}
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
diff --git a/goTool/cmd_serve.go b/goTool/cmd_serve.go
index 6421987..2bc86cf 100644
--- a/goTool/cmd_serve.go
+++ b/goTool/cmd_serve.go
@@ -20,21 +20,48 @@ var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
-func runServe(portName string, baud int, args []string) error {
+type serveOpts struct {
+ portName string
+ baud int
+ useBLE bool
+ bleName string
+ settle time.Duration
+}
+
+func runServe(opts serveOpts, args []string) error {
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)")
accelInterval := serveFlags.Duration("accel-interval", defaultAccelStreamInterval, "accel WebSocket sample period on API server")
- interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval")
+ interval := serveFlags.Duration("interval", 2*time.Second, "host poll interval")
if err := serveFlags.Parse(args); err != nil {
return err
}
- if portName == "" {
- return fmt.Errorf("serve requires -port (master UART)")
+ if opts.useBLE && opts.portName != "" {
+ return fmt.Errorf("use either -port or -ble, not both")
}
- link := newManagedSerial(portName, baud)
+ link := newManagedHost(opts.baud)
link.quiet = true
+ link.settle = opts.settle
+ if opts.settle <= 0 {
+ link.settle = time.Second
+ }
+
+ switch {
+ case opts.portName != "":
+ if err := link.ConnectUART(opts.portName, opts.baud); err != nil {
+ log.Printf("initial UART connect failed (UI can retry): %v", err)
+ }
+ case opts.useBLE:
+ name := opts.bleName
+ if name == "" {
+ name = bleDefaultName
+ }
+ if err := link.ConnectBLE("", name); err != nil {
+ log.Printf("initial BLE connect failed (UI can scan/retry): %v", err)
+ }
+ }
hub := newWSHub()
streamCtl := newAccelStreamCtl()
@@ -48,16 +75,17 @@ func runServe(portName string, baud int, args []string) error {
shutdownHTTPServer(dashSrv)
shutdownAPIServer(apiSrv)
if err := link.Close(); err != nil {
- log.Printf("UART close: %v", err)
+ log.Printf("host close: %v", err)
}
})
- go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop)
+ go runPoller(link, link.Endpoint(), hub, streamCtl, tapCtl, *interval, stop)
go runBatteryPoller(link, hub, 5*time.Second, stop)
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
+ go runBLEStreamDashboardPoller(link, hub, streamCtl, stop)
if *apiAddr != "" {
- apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
+ apiSrv = runAPIServer(link.Endpoint(), link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
}
mux := http.NewServeMux()
@@ -85,10 +113,12 @@ func runServe(portName string, baud int, args []string) error {
}
mux.Handle("/", http.FileServer(http.FS(ui)))
- log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, live-stream %s, auto-reconnect)",
- *addr, portName, baud, interval.String(), accelInterval.String())
+ log.Printf("dashboard http://localhost%s (transport=%s endpoint=%q poll %s, live-stream %s)",
+ *addr, link.Transport(), link.Endpoint(), interval.String(), accelInterval.String())
if *apiAddr == "" {
log.Printf("external API disabled (-api-addr \"\")")
+ } else {
+ log.Printf("external API http://localhost%s ws://localhost%s/ws", *apiAddr, *apiAddr)
}
dashSrv = &http.Server{Addr: *addr, Handler: mux}
diff --git a/goTool/dashboard.go b/goTool/dashboard.go
index 0642545..4e86506 100644
--- a/goTool/dashboard.go
+++ b/goTool/dashboard.go
@@ -66,8 +66,12 @@ type ClientView struct {
}
type DashboardState struct {
- UpdatedAt string `json:"updated_at"`
- SerialPort string `json:"serial_port"`
+ UpdatedAt string `json:"updated_at"`
+ SerialPort string `json:"serial_port"`
+ /** Alias of host_endpoint for older UI. */
+ HostEndpoint string `json:"host_endpoint"`
+ HostTransport string `json:"host_transport"`
+ HostConnected bool `json:"host_connected"`
UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"`
@@ -530,20 +534,35 @@ func (h *wsHub) broadcastRaw(v any) {
}
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState {
+ ep := link.Endpoint()
+ if ep == "" {
+ ep = portName
+ }
+ tr := link.Transport()
st := DashboardState{
- UpdatedAt: time.Now().Format(time.RFC3339),
- SerialPort: portName,
- Clients: []ClientView{},
+ UpdatedAt: time.Now().Format(time.RFC3339),
+ SerialPort: ep,
+ HostEndpoint: ep,
+ HostTransport: tr,
+ Clients: []ClientView{},
+ }
+ if !link.IsConnected() && tr == hostTransportNone {
+ st.SerialError = "not connected — use UART or BLE connect"
+ return st
}
ver, err := link.getVersionPoll()
if errors.Is(err, errUARTBusy) {
- return pausedPollState(portName, last)
+ return pausedPollState(ep, last)
}
if err != nil {
- return disconnectedState(portName, err)
+ return disconnectedState(ep, err)
}
st.UARTConnected = true
+ st.HostConnected = true
+ st.HostTransport = link.Transport()
+ st.HostEndpoint = link.Endpoint()
+ st.SerialPort = st.HostEndpoint
st.SerialOK = true
st.Master = MasterView{
Version: ver.GetVersion(),
@@ -565,7 +584,8 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
}
st.SerialOK = false
st.SerialError = err.Error()
- st.UARTConnected = link.IsConnected()
+ st.HostConnected = link.IsConnected()
+ st.UARTConnected = st.HostConnected
return st
}
@@ -677,6 +697,10 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
if !hub.liveStreamEnabled() {
continue
}
+ // BLE live path prefers BLE_STREAM_TICK pushes.
+ if link.Transport() == hostTransportBLE {
+ continue
+ }
cache, err := link.readCacheStatusPoll()
if err != nil {
continue
@@ -687,6 +711,36 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
}
}
+// runBLEStreamDashboardPoller feeds live accel/tap from BLE_STREAM_TICK when live_stream is on.
+func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, stop <-chan struct{}) {
+ _ = streamCtl
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() {
+ select {
+ case <-stop:
+ return
+ case <-time.After(50 * time.Millisecond):
+ }
+ continue
+ }
+ payload, err := link.NextStreamTick(200 * time.Millisecond)
+ if err != nil {
+ continue
+ }
+ cache, err := cacheStatusFromBLEStreamPayload(payload)
+ if err != nil {
+ continue
+ }
+ hub.mergeAccel(accelSamplesFromCacheStatus(cache))
+ hub.mergeTap(tapEventsFromCacheStatus(cache))
+ }
+}
+
func (h *wsHub) clientCount() int {
h.mu.RLock()
n := len(h.clients)
@@ -695,11 +749,14 @@ func (h *wsHub) clientCount() int {
}
func pausedPollState(portName string, last *DashboardState) DashboardState {
- if last != nil && last.UARTConnected {
+ if last != nil && (last.HostConnected || last.UARTConnected) {
st := *last
st.UpdatedAt = time.Now().Format(time.RFC3339)
st.SerialPort = portName
+ st.HostEndpoint = portName
st.SerialOK = true
+ st.HostConnected = true
+ st.UARTConnected = true
st.SerialError = "Live-Polling pausiert (OTA läuft)"
return st
}
@@ -774,20 +831,20 @@ func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *acce
ticker := time.NewTicker(interval)
defer ticker.Stop()
- uartUp := false
+ hostUp := false
var lastGood DashboardState
publish := func() {
st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl)
hub.setState(st)
- if st.UARTConnected && st.SerialOK {
+ if st.HostConnected && st.SerialOK {
hub.mu.RLock()
lastGood = hub.state
hub.mu.RUnlock()
}
- if st.UARTConnected && !uartUp {
- log.Printf("UART %s connected", portName)
+ if st.HostConnected && !hostUp {
+ log.Printf("host %s connected (%s)", st.HostEndpoint, st.HostTransport)
}
- uartUp = st.UARTConnected
+ hostUp = st.HostConnected
}
publish()
diff --git a/goTool/docs/API_REST.md b/goTool/docs/API_REST.md
index 1a0c08c..f3548a7 100644
--- a/goTool/docs/API_REST.md
+++ b/goTool/docs/API_REST.md
@@ -1,6 +1,6 @@
# REST API
-`go run . -port /dev/ttyUSB0 serve` starts two HTTP servers on the same UART link:
+`go run . serve` (optional `-port` / `-ble`) starts two HTTP servers on the same `managedHost` (UART or BLE):
| Base URL | Flag | Used by |
|----------|------|---------|
@@ -27,6 +27,9 @@ GET /api/v1/
"name": "powerpod-external-api",
"version": "1",
"serial_port": "/dev/ttyUSB0",
+ "transport": "uart",
+ "endpoint": "/dev/ttyUSB0",
+ "connected": true,
"websocket": "/ws",
"default_interval_ms": 16,
"min_interval_ms": 1,
@@ -110,7 +113,34 @@ WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"` →
Used by the web UI; safe for scripts that drive the same features.
-### Live stream (host `CACHE_STATUS` poll ~16 ms)
+### Host link (UART / BLE)
+
+```http
+GET /api/host
+→ {"transport":"none|uart|ble","endpoint":"…","connected":false,"ble_name":""}
+
+GET /api/serial/ports
+→ {"ports":["/dev/ttyUSB0",…]}
+
+POST /api/host/connect
+Content-Type: application/json
+{"transport":"uart","port":"/dev/ttyUSB0"}
+{"transport":"ble","address":"AA:BB:…","name":"Powerpod"}
+
+POST /api/host/disconnect
+
+POST /api/ble/scan
+{"timeout_s":5,"name_filter":"Powerpod"}
+→ {"devices":[{"address":"…","name":"Powerpod","rssi":-55}]}
+```
+
+`name_filter` is optional substring match (case-insensitive); omit to list all LE advertisements. Active BLE is disconnected before scan (BlueZ adapter).
+
+Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
+
+**OTA** (`POST /api/ota`) returns **400** unless `transport==uart` and connected.
+
+### Live stream (host `CACHE_STATUS` poll ~16 ms; BLE uses `BLE_STREAM_TICK`)
```http
GET /api/live-stream
diff --git a/goTool/docs/API_WEBSOCKET.md b/goTool/docs/API_WEBSOCKET.md
index 6718886..4a25dfc 100644
--- a/goTool/docs/API_WEBSOCKET.md
+++ b/goTool/docs/API_WEBSOCKET.md
@@ -2,15 +2,16 @@
External API: `ws://localhost:8081/ws` (default `-api-addr`, disable with empty string).
-Start with `go run . -port /dev/ttyUSB0 serve`.
+Start with `go run . serve` (optional `-port` / `-ble` to pre-connect). Host can be opened later via `host_connect` / dashboard REST.
---
## Connection flow
-1. Connect → server sends `hello` (push off; defaults and command list).
-2. Send JSON commands → one reply per message (`*_status` or `client_list`).
-3. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
+1. Connect → server sends `hello` (host status, push off; defaults and command list).
+2. Optionally `ble_scan` / `list_serial_ports` then `host_connect` if not already linked.
+3. Send JSON commands → one reply per message (`*_status`, `client_list`, `ble_scan_result`, …).
+4. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
Commands and pushes share one socket — always branch on `type`.
@@ -27,7 +28,9 @@ On disconnect, `set_stream` state for that socket is dropped. Firmware settings
| This connection | `set_stream` | Whether you receive push JSON on this socket |
-UART polling runs only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
+Host polling / BLE ticks run only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
+
+On **BLE**, live `input` prefers firmware `BLE_STREAM_TICK` pushes; otherwise falls back to `CACHE_STATUS` like UART.
### Push timing (per connection)
@@ -38,7 +41,7 @@ UART polling runs only when at least one connection has `receive_input: true` **
| `pre_fetch` | `set_stream`, `stream_status` | Ms before each push when the host starts the UART cache read |
-Global UART poll interval = minimum `interval_ms` among all connections with push enabled.
+Global host poll interval = minimum `interval_ms` among all connections with push enabled.
Typical sequence:
@@ -115,10 +118,14 @@ One JSON object per message; field `type` selects the command.
{
"type": "hello",
"serial_port": "/dev/ttyUSB0",
+ "transport": "uart",
+ "endpoint": "/dev/ttyUSB0",
+ "connected": true,
"interval_ms": 16,
"pre_fetch_ms": 2,
"tap_display_min_ms": 2000,
"commands": [
+ "get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
"list_clients",
"set_stream", "get_stream",
"set_input_stream", "get_input_stream",
@@ -128,6 +135,23 @@ One JSON object per message; field `type` selects the command.
}
```
+### Host link
+
+| Client `type` | Reply `type` | Notes |
+|---------------|--------------|--------|
+| `get_host` | `host_status` | `transport`, `endpoint`, `connected` |
+| `list_serial_ports` | `serial_ports` | `{ports:[…]}` |
+| `ble_scan` | `ble_scan_result` | optional `timeout_s`, `name_filter`; disconnects active BLE first |
+| `host_connect` | `host_status` | `transport:"uart","port"` or `transport:"ble","address","name?"` |
+| `host_disconnect` | `host_status` | |
+
+```json
+{"type":"host_connect","transport":"ble","address":"AA:BB:CC:DD:EE:FF","name":"Powerpod"}
+{"type":"host_status","transport":"ble","endpoint":"AA:BB:…","connected":true,"success":true}
+```
+
+If the host is not connected, other commands that need the master return their usual error in the status reply.
+
### `list_clients`
Request: `{"type":"list_clients"}`
diff --git a/goTool/host_conn.go b/goTool/host_conn.go
index e5e3b3b..9747ab1 100644
--- a/goTool/host_conn.go
+++ b/goTool/host_conn.go
@@ -73,6 +73,120 @@ func hostAccelStream(h hostConn, req *pb.AccelStreamRequest) (*pb.AccelStreamRes
return r, nil
}
+func hostFindMe(h hostConn, clientID uint32) error {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_FIND_ME,
+ Payload: &pb.UartMessage_EspnowFindMeRequest{
+ EspnowFindMeRequest: &pb.EspNowFindMeRequest{ClientId: clientID},
+ },
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_FIND_ME)}, body...)
+ respPayload, err := h.exchangePayload(payload, "FIND_ME")
+ if err != nil {
+ return err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetEspnowFindMeResponse()
+ if r == nil {
+ return fmt.Errorf("missing espnow_find_me_response")
+ }
+ if !r.GetSuccess() {
+ return fmt.Errorf("find-me rejected (client_id=%d)", r.GetClientId())
+ }
+ return nil
+}
+
+func hostRestart(h hostConn, clientID uint32) error {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_RESTART,
+ Payload: &pb.UartMessage_RestartRequest{
+ RestartRequest: &pb.RestartRequest{ClientId: clientID},
+ },
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_RESTART)}, body...)
+ respPayload, err := h.exchangePayload(payload, "RESTART")
+ if err != nil {
+ return err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetRestartResponse()
+ if r == nil {
+ return fmt.Errorf("missing restart_response")
+ }
+ if !r.GetSuccess() {
+ return fmt.Errorf("restart rejected (client_id=%d)", r.GetClientId())
+ }
+ return nil
+}
+
+func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_SET_LOG_LEVEL,
+ Payload: &pb.UartMessage_SetLogLevelRequest{
+ SetLogLevelRequest: &pb.SetLogLevelRequest{Write: write, Level: level},
+ },
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
+ respPayload, err := h.exchangePayload(payload, "SET_LOG_LEVEL")
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetSetLogLevelResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing set_log_level_response")
+ }
+ return r, nil
+}
+
+func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
+ msg := &pb.UartMessage{
+ Type: pb.MessageType_LED_RING,
+ Payload: &pb.UartMessage_LedRingProgressRequest{
+ LedRingProgressRequest: req,
+ },
+ }
+ body, err := proto.Marshal(msg)
+ if err != nil {
+ return nil, fmt.Errorf("encode: %w", err)
+ }
+ payload := append([]byte{byte(pb.MessageType_LED_RING)}, body...)
+ respPayload, err := h.exchangePayload(payload, "LED_RING")
+ if err != nil {
+ return nil, err
+ }
+ var respMsg pb.UartMessage
+ if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
+ return nil, fmt.Errorf("decode: %w", err)
+ }
+ r := respMsg.GetLedRingProgressResponse()
+ if r == nil {
+ return nil, fmt.Errorf("missing led_ring_progress_response")
+ }
+ return r, nil
+}
+
func hostEchoPing(h hostConn, clientID uint32) (*EchoPingResult, error) {
t0 := time.Now()
timestampUs := uint64(t0.UnixMicro())
diff --git a/goTool/host_managed.go b/goTool/host_managed.go
new file mode 100644
index 0000000..b13c8b4
--- /dev/null
+++ b/goTool/host_managed.go
@@ -0,0 +1,468 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "sync"
+ "time"
+
+ "go.bug.st/serial"
+ "tinygo.org/x/bluetooth"
+)
+
+const (
+ hostTransportNone = "none"
+ hostTransportUART = "uart"
+ hostTransportBLE = "ble"
+)
+
+// errHostBusy is returned when the link is held for OTA (UART only).
+var errHostBusy = errUARTBusy
+
+// managedHost is a reconnecting UART or BLE host link for serve/CLI.
+type managedHost struct {
+ quiet bool
+ baud int
+ settle time.Duration
+
+ mu sync.Mutex
+ transport string // none|uart|ble
+ endpoint string // port path or BLE address string
+ bleName string // last connected BLE local name (optional)
+ sp *serialPort
+ ble *bleLink
+ otaActive bool
+ uartPort string // remembered UART port for reconnect
+}
+
+func newManagedHost(baud int) *managedHost {
+ if baud <= 0 {
+ baud = defaultBaud
+ }
+ return &managedHost{
+ baud: baud,
+ transport: hostTransportNone,
+ settle: time.Second,
+ }
+}
+
+// newManagedSerial keeps the old constructor name for call sites during migrate.
+func newManagedSerial(portName string, baud int) *managedHost {
+ m := newManagedHost(baud)
+ if portName != "" {
+ m.uartPort = portName
+ m.endpoint = portName
+ m.transport = hostTransportUART
+ }
+ return m
+}
+
+func (m *managedHost) Transport() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.transport == "" {
+ return hostTransportNone
+ }
+ return m.transport
+}
+
+func (m *managedHost) Endpoint() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.endpoint
+}
+
+func (m *managedHost) Close() error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.disconnectLocked()
+}
+
+func (m *managedHost) IsConnected() bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ switch m.transport {
+ case hostTransportUART:
+ return m.sp != nil
+ case hostTransportBLE:
+ return m.ble != nil
+ default:
+ return false
+ }
+}
+
+func (m *managedHost) disconnectLocked() error {
+ var err error
+ if m.sp != nil {
+ err = m.sp.port.Close()
+ m.sp = nil
+ }
+ if m.ble != nil {
+ e := m.ble.Close()
+ if err == nil {
+ err = e
+ }
+ m.ble = nil
+ }
+ m.transport = hostTransportNone
+ return err
+}
+
+func (m *managedHost) Disconnect() error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ ep := m.endpoint
+ err := m.disconnectLocked()
+ if !m.quiet && ep != "" {
+ log.Printf("host disconnected (%s)", ep)
+ }
+ return err
+}
+
+func (m *managedHost) ConnectUART(port string, baud int) error {
+ if port == "" {
+ return fmt.Errorf("UART port required")
+ }
+ if baud <= 0 {
+ baud = m.baud
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.otaActive {
+ return errHostBusy
+ }
+ _ = m.disconnectLocked()
+ m.baud = baud
+ m.uartPort = port
+ m.endpoint = port
+ m.transport = hostTransportUART
+ if err := m.openUARTLocked(); err != nil {
+ m.transport = hostTransportNone
+ m.endpoint = ""
+ return err
+ }
+ return nil
+}
+
+func (m *managedHost) ConnectBLE(address string, name string) error {
+ if address == "" && name == "" {
+ return fmt.Errorf("BLE address or name required")
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.otaActive {
+ return errHostBusy
+ }
+ _ = m.disconnectLocked()
+
+ var link *bleLink
+ var err error
+ settle := m.settle
+ m.mu.Unlock()
+ if address != "" {
+ link, err = openBLEAddress(address, settle)
+ } else {
+ link, err = openBLE(name, settle)
+ }
+ m.mu.Lock()
+ if err != nil {
+ return err
+ }
+ link.quiet = m.quiet
+ m.ble = link
+ m.transport = hostTransportBLE
+ if address != "" {
+ m.endpoint = address
+ } else {
+ m.endpoint = name
+ }
+ m.bleName = name
+ if !m.quiet {
+ log.Printf("BLE host connected (%s)", m.endpoint)
+ }
+ return nil
+}
+
+func (m *managedHost) openUARTLocked() error {
+ sp, err := openSerial(m.uartPort, 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.uartPort, m.baud)
+ }
+ return nil
+}
+
+func (m *managedHost) openLocked() error {
+ return m.openUARTLocked()
+}
+
+func (m *managedHost) closeLocked() error {
+ return m.disconnectLocked()
+}
+
+func (m *managedHost) invalidateLocked(reason error) {
+ if m.sp == nil && m.ble == nil {
+ return
+ }
+ if !m.quiet {
+ log.Printf("host %s disconnected: %v", m.endpoint, reason)
+ }
+ _ = m.disconnectLocked()
+ // remember transport intent for UART reconnect
+ if m.uartPort != "" && m.transport == hostTransportNone {
+ // after invalidate, leave as none; poller will try Connect again via open
+ m.transport = hostTransportUART
+ m.endpoint = m.uartPort
+ }
+}
+
+func (m *managedHost) withPort(fn func(*serialPort) error) error {
+ return m.withPortLocked(false, fn)
+}
+
+func (m *managedHost) withPortPoll(fn func(*serialPort) error) error {
+ return m.withPortLocked(true, fn)
+}
+
+func (m *managedHost) withPortLocked(poll bool, fn func(*serialPort) error) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.otaActive {
+ return errHostBusy
+ }
+ if m.transport == hostTransportBLE {
+ return fmt.Errorf("operation requires UART host (DIP8=0)")
+ }
+ if m.transport != hostTransportUART && m.uartPort == "" {
+ return fmt.Errorf("not connected")
+ }
+ if m.transport == hostTransportNone && m.uartPort != "" {
+ m.transport = hostTransportUART
+ m.endpoint = m.uartPort
+ }
+ if m.sp == nil {
+ if err := m.openUARTLocked(); err != nil {
+ return fmt.Errorf("%s: %w", m.uartPort, err)
+ }
+ }
+ err := fn(m.sp)
+ if err != nil {
+ m.invalidateLocked(err)
+ }
+ return err
+}
+
+func (m *managedHost) recoverAfterMasterRestart() {
+ const bootWait = 6 * time.Second
+ m.mu.Lock()
+ tr := m.transport
+ m.disconnectLocked()
+ if tr == hostTransportUART {
+ m.transport = hostTransportUART
+ m.endpoint = m.uartPort
+ }
+ m.mu.Unlock()
+
+ log.Printf("host: master restart — waiting %s for boot", bootWait)
+ time.Sleep(bootWait)
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.transport == hostTransportUART {
+ if err := m.openUARTLocked(); err != nil {
+ log.Printf("UART reconnect after master restart: %v", err)
+ return
+ }
+ flushSerialInput(m.sp)
+ log.Printf("UART %s ready after master restart", m.uartPort)
+ }
+}
+
+func (m *managedHost) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
+ return m.exchangePayloadMode(false, payload, cmdName, readTimeout)
+}
+
+func (m *managedHost) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
+ return m.exchangePayloadMode(true, payload, cmdName, readTimeout)
+}
+
+func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.otaActive {
+ return nil, errHostBusy
+ }
+ switch m.transport {
+ case hostTransportBLE:
+ if m.ble == nil {
+ return nil, fmt.Errorf("BLE not connected")
+ }
+ resp, err := m.ble.exchangePayload(payload, cmdName)
+ if err != nil {
+ m.invalidateLocked(err)
+ }
+ return resp, err
+ case hostTransportUART, hostTransportNone:
+ if m.uartPort == "" && m.sp == nil {
+ return nil, fmt.Errorf("not connected")
+ }
+ if m.transport == hostTransportNone {
+ m.transport = hostTransportUART
+ m.endpoint = m.uartPort
+ }
+ if m.sp == nil {
+ if err := m.openUARTLocked(); err != nil {
+ return nil, fmt.Errorf("%s: %w", m.uartPort, err)
+ }
+ }
+ resp, err := m.sp.exchangePayloadLocked(payload, cmdName, timeout)
+ if err != nil {
+ m.invalidateLocked(err)
+ }
+ return resp, err
+ default:
+ return nil, fmt.Errorf("not connected")
+ }
+}
+
+func (m *managedHost) exchange(cmdID byte, cmdName string) ([]byte, error) {
+ return m.exchangePayload([]byte{cmdID}, cmdName)
+}
+
+func (m *managedHost) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
+ return m.exchangePayloadPoll([]byte{cmdID}, cmdName)
+}
+
+func (m *managedHost) exchangePayloadVia(
+ portFn func(func(*serialPort) error) error,
+ payload []byte, cmdName string,
+) ([]byte, error) {
+ // Prefer transport-aware path (works for BLE too).
+ _ = portFn
+ return m.exchangePayload(payload, cmdName)
+}
+
+func (m *managedHost) exchangeVia(
+ portFn func(func(*serialPort) error) error,
+ cmdID byte, cmdName string,
+) ([]byte, error) {
+ _ = portFn
+ return m.exchange(cmdID, cmdName)
+}
+
+func (m *managedHost) NextStreamTick(timeout time.Duration) ([]byte, error) {
+ m.mu.Lock()
+ ble := m.ble
+ tr := m.transport
+ m.mu.Unlock()
+ if tr != hostTransportBLE || ble == nil {
+ return nil, fmt.Errorf("BLE stream ticks require BLE host")
+ }
+ return ble.NextStreamTick(timeout)
+}
+
+func (m *managedHost) HostStatus() map[string]any {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ connected := false
+ switch m.transport {
+ case hostTransportUART:
+ connected = m.sp != nil
+ case hostTransportBLE:
+ connected = m.ble != nil
+ }
+ return map[string]any{
+ "transport": m.transportOrNone(),
+ "endpoint": m.endpoint,
+ "connected": connected,
+ "ble_name": m.bleName,
+ }
+}
+
+func (m *managedHost) transportOrNone() string {
+ if m.transport == "" {
+ return hostTransportNone
+ }
+ return m.transport
+}
+
+func listSerialPorts() ([]string, error) {
+ ports, err := serial.GetPortsList()
+ if err != nil {
+ return nil, err
+ }
+ if ports == nil {
+ return []string{}, nil
+ }
+ return ports, nil
+}
+
+type bleScanDevice struct {
+ Address string `json:"address"`
+ Name string `json:"name"`
+ RSSI int `json:"rssi"`
+}
+
+func scanBLEDevices(timeout time.Duration, nameFilter string) ([]bleScanDevice, error) {
+ if timeout <= 0 {
+ timeout = 5 * time.Second
+ }
+ adapter := bluetooth.DefaultAdapter
+ if err := adapter.Enable(); err != nil {
+ return nil, fmt.Errorf("enable bluetooth: %w", err)
+ }
+
+ seen := map[string]bleScanDevice{}
+ var mu sync.Mutex
+ go func() {
+ time.Sleep(timeout)
+ _ = adapter.StopScan()
+ }()
+ filter := strings.TrimSpace(nameFilter)
+ _ = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
+ name := result.LocalName()
+ if filter != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(filter)) {
+ return
+ }
+ addr := result.Address.String()
+ mu.Lock()
+ seen[addr] = bleScanDevice{
+ Address: addr,
+ Name: name,
+ RSSI: int(result.RSSI),
+ }
+ mu.Unlock()
+ })
+ // StopScan ends Scan; ignore error and return whatever was found.
+ mu.Lock()
+ defer mu.Unlock()
+ out := make([]bleScanDevice, 0, len(seen))
+ for _, d := range seen {
+ out = append(out, d)
+ }
+ return out, nil
+}
+
+func disconnectedState(portName string, err error) DashboardState {
+ msg := "host disconnected"
+ if err != nil {
+ msg = err.Error()
+ }
+ return DashboardState{
+ UpdatedAt: time.Now().Format(time.RFC3339),
+ SerialPort: portName,
+ HostEndpoint: portName,
+ UARTConnected: false,
+ HostConnected: false,
+ HostTransport: hostTransportNone,
+ SerialOK: false,
+ SerialError: msg,
+ Master: MasterView{OK: false, Error: msg},
+ Clients: []ClientView{},
+ }
+}
diff --git a/goTool/main.go b/goTool/main.go
index 51ff699..a611c19 100644
--- a/goTool/main.go
+++ b/goTool/main.go
@@ -27,7 +27,7 @@ func usage() {
fmt.Fprintf(os.Stderr, " ble-rtt Host↔Master RTT via VERSION (N samples)\n")
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\n")
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
- fmt.Fprintf(os.Stderr, " serve web dashboard (UART only)\n")
+ fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions; UART host only)\n")
fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n")
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
@@ -61,16 +61,17 @@ func main() {
}
runErr = runTest(*portName, *baud, flag.Args()[1:])
case "serve", "web", "dashboard":
- if *useBLE {
- fmt.Fprintf(os.Stderr, "serve requires -port (UART only in this release)\n")
+ if *useBLE && *portName != "" {
+ fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
os.Exit(2)
}
- if *portName == "" {
- fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
- usage()
- os.Exit(2)
- }
- runErr = runServe(*portName, *baud, flag.Args()[1:])
+ runErr = runServe(serveOpts{
+ portName: *portName,
+ baud: *baud,
+ useBLE: *useBLE,
+ bleName: *bleName,
+ settle: time.Duration(*bleSettle * float64(time.Second)),
+ }, flag.Args()[1:])
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "ota", "ota-progress", "ota_progress":
if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
diff --git a/goTool/serial_link.go b/goTool/serial_link.go
index 1799a46..bf5bc72 100644
--- a/goTool/serial_link.go
+++ b/goTool/serial_link.go
@@ -1,12 +1,6 @@
package main
-import (
- "errors"
- "fmt"
- "log"
- "sync"
- "time"
-)
+import "errors"
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug).
var errUARTBusy = errors.New("uart busy (OTA in progress)")
@@ -14,171 +8,5 @@ var errUARTBusy = errors.New("uart busy (OTA in progress)")
// errOTAInProgress is returned when a second OTA upload is attempted while one is running.
var errOTAInProgress = errors.New("OTA upload already in progress")
-// 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
- otaActive bool // UART held for firmware upload; poll/API must not interleave
-}
-
-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 {
- return m.withPortLocked(false, fn)
-}
-
-// withPortPoll is like withPort but returns errUARTBusy during OTA (no TryLock race).
-func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error {
- return m.withPortLocked(true, fn)
-}
-
-func (m *managedSerial) withPortLocked(poll bool, fn func(*serialPort) error) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.otaActive {
- return errUARTBusy
- }
-
- 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) recoverAfterMasterRestart() {
- const bootWait = 6 * time.Second
-
- m.mu.Lock()
- m.closeLocked()
- m.mu.Unlock()
-
- log.Printf("UART: master restart — waiting %s for boot", bootWait)
- time.Sleep(bootWait)
-
- m.mu.Lock()
- defer m.mu.Unlock()
- if err := m.openLocked(); err != nil {
- log.Printf("UART reconnect after master restart: %v", err)
- return
- }
- flushSerialInput(m.sp)
- log.Printf("UART %s ready after master restart", m.portName)
-}
-
-func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
- return m.exchangePayloadVia(m.withPort, payload, cmdName)
-}
-
-func (m *managedSerial) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
- return m.exchangePayloadVia(m.withPortPoll, payload, cmdName)
-}
-
-func (m *managedSerial) exchangePayloadVia(
- portFn func(func(*serialPort) error) error,
- payload []byte, cmdName string,
-) ([]byte, error) {
- var resp []byte
- err := portFn(func(sp *serialPort) error {
- var e error
- resp, e = sp.exchangePayloadLocked(payload, cmdName, readTimeout)
- return e
- })
- return resp, err
-}
-
-func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) {
- return m.exchangeVia(m.withPort, cmdID, cmdName)
-}
-
-func (m *managedSerial) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
- return m.exchangeVia(m.withPortPoll, cmdID, cmdName)
-}
-
-func (m *managedSerial) exchangeVia(
- portFn func(func(*serialPort) error) error,
- cmdID byte, cmdName string,
-) ([]byte, error) {
- var resp []byte
- err := portFn(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{},
- }
-}
+// managedSerial is an alias for managedHost (UART-first history).
+type managedSerial = managedHost
diff --git a/goTool/webui/index.html b/goTool/webui/index.html
index 4e345cd..0752ff4 100644
--- a/goTool/webui/index.html
+++ b/goTool/webui/index.html
@@ -179,8 +179,8 @@
+ :class="hostUp() ? 'badge-online' : 'badge-offline'"
+ x-text="hostBadge()">
@@ -195,19 +195,94 @@
x-text="configMsg">
+
+ Scan startet eine kurze Suche (trennt aktive BLE-Verbindung).
+
+
+
+
+
+
+
+ Name Adresse RSSI
+
+
+
+
+
+
+
+
+
+
Deadzone nur am Master — Slaves bleiben unverändert.
- + + x-text="state.serial_error || 'Host nicht verbunden — oben UART oder BLE wählen.'">- UART nicht verbunden — Eingabe gesperrt. +
+ Host nicht verbunden — Eingabe gesperrt.
@@ -373,12 +448,12 @@ :disabled="busy"> @@ -395,7 +470,7 @@ class="btn btn-sm" :class="state.live_stream ? 'btn-warning' : 'btn-success'" @click="setLiveStream(!state.live_stream)" - :disabled="busy || !state.uart_connected || !(state.clients || []).length" + :disabled="busy || !hostUp() || !(state.clients || []).length" x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"> Tap alle Slaves: @@ -403,7 +478,7 @@ @@ -445,7 +520,7 @@ @@ -462,7 +537,7 @@ class="btn btn-sm" :class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'" @click="setAccelStream(c.id, !c.accel_stream)" - :disabled="busy || !state.uart_connected || !c.available" + :disabled="busy || !hostUp() || !c.available" x-text="c.accel_stream ? 'Aus' : 'An'" title="ESP-NOW Accel-Stream auf Slave"> @@ -472,19 +547,19 @@ S + :disabled="busy || !hostUp() || !c.available"> S @@ -500,36 +575,36 @@ :disabled="busy"> @@ -602,17 +677,17 @@
- Lädt eine .bin auf den Master (UART), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
- Während des Uploads pausiert das Live-Polling.
+ Lädt eine .bin auf den Master (nur UART-Host, DIP8=0), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
+ Während des Uploads pausiert das Live-Polling. Bei BLE-Host ist OTA nicht verfügbar.