Add dynamic UART/BLE host connect for serve, dashboard, and WS.

Serve starts without a required port; REST/WS/UI can scan and switch transports, with BLE live ticks and UART-only OTA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 22:40:28 +02:00
co-authored by Cursor
parent 92a13d7fd0
commit aa67edfe94
18 changed files with 1573 additions and 404 deletions
+1 -1
View File
@@ -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).
--- ---
+1 -1
View File
@@ -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 1 | 1 | `board_input.c` |
| LiPo ADC 2 | 11 | `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. **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.
+8 -6
View File
@@ -61,16 +61,18 @@ See [`testdata/README.md`](testdata/README.md) for the JSON schema.
### Web dashboard ### 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 ```bash
go run . serve
go run . -port /dev/ttyUSB0 serve go run . -port /dev/ttyUSB0 serve
go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s go run . -ble serve
go run . -port /dev/ttyUSB0 serve -api-addr :8081 -accel-interval 16ms go run . serve -addr :8080 -interval 2s
go run . serve -api-addr :8081 -accel-interval 16ms
make gotool-serve PORT=/dev/ttyUSB0 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: **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`). 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 ### 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 | | Doc | Content |
|-----|---------| |-----|---------|
+140
View File
@@ -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})
})
}
+5
View File
@@ -93,6 +93,7 @@ type otaAPIResponse struct {
} }
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) { func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
mountHostAPI(mux, link)
mountLiveStreamAPI(mux, hub) mountLiveStreamAPI(mux, hub)
mountAccelStreamAPI(mux, link, hub, streamCtl) mountAccelStreamAPI(mux, link, hub, streamCtl)
mountTapAPI(mux, link, hub, tapCtl) 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) { 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 { if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"}) writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
return return
+160 -8
View File
@@ -39,6 +39,9 @@ type InputClientSample struct {
type InputStreamMessage struct { type InputStreamMessage struct {
Type string `json:"type"` // "hello" | "input" Type string `json:"type"` // "hello" | "input"
Serial string `json:"serial_port,omitempty"` 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"` IntervalMs int `json:"interval_ms,omitempty"`
PreFetchMs int `json:"pre_fetch_ms,omitempty"` PreFetchMs int `json:"pre_fetch_ms,omitempty"`
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"` TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
@@ -51,6 +54,33 @@ type InputStreamMessage struct {
Error string `json:"error,omitempty"` 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). // StreamStatusMessage is the reply to set_stream / get_stream (this connection).
type StreamStatusMessage struct { type StreamStatusMessage struct {
Type string `json:"type"` // "stream_status" Type string `json:"type"` // "stream_status"
@@ -116,12 +146,23 @@ type accelWSCommand struct {
DoubleTap *bool `json:"double_tap"` DoubleTap *bool `json:"double_tap"`
Triple *bool `json:"triple"` Triple *bool `json:"triple"`
AllClients bool `json:"all_clients"` 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 { type APIInfoResponse struct {
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
SerialPort string `json:"serial_port"` SerialPort string `json:"serial_port"`
Transport string `json:"transport"`
Endpoint string `json:"endpoint"`
Connected bool `json:"connected"`
WebSocket string `json:"websocket"` WebSocket string `json:"websocket"`
DefaultIntervalMs int `json:"default_interval_ms"` DefaultIntervalMs int `json:"default_interval_ms"`
DefaultPreFetchMs int `json:"default_pre_fetch_ms"` DefaultPreFetchMs int `json:"default_pre_fetch_ms"`
@@ -196,7 +237,7 @@ func clampPreFetch(d time.Duration) time.Duration {
return d return d
} }
func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber { func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *wsSubscriber {
sub := &wsSubscriber{ sub := &wsSubscriber{
conn: conn, conn: conn,
receiveInput: false, receiveInput: false,
@@ -207,14 +248,20 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
h.clients[conn] = sub h.clients[conn] = sub
h.mu.Unlock() h.mu.Unlock()
st := link.HostStatus()
connected := st["connected"].(bool)
hello := InputStreamMessage{ hello := InputStreamMessage{
Type: "hello", Type: "hello",
Serial: portName, Serial: st["endpoint"].(string),
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: &connected,
IntervalMs: int(h.defaultInterval / time.Millisecond), IntervalMs: int(h.defaultInterval / time.Millisecond),
PreFetchMs: int(h.defaultPreFetch / time.Millisecond), PreFetchMs: int(h.defaultPreFetch / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs, TapDisplayMinMs: apiTapDisplayMinMs,
Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection", Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
Commands: []string{ Commands: []string{
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
"list_clients", "list_clients",
"set_stream", "get_stream", "set_stream", "get_stream",
"set_input_stream", "get_input_stream", "set_input_stream", "get_input_stream",
@@ -454,6 +501,29 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
continue 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) needRead, needDeliver, waitPreFetch := hub.streamTiming(now)
if needRead && pending == nil { if needRead && pending == nil {
@@ -634,6 +704,58 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
} }
switch cmd.Type { 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": case "list_clients":
clients, err := link.listClientsPoll() clients, err := link.listClientsPoll()
if err != nil { if err != nil {
@@ -791,13 +913,38 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
default: default:
writeStreamStatus(conn, StreamStatusMessage{ writeStreamStatus(conn, StreamStatusMessage{
Type: "stream_status", 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) { func hostStatusFromLink(link *managedSerial, success bool, errMsg string) HostStatusMessage {
sub := hub.register(conn, portName) 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 hub.unregister(conn)
defer conn.Close() 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) { 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) defMs := int(defaultInterval / time.Millisecond)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" { 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) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
st := link.HostStatus()
writeJSON(w, http.StatusOK, APIInfoResponse{ writeJSON(w, http.StatusOK, APIInfoResponse{
Name: "powerpod-external-api", Name: "powerpod-external-api",
Version: "1", Version: "1",
SerialPort: portName, SerialPort: st["endpoint"].(string),
Transport: st["transport"].(string),
Endpoint: st["endpoint"].(string),
Connected: st["connected"].(bool),
WebSocket: "/ws", WebSocket: "/ws",
DefaultIntervalMs: defMs, DefaultIntervalMs: defMs,
DefaultPreFetchMs: defaultPreFetchMs, DefaultPreFetchMs: defaultPreFetchMs,
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond), MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond), MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
TapDisplayMinMs: apiTapDisplayMinMs, 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) log.Printf("api websocket upgrade: %v", err)
return return
} }
serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub) serveExternalWS(conn, link, dash, ctl, tapCtl, hub)
}) })
} }
+51 -12
View File
@@ -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) 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) log.Printf("BLE scanning for %q…", name)
var foundAddr bluetooth.Address var foundAddr bluetooth.Address
var found bool var found bool
@@ -53,7 +44,7 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
time.Sleep(15 * time.Second) time.Sleep(15 * time.Second)
_ = adapter.StopScan() _ = 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 { if result.LocalName() != name {
return return
} }
@@ -67,9 +58,57 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
if !found { if !found {
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name) return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
} }
return connectBLEDevice(foundAddr, settle)
}
log.Printf("BLE connecting…") func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
device, err := adapter.Connect(foundAddr, bluetooth.ConnectionParams{}) 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 { if err != nil {
return nil, fmt.Errorf("connect: %w", err) return nil, fmt.Errorf("connect: %w", err)
} }
+27 -1
View File
@@ -1,6 +1,12 @@
package main package main
import "powerpod/gotool/pb" import (
"fmt"
"powerpod/gotool/pb"
"google.golang.org/protobuf/proto"
)
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample // accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
// (for dashboard / WebSocket accel push). // (for dashboard / WebSocket accel push).
@@ -47,3 +53,23 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
} }
return out 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
}
+161 -105
View File
@@ -64,16 +64,14 @@ func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, erro
} }
func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) { func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
var resp *pb.BatteryStatusResponse return m.batteryStatusExchange(false, req)
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.batteryStatus(req)
return e
})
return resp, err
} }
func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) { 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{ msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_STATUS, Type: pb.MessageType_BATTERY_STATUS,
Payload: &pb.UartMessage_BatteryStatusRequest{ Payload: &pb.UartMessage_BatteryStatusRequest{
@@ -85,23 +83,18 @@ func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.Bat
return nil, fmt.Errorf("encode: %w", err) return nil, fmt.Errorf("encode: %w", err)
} }
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...) 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 { if err != nil {
return nil, err return nil, err
} }
return decodeBatteryStatusPayload(respPayload) 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) { func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
msg := &pb.UartMessage{ msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_STATUS, 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) { 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) { 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). // 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) { 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) { 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( func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
portFn func(func(*serialPort) error) error, msg := &pb.UartMessage{
req *pb.TapNotifyRequest, Type: pb.MessageType_TAP_NOTIFY,
) (*pb.TapNotifyResponse, error) { Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
var resp *pb.TapNotifyResponse }
err := portFn(func(sp *serialPort) error { body, err := proto.Marshal(msg)
var e error if err != nil {
resp, e = sp.TapNotify(req) return nil, fmt.Errorf("encode: %w", err)
return e }
}) payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
return resp, err 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) { func (m *managedSerial) readCacheStatusPoll() (*pb.CacheStatusResponse, error) {
@@ -204,69 +214,138 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
return r, nil return r, nil
} }
func (m *managedSerial) accelStreamVia( func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
portFn func(func(*serialPort) error) error, msg := &pb.UartMessage{
req *pb.AccelStreamRequest, Type: pb.MessageType_ACCEL_STREAM,
) (*pb.AccelStreamResponse, error) { Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
var resp *pb.AccelStreamResponse }
err := portFn(func(sp *serialPort) error { body, err := proto.Marshal(msg)
var e error if err != nil {
resp, e = sp.AccelStream(req) return nil, fmt.Errorf("encode: %w", err)
return e }
}) payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
return resp, err 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) { 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) { 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) { 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) { 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( func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
portFn func(func(*serialPort) error) error, msg := &pb.UartMessage{
req *pb.TapConfigRequest, Type: pb.MessageType_TAP_CONFIG,
) (*pb.TapConfigResponse, error) { Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
var resp *pb.TapConfigResponse }
err := portFn(func(sp *serialPort) error { body, err := proto.Marshal(msg)
var e error if err != nil {
resp, e = sp.TapConfig(req) return nil, fmt.Errorf("encode: %w", err)
return e }
}) payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
return resp, err 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( func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
portFn func(func(*serialPort) error) error, msg := &pb.UartMessage{
req *pb.AccelDeadzoneRequest, Type: pb.MessageType_ACCEL_DEADZONE,
) (*pb.AccelDeadzoneResponse, error) { Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
var resp *pb.AccelDeadzoneResponse }
err := portFn(func(sp *serialPort) error { body, err := proto.Marshal(msg)
var e error if err != nil {
resp, e = sp.AccelDeadzone(req) return nil, fmt.Errorf("encode: %w", err)
return e }
}) payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
return resp, err 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) { func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
var resp *pb.EspNowUnicastTestResponse req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
err := m.withPort(func(sp *serialPort) error { msg := &pb.UartMessage{
var e error Type: pb.MessageType_ESPNOW_UNICAST_TEST,
resp, e = sp.EspnowUnicastTest(clientID, seq) Payload: &pb.UartMessage_EspnowUnicastTestRequest{
return e EspnowUnicastTestRequest: req,
}) },
return resp, err }
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) { 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 { func (m *managedSerial) FindMe(clientID uint32) error {
return m.withPort(func(sp *serialPort) error { return hostFindMe(m, clientID)
return runFindMeClient(sp, clientID)
})
} }
func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) { func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
var resp *pb.SetLogLevelResponse return hostSetLogLevel(m, write, level)
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = runSetLogLevelClient(sp, write, level)
return e
})
return resp, err
} }
func (m *managedSerial) Restart(clientID uint32) error { func (m *managedSerial) Restart(clientID uint32) error {
err := m.withPort(func(sp *serialPort) error { if err := hostRestart(m, clientID); err != nil {
return runRestartClient(sp, clientID)
})
if err != nil {
return err return err
} }
if clientID == 0 { if clientID == 0 {
@@ -618,13 +686,7 @@ func (s *serialPort) EchoPing(clientID uint32) (*EchoPingResult, error) {
} }
func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) { func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) {
var result *EchoPingResult return hostEchoPing(m, clientID)
err := m.withPort(func(sp *serialPort) error {
var e error
result, e = sp.echoPing(clientID)
return e
})
return result, err
} }
func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) { 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) { func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
var resp *pb.LedRingProgressResponse return hostLedRing(m, req)
err := m.withPort(func(sp *serialPort) error {
var e error
resp, e = sp.LedRing(req)
return e
})
return resp, err
} }
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) { func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
+40 -10
View File
@@ -20,21 +20,48 @@ var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, 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) serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address") addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)") 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") 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 { if err := serveFlags.Parse(args); err != nil {
return err return err
} }
if portName == "" { if opts.useBLE && opts.portName != "" {
return fmt.Errorf("serve requires -port (master UART)") return fmt.Errorf("use either -port or -ble, not both")
} }
link := newManagedSerial(portName, baud) link := newManagedHost(opts.baud)
link.quiet = true 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() hub := newWSHub()
streamCtl := newAccelStreamCtl() streamCtl := newAccelStreamCtl()
@@ -48,16 +75,17 @@ func runServe(portName string, baud int, args []string) error {
shutdownHTTPServer(dashSrv) shutdownHTTPServer(dashSrv)
shutdownAPIServer(apiSrv) shutdownAPIServer(apiSrv)
if err := link.Close(); err != nil { 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 runBatteryPoller(link, hub, 5*time.Second, stop)
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop) go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
go runBLEStreamDashboardPoller(link, hub, streamCtl, stop)
if *apiAddr != "" { 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() mux := http.NewServeMux()
@@ -85,10 +113,12 @@ func runServe(portName string, baud int, args []string) error {
} }
mux.Handle("/", http.FileServer(http.FS(ui))) mux.Handle("/", http.FileServer(http.FS(ui)))
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, live-stream %s, auto-reconnect)", log.Printf("dashboard http://localhost%s (transport=%s endpoint=%q poll %s, live-stream %s)",
*addr, portName, baud, interval.String(), accelInterval.String()) *addr, link.Transport(), link.Endpoint(), interval.String(), accelInterval.String())
if *apiAddr == "" { if *apiAddr == "" {
log.Printf("external API disabled (-api-addr \"\")") 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} dashSrv = &http.Server{Addr: *addr, Handler: mux}
+71 -14
View File
@@ -66,8 +66,12 @@ type ClientView struct {
} }
type DashboardState struct { type DashboardState struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
SerialPort string `json:"serial_port"` 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"` UARTConnected bool `json:"uart_connected"`
SerialOK bool `json:"serial_ok"` SerialOK bool `json:"serial_ok"`
SerialError string `json:"serial_error,omitempty"` 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 { 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{ st := DashboardState{
UpdatedAt: time.Now().Format(time.RFC3339), UpdatedAt: time.Now().Format(time.RFC3339),
SerialPort: portName, SerialPort: ep,
Clients: []ClientView{}, 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() ver, err := link.getVersionPoll()
if errors.Is(err, errUARTBusy) { if errors.Is(err, errUARTBusy) {
return pausedPollState(portName, last) return pausedPollState(ep, last)
} }
if err != nil { if err != nil {
return disconnectedState(portName, err) return disconnectedState(ep, err)
} }
st.UARTConnected = true st.UARTConnected = true
st.HostConnected = true
st.HostTransport = link.Transport()
st.HostEndpoint = link.Endpoint()
st.SerialPort = st.HostEndpoint
st.SerialOK = true st.SerialOK = true
st.Master = MasterView{ st.Master = MasterView{
Version: ver.GetVersion(), Version: ver.GetVersion(),
@@ -565,7 +584,8 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
} }
st.SerialOK = false st.SerialOK = false
st.SerialError = err.Error() st.SerialError = err.Error()
st.UARTConnected = link.IsConnected() st.HostConnected = link.IsConnected()
st.UARTConnected = st.HostConnected
return st return st
} }
@@ -677,6 +697,10 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
if !hub.liveStreamEnabled() { if !hub.liveStreamEnabled() {
continue continue
} }
// BLE live path prefers BLE_STREAM_TICK pushes.
if link.Transport() == hostTransportBLE {
continue
}
cache, err := link.readCacheStatusPoll() cache, err := link.readCacheStatusPoll()
if err != nil { if err != nil {
continue 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 { func (h *wsHub) clientCount() int {
h.mu.RLock() h.mu.RLock()
n := len(h.clients) n := len(h.clients)
@@ -695,11 +749,14 @@ func (h *wsHub) clientCount() int {
} }
func pausedPollState(portName string, last *DashboardState) DashboardState { func pausedPollState(portName string, last *DashboardState) DashboardState {
if last != nil && last.UARTConnected { if last != nil && (last.HostConnected || last.UARTConnected) {
st := *last st := *last
st.UpdatedAt = time.Now().Format(time.RFC3339) st.UpdatedAt = time.Now().Format(time.RFC3339)
st.SerialPort = portName st.SerialPort = portName
st.HostEndpoint = portName
st.SerialOK = true st.SerialOK = true
st.HostConnected = true
st.UARTConnected = true
st.SerialError = "Live-Polling pausiert (OTA läuft)" st.SerialError = "Live-Polling pausiert (OTA läuft)"
return st return st
} }
@@ -774,20 +831,20 @@ func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *acce
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
uartUp := false hostUp := false
var lastGood DashboardState var lastGood DashboardState
publish := func() { publish := func() {
st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl) st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl)
hub.setState(st) hub.setState(st)
if st.UARTConnected && st.SerialOK { if st.HostConnected && st.SerialOK {
hub.mu.RLock() hub.mu.RLock()
lastGood = hub.state lastGood = hub.state
hub.mu.RUnlock() hub.mu.RUnlock()
} }
if st.UARTConnected && !uartUp { if st.HostConnected && !hostUp {
log.Printf("UART %s connected", portName) log.Printf("host %s connected (%s)", st.HostEndpoint, st.HostTransport)
} }
uartUp = st.UARTConnected hostUp = st.HostConnected
} }
publish() publish()
+32 -2
View File
@@ -1,6 +1,6 @@
# REST API # 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 | | Base URL | Flag | Used by |
|----------|------|---------| |----------|------|---------|
@@ -27,6 +27,9 @@ GET /api/v1/
"name": "powerpod-external-api", "name": "powerpod-external-api",
"version": "1", "version": "1",
"serial_port": "/dev/ttyUSB0", "serial_port": "/dev/ttyUSB0",
"transport": "uart",
"endpoint": "/dev/ttyUSB0",
"connected": true,
"websocket": "/ws", "websocket": "/ws",
"default_interval_ms": 16, "default_interval_ms": 16,
"min_interval_ms": 1, "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. 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 ```http
GET /api/live-stream GET /api/live-stream
+30 -6
View File
@@ -2,15 +2,16 @@
External API: `ws://localhost:8081/ws` (default `-api-addr`, disable with empty string). 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 ## Connection flow
1. Connect → server sends `hello` (push off; defaults and command list). 1. Connect → server sends `hello` (host status, push off; defaults and command list).
2. Send JSON commands → one reply per message (`*_status` or `client_list`). 2. Optionally `ble_scan` / `list_serial_ports` then `host_connect` if not already linked.
3. After `set_stream` with `enable: true`, server may push `input` messages without a prior command. 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`. 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 | | 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) ### 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 | | `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: Typical sequence:
@@ -115,10 +118,14 @@ One JSON object per message; field `type` selects the command.
{ {
"type": "hello", "type": "hello",
"serial_port": "/dev/ttyUSB0", "serial_port": "/dev/ttyUSB0",
"transport": "uart",
"endpoint": "/dev/ttyUSB0",
"connected": true,
"interval_ms": 16, "interval_ms": 16,
"pre_fetch_ms": 2, "pre_fetch_ms": 2,
"tap_display_min_ms": 2000, "tap_display_min_ms": 2000,
"commands": [ "commands": [
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
"list_clients", "list_clients",
"set_stream", "get_stream", "set_stream", "get_stream",
"set_input_stream", "get_input_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` ### `list_clients`
Request: `{"type":"list_clients"}` Request: `{"type":"list_clients"}`
+114
View File
@@ -73,6 +73,120 @@ func hostAccelStream(h hostConn, req *pb.AccelStreamRequest) (*pb.AccelStreamRes
return r, nil 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) { func hostEchoPing(h hostConn, clientID uint32) (*EchoPingResult, error) {
t0 := time.Now() t0 := time.Now()
timestampUs := uint64(t0.UnixMicro()) timestampUs := uint64(t0.UnixMicro())
+468
View File
@@ -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{},
}
}
+10 -9
View File
@@ -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-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, " ble-stream enable stream + measure BleStreamTick window\n")
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\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 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, " 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") 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:]) runErr = runTest(*portName, *baud, flag.Args()[1:])
case "serve", "web", "dashboard": case "serve", "web", "dashboard":
if *useBLE { if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "serve requires -port (UART only in this release)\n") fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
os.Exit(2) os.Exit(2)
} }
if *portName == "" { runErr = runServe(serveOpts{
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd) portName: *portName,
usage() baud: *baud,
os.Exit(2) useBLE: *useBLE,
} bleName: *bleName,
runErr = runServe(*portName, *baud, flag.Args()[1:]) 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": 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 != "" { if *useBLE && *portName != "" {
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n") fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
+3 -175
View File
@@ -1,12 +1,6 @@
package main package main
import ( import "errors"
"errors"
"fmt"
"log"
"sync"
"time"
)
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug). // 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)") 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. // errOTAInProgress is returned when a second OTA upload is attempted while one is running.
var errOTAInProgress = errors.New("OTA upload already in progress") var errOTAInProgress = errors.New("OTA upload already in progress")
// managedSerial keeps the UART open and reconnects after I/O failures or unplug. // managedSerial is an alias for managedHost (UART-first history).
type managedSerial struct { type managedSerial = managedHost
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{},
}
}
+251 -54
View File
@@ -179,8 +179,8 @@
<span class="navbar-brand mb-0 h1">Powerpod</span> <span class="navbar-brand mb-0 h1">Powerpod</span>
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
<span class="badge rounded-pill me-1" <span class="badge rounded-pill me-1"
:class="state.uart_connected ? 'badge-online' : 'badge-offline'" :class="hostUp() ? 'badge-online' : 'badge-offline'"
x-text="state.uart_connected ? 'UART' : 'UART off'"></span> x-text="hostBadge()"></span>
<span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'" <span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'"
x-text="wsConnected ? 'WS' : 'WS off'"></span> x-text="wsConnected ? 'WS' : 'WS off'"></span>
<small class="text-muted ms-2" x-text="state.updated_at || '—'"></small> <small class="text-muted ms-2" x-text="state.updated_at || '—'"></small>
@@ -195,19 +195,94 @@
x-text="configMsg"></div> x-text="configMsg"></div>
</template> </template>
<section class="card mb-4">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Host-Verbindung</span>
<span class="text-muted small" x-text="hostEndpointLabel()"></span>
</div>
<div class="card-body">
<ul class="nav nav-pills mb-3 gap-1">
<li class="nav-item">
<button type="button" class="nav-link"
:class="connTab === 'uart' ? 'active' : ''"
@click="connTab = 'uart'">UART</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link"
:class="connTab === 'ble' ? 'active' : ''"
@click="connTab = 'ble'">BLE</button>
</li>
</ul>
<div x-show="connTab === 'uart'" class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label text-muted small mb-1" for="uart-port">Port</label>
<select id="uart-port" class="form-control form-control-sm" style="min-width:12rem"
x-model="uartPort" :disabled="hostBusy">
<option value="">— Port wählen —</option>
<template x-for="p in serialPorts" :key="p">
<option :value="p" x-text="p"></option>
</template>
</select>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="refreshSerialPorts()" :disabled="hostBusy">Aktualisieren</button>
<button type="button" class="btn btn-primary btn-sm"
@click="connectUART()" :disabled="hostBusy || !uartPort">Connect</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="disconnectHost()" :disabled="hostBusy || !hostUp()">Disconnect</button>
</div>
<div x-show="connTab === 'ble'">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="scanBLE()" :disabled="hostBusy || bleScanning"
x-text="bleScanning ? 'Scan…' : 'Scan'"></button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="disconnectHost()" :disabled="hostBusy || !hostUp()">Disconnect</button>
<span class="text-muted small" x-show="bleScanErr" x-text="bleScanErr"></span>
</div>
<div class="table-responsive" x-show="bleDevices.length">
<table class="table table-sm pp-table mb-0">
<thead>
<tr><th>Name</th><th>Adresse</th><th>RSSI</th><th></th></tr>
</thead>
<tbody>
<template x-for="d in bleDevices" :key="d.address">
<tr>
<td x-text="d.name || '—'"></td>
<td class="mac" x-text="d.address"></td>
<td x-text="d.rssi"></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-primary"
@click="connectBLE(d)" :disabled="hostBusy">Connect</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="text-muted small mb-0" x-show="!bleDevices.length && !bleScanning">
Scan startet eine kurze Suche (trennt aktive BLE-Verbindung).
</p>
</div>
</div>
</section>
<div class="row g-4"> <div class="row g-4">
<section class="col-lg-4"> <section class="col-lg-4">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">Master (BMA456 lokal)</div> <div class="card-header">Master (BMA456 lokal)</div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p> <p class="text-muted small mb-2"
x-text="(state.host_transport || 'none').toUpperCase() + ' ' + (state.host_endpoint || state.serial_port || '—')"></p>
<p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p> <p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p>
<template x-if="!state.uart_connected"> <template x-if="!hostUp()">
<div class="alert alert-warning py-2 mb-2" <div class="alert alert-warning py-2 mb-2"
x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div> x-text="state.serial_error || 'Host nicht verbunden — oben UART oder BLE wählen.'"></div>
</template> </template>
<template x-if="state.uart_connected && !state.serial_ok"> <template x-if="hostUp() && !state.serial_ok">
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div> <div class="alert alert-danger py-2" x-text="state.serial_error || 'Host error'"></div>
</template> </template>
<template x-if="state.master?.ok"> <template x-if="state.master?.ok">
<dl class="row mb-0"> <dl class="row mb-0">
@@ -242,26 +317,26 @@
min="0" max="4095" step="1" min="0" max="4095" step="1"
placeholder="z. B. 100" placeholder="z. B. 100"
x-model.number="masterDz" x-model.number="masterDz"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()" @click="readMasterDeadzone()"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Lesen Lesen
</button> </button>
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()" @click="setMasterDeadzone()"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Setzen Setzen
</button> </button>
<button type="button" class="btn btn-outline-warning btn-sm" <button type="button" class="btn btn-outline-warning btn-sm"
@click="findMe()" @click="findMe()"
:disabled="busy || !state.uart_connected" :disabled="busy || !hostUp()"
title="LED-Ring: Rot/Grün/Blau je 3×"> title="LED-Ring: Rot/Grün/Blau je 3×">
Find me Find me
</button> </button>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)" @click="restart(0)"
:disabled="busy || !state.uart_connected" :disabled="busy || !hostUp()"
title="Master neu starten"> title="Master neu starten">
Restart Restart
</button> </button>
@@ -272,7 +347,7 @@
<div class="d-flex flex-wrap gap-2 align-items-end"> <div class="d-flex flex-wrap gap-2 align-items-end">
<select id="master-log-level" class="form-select form-select-sm config-input" <select id="master-log-level" class="form-select form-select-sm config-input"
x-model.number="masterLogLevel" x-model.number="masterLogLevel"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
<option value="0">None (aus)</option> <option value="0">None (aus)</option>
<option value="1">Error</option> <option value="1">Error</option>
<option value="2">Warn</option> <option value="2">Warn</option>
@@ -282,12 +357,12 @@
</select> </select>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterLogLevel()" @click="readMasterLogLevel()"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Lesen Lesen
</button> </button>
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="setMasterLogLevel()" @click="setMasterLogLevel()"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Setzen Setzen
</button> </button>
</div> </div>
@@ -297,65 +372,65 @@
<label class="form-label text-muted mb-0" for="tap-sens">sens (0–15)</label> <label class="form-label text-muted mb-0" for="tap-sens">sens (0–15)</label>
<input id="tap-sens" type="number" class="form-control form-control-sm" <input id="tap-sens" type="number" class="form-control form-control-sm"
min="0" max="15" x-model.number="masterTapCfg.tap_sens_thres" min="0" max="15" x-model.number="masterTapCfg.tap_sens_thres"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-max-gest">max_gest (×5ms)</label> <label class="form-label text-muted mb-0" for="tap-max-gest">max_gest (×5ms)</label>
<input id="tap-max-gest" type="number" class="form-control form-control-sm" <input id="tap-max-gest" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.max_gest_dur" min="0" max="65535" x-model.number="masterTapCfg.max_gest_dur"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-shock">shock (×5ms)</label> <label class="form-label text-muted mb-0" for="tap-shock">shock (×5ms)</label>
<input id="tap-shock" type="number" class="form-control form-control-sm" <input id="tap-shock" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.tap_shock_dur" min="0" max="65535" x-model.number="masterTapCfg.tap_shock_dur"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-quiet">quiet (×5ms)</label> <label class="form-label text-muted mb-0" for="tap-quiet">quiet (×5ms)</label>
<input id="tap-quiet" type="number" class="form-control form-control-sm" <input id="tap-quiet" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.quite_time_after_gest" min="0" max="65535" x-model.number="masterTapCfg.quite_time_after_gest"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-wait">wait (0|1)</label> <label class="form-label text-muted mb-0" for="tap-wait">wait (0|1)</label>
<input id="tap-wait" type="number" class="form-control form-control-sm" <input id="tap-wait" type="number" class="form-control form-control-sm"
min="0" max="1" x-model.number="masterTapCfg.wait_for_timeout" min="0" max="1" x-model.number="masterTapCfg.wait_for_timeout"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-axis">axis (0=X…2=Z)</label> <label class="form-label text-muted mb-0" for="tap-axis">axis (0=X…2=Z)</label>
<input id="tap-axis" type="number" class="form-control form-control-sm" <input id="tap-axis" type="number" class="form-control form-control-sm"
min="0" max="2" x-model.number="masterTapCfg.axis_sel" min="0" max="2" x-model.number="masterTapCfg.axis_sel"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
</div> </div>
</div> </div>
<div class="d-flex flex-wrap gap-3 align-items-center mt-2 small"> <div class="d-flex flex-wrap gap-3 align-items-center mt-2 small">
<label class="tap-toggle mb-0"> <label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_single" <input type="checkbox" x-model="masterTapCfg.enable_single"
:disabled="busy || !state.uart_connected"> Single :disabled="busy || !hostUp()"> Single
</label> </label>
<label class="tap-toggle mb-0"> <label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_double" <input type="checkbox" x-model="masterTapCfg.enable_double"
:disabled="busy || !state.uart_connected"> Double :disabled="busy || !hostUp()"> Double
</label> </label>
<label class="tap-toggle mb-0"> <label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_triple" <input type="checkbox" x-model="masterTapCfg.enable_triple"
:disabled="busy || !state.uart_connected"> Triple :disabled="busy || !hostUp()"> Triple
</label> </label>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterTapConfig()" @click="readMasterTapConfig()"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Lesen Lesen
</button> </button>
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="setMasterTapConfig()" @click="setMasterTapConfig()"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Setzen Setzen
</button> </button>
</div> </div>
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected"> <p class="text-muted small mt-2 mb-0" x-show="!hostUp()">
UART nicht verbunden — Eingabe gesperrt. Host nicht verbunden — Eingabe gesperrt.
</p> </p>
</div> </div>
</div> </div>
@@ -373,12 +448,12 @@
:disabled="busy"> :disabled="busy">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="setDeadzoneAll(allDz)" @click="setDeadzoneAll(allDz)"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Alle Slaves Alle Slaves
</button> </button>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="setTapConfigAll()" @click="setTapConfigAll()"
:disabled="busy || !state.uart_connected" :disabled="busy || !hostUp()"
title="Master-Tap-Config an alle Slaves (ESP-NOW)"> title="Master-Tap-Config an alle Slaves (ESP-NOW)">
Tap-Cfg alle Tap-Cfg alle
</button> </button>
@@ -395,7 +470,7 @@
class="btn btn-sm" class="btn btn-sm"
:class="state.live_stream ? 'btn-warning' : 'btn-success'" :class="state.live_stream ? 'btn-warning' : 'btn-success'"
@click="setLiveStream(!state.live_stream)" @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'"></button> x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"></button>
<span class="text-muted small">Tap alle Slaves:</span> <span class="text-muted small">Tap alle Slaves:</span>
<label class="tap-toggle"><input type="checkbox" x-model="allTapSingle" :disabled="busy"> S</label> <label class="tap-toggle"><input type="checkbox" x-model="allTapSingle" :disabled="busy"> S</label>
@@ -403,7 +478,7 @@
<label class="tap-toggle"><input type="checkbox" x-model="allTapTriple" :disabled="busy"> T</label> <label class="tap-toggle"><input type="checkbox" x-model="allTapTriple" :disabled="busy"> T</label>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="setTapNotifyAll(allTapSingle, allTapDouble, allTapTriple)" @click="setTapNotifyAll(allTapSingle, allTapDouble, allTapTriple)"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Tap setzen Tap setzen
</button> </button>
</div> </div>
@@ -445,7 +520,7 @@
<span x-text="formatTapCfg(c.tap_config)" :title="tapCfgTitle(c.tap_config)"></span> <span x-text="formatTapCfg(c.tap_config)" :title="tapCfgTitle(c.tap_config)"></span>
<button type="button" class="btn btn-outline-primary btn-sm ms-1 py-0 px-1" <button type="button" class="btn btn-outline-primary btn-sm ms-1 py-0 px-1"
@click="setTapConfig(c.id, masterTapCfg)" @click="setTapConfig(c.id, masterTapCfg)"
:disabled="busy || !state.uart_connected || !c.available" :disabled="busy || !hostUp() || !c.available"
title="Master-Tap-Config an diesen Slave"> title="Master-Tap-Config an diesen Slave">
Set Set
</button> </button>
@@ -462,7 +537,7 @@
class="btn btn-sm" class="btn btn-sm"
:class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'" :class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'"
@click="setAccelStream(c.id, !c.accel_stream)" @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'" x-text="c.accel_stream ? 'Aus' : 'An'"
title="ESP-NOW Accel-Stream auf Slave"></button> title="ESP-NOW Accel-Stream auf Slave"></button>
</td> </td>
@@ -472,19 +547,19 @@
<input type="checkbox" <input type="checkbox"
:checked="c.tap_notify_single" :checked="c.tap_notify_single"
@change="setTapNotify(c.id, $event.target.checked, c.tap_notify_double, c.tap_notify_triple)" @change="setTapNotify(c.id, $event.target.checked, c.tap_notify_double, c.tap_notify_triple)"
:disabled="busy || !state.uart_connected || !c.available"> S :disabled="busy || !hostUp() || !c.available"> S
</label> </label>
<label class="tap-toggle" title="Double tap"> <label class="tap-toggle" title="Double tap">
<input type="checkbox" <input type="checkbox"
:checked="c.tap_notify_double" :checked="c.tap_notify_double"
@change="setTapNotify(c.id, c.tap_notify_single, $event.target.checked, c.tap_notify_triple)" @change="setTapNotify(c.id, c.tap_notify_single, $event.target.checked, c.tap_notify_triple)"
:disabled="busy || !state.uart_connected || !c.available"> D :disabled="busy || !hostUp() || !c.available"> D
</label> </label>
<label class="tap-toggle" title="Triple tap"> <label class="tap-toggle" title="Triple tap">
<input type="checkbox" <input type="checkbox"
:checked="c.tap_notify_triple" :checked="c.tap_notify_triple"
@change="setTapNotify(c.id, c.tap_notify_single, c.tap_notify_double, $event.target.checked)" @change="setTapNotify(c.id, c.tap_notify_single, c.tap_notify_double, $event.target.checked)"
:disabled="busy || !state.uart_connected || !c.available"> T :disabled="busy || !hostUp() || !c.available"> T
</label> </label>
</div> </div>
</td> </td>
@@ -500,36 +575,36 @@
:disabled="busy"> :disabled="busy">
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)" @click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
:disabled="busy || !state.uart_connected || !c.available"> :disabled="busy || !hostUp() || !c.available">
Setzen Setzen
</button> </button>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="unicastTest(c.id)" @click="unicastTest(c.id)"
:disabled="busy || !state.uart_connected || !c.available" :disabled="busy || !hostUp() || !c.available"
title="ESP-NOW Unicast-Test"> title="ESP-NOW Unicast-Test">
Test Test
</button> </button>
<button type="button" class="btn btn-outline-info btn-sm" <button type="button" class="btn btn-outline-info btn-sm"
@click="echoPing(c.id)" @click="echoPing(c.id)"
:disabled="busy || !state.uart_connected || !c.available" :disabled="busy || !hostUp() || !c.available"
title="ESP-NOW Timestamp-Echo (Round-Trip-Latenz)"> title="ESP-NOW Timestamp-Echo (Round-Trip-Latenz)">
Ping Ping
</button> </button>
<button type="button" class="btn btn-outline-info btn-sm" <button type="button" class="btn btn-outline-info btn-sm"
@click="ledRing({ clientId: c.id })" @click="ledRing({ clientId: c.id })"
:disabled="busy || !state.uart_connected || !c.available" :disabled="busy || !hostUp() || !c.available"
title="LED-Ring (aktueller Modus)"> title="LED-Ring (aktueller Modus)">
LED LED
</button> </button>
<button type="button" class="btn btn-outline-warning btn-sm" <button type="button" class="btn btn-outline-warning btn-sm"
@click="ledRing({ clientId: c.id, mode: 'find-me' })" @click="ledRing({ clientId: c.id, mode: 'find-me' })"
:disabled="busy || !state.uart_connected || !c.available" :disabled="busy || !hostUp() || !c.available"
title="Find me"> title="Find me">
Find Find
</button> </button>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(c.id)" @click="restart(c.id)"
:disabled="busy || !state.uart_connected || !c.available" :disabled="busy || !hostUp() || !c.available"
title="Neustart per ESP-NOW"> title="Neustart per ESP-NOW">
Restart Restart
</button> </button>
@@ -602,17 +677,17 @@
<div class="col-md-4 d-flex flex-wrap gap-2"> <div class="col-md-4 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="ledRing({ clientId: 0 })" @click="ledRing({ clientId: 0 })"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Master Master
</button> </button>
<button type="button" class="btn btn-outline-primary btn-sm" <button type="button" class="btn btn-outline-primary btn-sm"
@click="ledRing({ allClients: true, slavesOnly: true })" @click="ledRing({ allClients: true, slavesOnly: true })"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Alle Slaves Alle Slaves
</button> </button>
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="ledRing({ allClients: true })" @click="ledRing({ allClients: true })"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Alle + Master Alle + Master
</button> </button>
</div> </div>
@@ -621,23 +696,23 @@
</div> </div>
</section> </section>
<section class="col-12"> <section class="col-12" x-show="isUARTHost() || !hostUp()">
<div class="card"> <div class="card">
<div class="card-header">Firmware OTA (A/B)</div> <div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small mb-3"> <p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf den Master (UART), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves. Lädt eine <code>.bin</code> 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. Während des Uploads pausiert das Live-Polling. Bei BLE-Host ist OTA nicht verfügbar.
</p> </p>
<div class="mb-3"> <div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream" <input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]" @change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !state.uart_connected"> :disabled="ota.active || busy || !isUARTHost()">
</div> </div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2"> <div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm" <button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()" @click="uploadOTA()"
:disabled="ota.active || busy || !state.uart_connected || !otaFile"> :disabled="ota.active || busy || !isUARTHost() || !otaFile">
Firmware hochladen Firmware hochladen
</button> </button>
<span class="text-muted small" x-show="otaFile" <span class="text-muted small" x-show="otaFile"
@@ -666,7 +741,7 @@
<td class="ota-restart-col text-end"> <td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)" @click="restart(0)"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Restart Restart
</button> </button>
</td> </td>
@@ -703,7 +778,7 @@
<td class="ota-restart-col text-end"> <td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(row.id)" @click="restart(row.id)"
:disabled="busy || !state.uart_connected"> :disabled="busy || !hostUp()">
Restart Restart
</button> </button>
</td> </td>
@@ -733,6 +808,13 @@
state: { master: {}, clients: [] }, state: { master: {}, clients: [] },
ws: null, ws: null,
wsConnected: false, wsConnected: false,
connTab: 'uart',
uartPort: '',
serialPorts: [],
bleDevices: [],
bleScanning: false,
bleScanErr: '',
hostBusy: false,
masterDz: 100, masterDz: 100,
masterDzSeeded: false, masterDzSeeded: false,
masterLogLevel: 0, masterLogLevel: 0,
@@ -779,9 +861,121 @@
blinkMs: 350, blinkMs: 350,
blinkCount: 1 blinkCount: 1
}, },
hostUp() {
return !!(this.state?.host_connected || this.state?.uart_connected);
},
isUARTHost() {
return this.hostUp() && (this.state?.host_transport || 'uart') === 'uart';
},
hostBadge() {
const t = this.state?.host_transport || 'none';
if (!this.hostUp()) return 'off';
if (t === 'ble') return 'BLE';
if (t === 'uart') return 'UART';
return 'on';
},
hostEndpointLabel() {
const ep = this.state?.host_endpoint || this.state?.serial_port || '';
const t = this.state?.host_transport || 'none';
return ep ? `${t}: ${ep}` : t;
},
async refreshSerialPorts() {
try {
const r = await fetch('/api/serial/ports');
const data = await r.json();
this.serialPorts = data.ports || [];
if (!this.uartPort && this.serialPorts.length) {
this.uartPort = this.serialPorts[0];
}
} catch (_) {
this.serialPorts = [];
}
},
async connectUART() {
if (!this.uartPort) return;
this.hostBusy = true;
this.bleScanErr = '';
try {
const r = await fetch('/api/host/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transport: 'uart', port: this.uartPort }),
});
const data = await r.json();
if (!r.ok || data.error) {
this.flash(data.error || 'UART connect failed', false);
} else {
this.flash('UART verbunden: ' + (data.endpoint || this.uartPort), true);
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
async scanBLE() {
this.bleScanning = true;
this.bleScanErr = '';
this.bleDevices = [];
this.hostBusy = true;
try {
const r = await fetch('/api/ble/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeout_s: 5 }),
});
const data = await r.json();
if (data.error) this.bleScanErr = data.error;
this.bleDevices = (data.devices || []).slice().sort((a, b) => (b.rssi || 0) - (a.rssi || 0));
} catch (e) {
this.bleScanErr = String(e);
} finally {
this.bleScanning = false;
this.hostBusy = false;
}
},
async connectBLE(d) {
if (!d?.address) return;
this.hostBusy = true;
this.bleScanErr = '';
try {
const r = await fetch('/api/host/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transport: 'ble',
address: d.address,
name: d.name || '',
}),
});
const data = await r.json();
if (!r.ok || data.error) {
this.flash(data.error || 'BLE connect failed', false);
} else {
this.flash('BLE verbunden: ' + (data.endpoint || d.address), true);
this.connTab = 'ble';
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
async disconnectHost() {
this.hostBusy = true;
try {
await fetch('/api/host/disconnect', { method: 'POST' });
this.flash('Host getrennt', true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
connect() { connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws'; const url = proto + '//' + location.host + '/ws';
this.refreshSerialPorts();
if (this._batteryTimer) clearInterval(this._batteryTimer); if (this._batteryTimer) clearInterval(this._batteryTimer);
this._batteryTimer = setInterval(() => this.refreshBattery(), 5000); this._batteryTimer = setInterval(() => this.refreshBattery(), 5000);
if (this._tapTimer) clearInterval(this._tapTimer); if (this._tapTimer) clearInterval(this._tapTimer);
@@ -791,6 +985,7 @@
this.ws.onopen = () => { this.ws.onopen = () => {
this.wsConnected = true; this.wsConnected = true;
this.refreshBattery(); this.refreshBattery();
this.refreshSerialPorts();
}; };
this.ws.onclose = () => { this.ws.onclose = () => {
this.wsConnected = false; this.wsConnected = false;
@@ -809,6 +1004,8 @@
} }
const prev = this.state; const prev = this.state;
this.state = msg; this.state = msg;
if (msg.host_transport === 'ble') this.connTab = 'ble';
else if (msg.host_transport === 'uart') this.connTab = 'uart';
this.preserveBatteryInState(prev, this.state); this.preserveBatteryInState(prev, this.state);
this.syncTapDisplay(msg.clients || []); this.syncTapDisplay(msg.clients || []);
if (!this.masterDzSeeded && msg.master?.deadzone != null) { if (!this.masterDzSeeded && msg.master?.deadzone != null) {
@@ -898,7 +1095,7 @@
}; };
}, },
async refreshBattery() { async refreshBattery() {
if (!this.state?.uart_connected) return; if (!this.hostUp()) return;
try { try {
const r = await fetch('/api/battery?all_clients=1'); const r = await fetch('/api/battery?all_clients=1');
if (!r.ok) return; if (!r.ok) return;