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:
@@ -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).
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -55,7 +55,7 @@ Zielbild für den Host: Befehle an den Master senden; der Master steuert Slaves
|
||||
| LiPo ADC 1 | 1 | `board_input.c` |
|
||||
| LiPo ADC 2 | 11 | `board_input.c` |
|
||||
|
||||
**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool: `-port …` oder `-ble` / `-ble-name Powerpod`. OTA und `gotool serve` nur über UART. Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16).
|
||||
**Host-Protokoll (UART oder BLE):** Gleiches Framing (`0xAA`…`0xCC`) + nanopb. **UART:** `UART_NUM_1`, **921600** Baud, 8N1. **BLE:** Gerät `Powerpod`, NUS-UUIDs `6e400001`/`6e400002` (Write=RX, Notify=TX). goTool CLI: `-port …` oder `-ble` / `-ble-name Powerpod`. **`gotool serve`** startet ohne Pflicht-Gerät; Dashboard/REST/WS verbinden UART oder BLE dynamisch (`-port`/`-ble` nur optionale Vorbelegung). **OTA nur UART** (DIP8=0). Bei BLE + Accel-Stream: periodische `BLE_STREAM_TICK`-Notifies (`period_ms` 8–16) speisen Live-Accel im Dashboard/API.
|
||||
|
||||
**ESP-IDF-Log (Debug):** `esp_log_*` geht auf **UART0** (Standard-Konsole, typisch USB am Dev-Board, **115200** Baud, `CONFIG_ESP_CONSOLE_UART_NUM=0`). Das ist **getrennt** vom Host-UART1 — goTool liest keine `esp_log`-Ausgabe. Ohne angeschlossenes Debug-Kabel werden aktivierte Logs trotzdem formatiert und an UART0 gesendet (CPU-Overhead bleibt); nur `ESP_LOG_NONE` / Level-Filter vermeiden die Arbeit.
|
||||
|
||||
|
||||
+8
-6
@@ -61,16 +61,18 @@ See [`testdata/README.md`](testdata/README.md) for the JSON schema.
|
||||
|
||||
### Web dashboard
|
||||
|
||||
Polls the master over UART and pushes state to the browser via WebSocket (Alpine.js + Bootstrap 5).
|
||||
Starts without a required host link. Connect UART or BLE from the UI (or pre-connect with `-port` / `-ble`). State is pushed to the browser via WebSocket (Alpine.js + Bootstrap 5). OTA remains UART-only.
|
||||
|
||||
```bash
|
||||
go run . serve
|
||||
go run . -port /dev/ttyUSB0 serve
|
||||
go run . -port /dev/ttyUSB0 serve -addr :8080 -interval 2s
|
||||
go run . -port /dev/ttyUSB0 serve -api-addr :8081 -accel-interval 16ms
|
||||
go run . -ble serve
|
||||
go run . serve -addr :8080 -interval 2s
|
||||
go run . serve -api-addr :8081 -accel-interval 16ms
|
||||
make gotool-serve PORT=/dev/ttyUSB0
|
||||
```
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080) — shows master firmware info and the ESP-NOW client table from `CLIENT_INFO`.
|
||||
Open [http://localhost:8080](http://localhost:8080) — Host-Verbindung panel (UART ports / BLE scan), master firmware info, ESP-NOW client table.
|
||||
|
||||
**Tap (dashboard):** two independent controls per slave:
|
||||
|
||||
@@ -81,11 +83,11 @@ Open [http://localhost:8080](http://localhost:8080) — shows master firmware in
|
||||
|
||||
Enable notify first, then turn receive on to see events. Same split as the external WebSocket API (`set_tap_notify` vs `set_tap_stream`).
|
||||
|
||||
If the UART device is unplugged or the port disappears, `serve` keeps running and retries on each poll interval; the UI shows **UART off** until the port is available again.
|
||||
If the host disconnects, `serve` keeps running; the badge shows **off** until you reconnect from the UI or via REST/WS.
|
||||
|
||||
### HTTP / WebSocket API
|
||||
|
||||
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same UART as the dashboard.
|
||||
`serve` also listens on **`:8081`** for external programs (`-api-addr`, empty to disable). Same `managedHost` as the dashboard (UART or BLE).
|
||||
|
||||
| Doc | Content |
|
||||
|-----|---------|
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,7 @@ type otaAPIResponse struct {
|
||||
}
|
||||
|
||||
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||||
mountHostAPI(mux, link)
|
||||
mountLiveStreamAPI(mux, hub)
|
||||
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
||||
mountTapAPI(mux, link, hub, tapCtl)
|
||||
@@ -157,6 +158,10 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
|
||||
}
|
||||
|
||||
func serveOTAUpload(w http.ResponseWriter, r *http.Request, link *managedSerial, hub *wsHub) {
|
||||
if link.Transport() != hostTransportUART || !link.IsConnected() {
|
||||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "OTA requires UART host (DIP8=0)"})
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(otaMaxFirmwareSize); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, otaAPIResponse{Error: "invalid form"})
|
||||
return
|
||||
|
||||
+160
-8
@@ -39,6 +39,9 @@ type InputClientSample struct {
|
||||
type InputStreamMessage struct {
|
||||
Type string `json:"type"` // "hello" | "input"
|
||||
Serial string `json:"serial_port,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Connected *bool `json:"connected,omitempty"`
|
||||
IntervalMs int `json:"interval_ms,omitempty"`
|
||||
PreFetchMs int `json:"pre_fetch_ms,omitempty"`
|
||||
TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"`
|
||||
@@ -51,6 +54,33 @@ type InputStreamMessage struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HostStatusMessage is the reply to get_host / host_connect / host_disconnect.
|
||||
type HostStatusMessage struct {
|
||||
Type string `json:"type"` // "host_status"
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Connected bool `json:"connected"`
|
||||
BleName string `json:"ble_name,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SerialPortsMessage is the reply to list_serial_ports.
|
||||
type SerialPortsMessage struct {
|
||||
Type string `json:"type"` // "serial_ports"
|
||||
Ports []string `json:"ports"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BleScanResultMessage is the reply to ble_scan.
|
||||
type BleScanResultMessage struct {
|
||||
Type string `json:"type"` // "ble_scan_result"
|
||||
Devices []bleScanDevice `json:"devices"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// StreamStatusMessage is the reply to set_stream / get_stream (this connection).
|
||||
type StreamStatusMessage struct {
|
||||
Type string `json:"type"` // "stream_status"
|
||||
@@ -116,12 +146,23 @@ type accelWSCommand struct {
|
||||
DoubleTap *bool `json:"double_tap"`
|
||||
Triple *bool `json:"triple"`
|
||||
AllClients bool `json:"all_clients"`
|
||||
// Host connect / BLE scan
|
||||
Transport string `json:"transport"`
|
||||
Port string `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Baud int `json:"baud"`
|
||||
TimeoutS float64 `json:"timeout_s"`
|
||||
NameFilter string `json:"name_filter"`
|
||||
}
|
||||
|
||||
type APIInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Connected bool `json:"connected"`
|
||||
WebSocket string `json:"websocket"`
|
||||
DefaultIntervalMs int `json:"default_interval_ms"`
|
||||
DefaultPreFetchMs int `json:"default_pre_fetch_ms"`
|
||||
@@ -196,7 +237,7 @@ func clampPreFetch(d time.Duration) time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber {
|
||||
func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *wsSubscriber {
|
||||
sub := &wsSubscriber{
|
||||
conn: conn,
|
||||
receiveInput: false,
|
||||
@@ -207,14 +248,20 @@ func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubs
|
||||
h.clients[conn] = sub
|
||||
h.mu.Unlock()
|
||||
|
||||
st := link.HostStatus()
|
||||
connected := st["connected"].(bool)
|
||||
hello := InputStreamMessage{
|
||||
Type: "hello",
|
||||
Serial: portName,
|
||||
Serial: st["endpoint"].(string),
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: &connected,
|
||||
IntervalMs: int(h.defaultInterval / time.Millisecond),
|
||||
PreFetchMs: int(h.defaultPreFetch / time.Millisecond),
|
||||
TapDisplayMinMs: apiTapDisplayMinMs,
|
||||
Note: "set_tap_notify configures slave S/D/T only; set_stream enables input polling/push on this connection",
|
||||
Commands: []string{
|
||||
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
|
||||
"list_clients",
|
||||
"set_stream", "get_stream",
|
||||
"set_input_stream", "get_input_stream",
|
||||
@@ -454,6 +501,29 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer BLE_STREAM_TICK when on BLE (firmware push).
|
||||
if link.Transport() == hostTransportBLE {
|
||||
payload, err := link.NextStreamTick(20 * time.Millisecond)
|
||||
if err == nil {
|
||||
cache, cerr := cacheStatusFromBLEStreamPayload(payload)
|
||||
if cerr == nil {
|
||||
hub.ingestTapFromCache(cache)
|
||||
hub.mu.RLock()
|
||||
clients := hub.inputClientsFromCacheLocked(cache, time.Now())
|
||||
hub.mu.RUnlock()
|
||||
hub.deliverInput(InputStreamMessage{
|
||||
Type: "input",
|
||||
T: time.Now().UnixNano(),
|
||||
Success: true,
|
||||
Clients: clients,
|
||||
})
|
||||
pending = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall through to CACHE_STATUS poll if no tick yet.
|
||||
}
|
||||
|
||||
needRead, needDeliver, waitPreFetch := hub.streamTiming(now)
|
||||
|
||||
if needRead && pending == nil {
|
||||
@@ -634,6 +704,58 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
}
|
||||
|
||||
switch cmd.Type {
|
||||
case "get_host":
|
||||
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
|
||||
|
||||
case "list_serial_ports":
|
||||
ports, err := listSerialPorts()
|
||||
msg := SerialPortsMessage{Type: "serial_ports", Ports: ports, Success: err == nil}
|
||||
if err != nil {
|
||||
msg.Error = err.Error()
|
||||
msg.Ports = []string{}
|
||||
}
|
||||
writeWSJSON(conn, msg)
|
||||
|
||||
case "ble_scan":
|
||||
timeout := time.Duration(cmd.TimeoutS * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if link.Transport() == hostTransportBLE {
|
||||
_ = link.Disconnect()
|
||||
}
|
||||
devs, err := scanBLEDevices(timeout, cmd.NameFilter)
|
||||
msg := BleScanResultMessage{Type: "ble_scan_result", Devices: devs, Success: err == nil}
|
||||
if err != nil {
|
||||
msg.Error = err.Error()
|
||||
msg.Devices = []bleScanDevice{}
|
||||
}
|
||||
writeWSJSON(conn, msg)
|
||||
|
||||
case "host_connect":
|
||||
var err error
|
||||
switch cmd.Transport {
|
||||
case hostTransportUART:
|
||||
err = link.ConnectUART(cmd.Port, cmd.Baud)
|
||||
case hostTransportBLE:
|
||||
err = link.ConnectBLE(cmd.Address, cmd.Name)
|
||||
default:
|
||||
writeHostStatus(conn, HostStatusMessage{
|
||||
Type: "host_status",
|
||||
Error: "transport must be uart or ble",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeHostStatus(conn, hostStatusFromLink(link, false, err.Error()))
|
||||
return
|
||||
}
|
||||
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
|
||||
|
||||
case "host_disconnect":
|
||||
_ = link.Disconnect()
|
||||
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
|
||||
|
||||
case "list_clients":
|
||||
clients, err := link.listClientsPoll()
|
||||
if err != nil {
|
||||
@@ -791,13 +913,38 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
||||
default:
|
||||
writeStreamStatus(conn, StreamStatusMessage{
|
||||
Type: "stream_status",
|
||||
Error: "unknown type (list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
|
||||
Error: "unknown type (get_host, list_serial_ports, ble_scan, host_connect, host_disconnect, list_clients, set_stream, get_stream, set_input_stream, get_input_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, portName string, hub *accelStreamHub) {
|
||||
sub := hub.register(conn, portName)
|
||||
func hostStatusFromLink(link *managedSerial, success bool, errMsg string) HostStatusMessage {
|
||||
st := link.HostStatus()
|
||||
return HostStatusMessage{
|
||||
Type: "host_status",
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: st["connected"].(bool),
|
||||
BleName: st["ble_name"].(string),
|
||||
Success: success && errMsg == "",
|
||||
Error: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
func writeHostStatus(conn *websocket.Conn, msg HostStatusMessage) {
|
||||
writeWSJSON(conn, msg)
|
||||
}
|
||||
|
||||
func writeWSJSON(conn *websocket.Conn, msg any) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
|
||||
sub := hub.register(conn, link)
|
||||
defer hub.unregister(conn)
|
||||
defer conn.Close()
|
||||
|
||||
@@ -811,6 +958,7 @@ func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl
|
||||
}
|
||||
|
||||
func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||||
_ = portName
|
||||
defMs := int(defaultInterval / time.Millisecond)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" {
|
||||
@@ -821,17 +969,21 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
st := link.HostStatus()
|
||||
writeJSON(w, http.StatusOK, APIInfoResponse{
|
||||
Name: "powerpod-external-api",
|
||||
Version: "1",
|
||||
SerialPort: portName,
|
||||
SerialPort: st["endpoint"].(string),
|
||||
Transport: st["transport"].(string),
|
||||
Endpoint: st["endpoint"].(string),
|
||||
Connected: st["connected"].(bool),
|
||||
WebSocket: "/ws",
|
||||
DefaultIntervalMs: defMs,
|
||||
DefaultPreFetchMs: defaultPreFetchMs,
|
||||
MinIntervalMs: int(minAPIStreamInterval / time.Millisecond),
|
||||
MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond),
|
||||
TapDisplayMinMs: apiTapDisplayMinMs,
|
||||
Description: "WebSocket: set_input_stream + set_stream for input (accel + tap); set_tap_notify configures slave tap kinds",
|
||||
Description: "WebSocket: host_connect/ble_scan + set_input_stream + set_stream for input (accel + tap)",
|
||||
})
|
||||
})
|
||||
|
||||
@@ -841,7 +993,7 @@ func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.
|
||||
log.Printf("api websocket upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub)
|
||||
serveExternalWS(conn, link, dash, ctl, tapCtl, hub)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+51
-12
@@ -37,15 +37,6 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
||||
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
||||
}
|
||||
|
||||
svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("BLE scanning for %q…", name)
|
||||
var foundAddr bluetooth.Address
|
||||
var found bool
|
||||
@@ -53,7 +44,7 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
||||
time.Sleep(15 * time.Second)
|
||||
_ = adapter.StopScan()
|
||||
}()
|
||||
err = adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||
if result.LocalName() != name {
|
||||
return
|
||||
}
|
||||
@@ -67,9 +58,57 @@ func openBLE(name string, settle time.Duration) (*bleLink, error) {
|
||||
if !found {
|
||||
return nil, fmt.Errorf("device %q not found (check DIP8=BLE and advertising)", name)
|
||||
}
|
||||
return connectBLEDevice(foundAddr, settle)
|
||||
}
|
||||
|
||||
log.Printf("BLE connecting…")
|
||||
device, err := adapter.Connect(foundAddr, bluetooth.ConnectionParams{})
|
||||
func openBLEAddress(addrStr string, settle time.Duration) (*bleLink, error) {
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
if err := adapter.Enable(); err != nil {
|
||||
return nil, fmt.Errorf("enable bluetooth adapter: %w (check BlueZ / permissions)", err)
|
||||
}
|
||||
if mac, err := bluetooth.ParseMAC(addrStr); err == nil {
|
||||
var addr bluetooth.Address
|
||||
addr.Set(mac.String())
|
||||
return connectBLEDevice(addr, settle)
|
||||
}
|
||||
// Fallback: scan until address string matches
|
||||
log.Printf("BLE resolving address %s via scan…", addrStr)
|
||||
var foundAddr bluetooth.Address
|
||||
var found bool
|
||||
go func() {
|
||||
time.Sleep(15 * time.Second)
|
||||
_ = adapter.StopScan()
|
||||
}()
|
||||
err := adapter.Scan(func(a *bluetooth.Adapter, result bluetooth.ScanResult) {
|
||||
if result.Address.String() != addrStr {
|
||||
return
|
||||
}
|
||||
foundAddr = result.Address
|
||||
found = true
|
||||
_ = a.StopScan()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan for address: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("BLE address %s not found", addrStr)
|
||||
}
|
||||
return connectBLEDevice(foundAddr, settle)
|
||||
}
|
||||
|
||||
func connectBLEDevice(addr bluetooth.Address, settle time.Duration) (*bleLink, error) {
|
||||
svcUUID, err := bluetooth.ParseUUID(bleServiceUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chrUUID, err := bluetooth.ParseUUID(bleCharUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
log.Printf("BLE connecting to %s…", addr.String())
|
||||
device, err := adapter.Connect(addr, bluetooth.ConnectionParams{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
+27
-1
@@ -1,6 +1,12 @@
|
||||
package main
|
||||
|
||||
import "powerpod/gotool/pb"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"powerpod/gotool/pb"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// accelSamplesFromCacheStatus maps combined CACHE_STATUS entries to AccelSample
|
||||
// (for dashboard / WebSocket accel push).
|
||||
@@ -47,3 +53,23 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
|
||||
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
|
||||
func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
||||
if len(payload) < 2 {
|
||||
return nil, fmt.Errorf("BLE stream tick too short")
|
||||
}
|
||||
if payload[0] != byte(pb.MessageType_BLE_STREAM_TICK) {
|
||||
return nil, fmt.Errorf("unexpected BLE tick type 0x%02x", payload[0])
|
||||
}
|
||||
var msg pb.UartMessage
|
||||
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tick := msg.GetBleStreamTick()
|
||||
if tick == nil {
|
||||
return nil, fmt.Errorf("missing ble_stream_tick")
|
||||
}
|
||||
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
|
||||
}
|
||||
|
||||
+161
-105
@@ -64,16 +64,14 @@ func decodeBatteryStatusPayload(payload []byte) (*pb.BatteryStatusResponse, erro
|
||||
}
|
||||
|
||||
func (m *managedSerial) BatteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
var resp *pb.BatteryStatusResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.batteryStatus(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
return m.batteryStatusExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
return m.batteryStatusExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) batteryStatusExchange(poll bool, req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_BATTERY_STATUS,
|
||||
Payload: &pb.UartMessage_BatteryStatusRequest{
|
||||
@@ -85,23 +83,18 @@ func (m *managedSerial) BatteryStatusPoll(req *pb.BatteryStatusRequest) (*pb.Bat
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_BATTERY_STATUS)}, body...)
|
||||
respPayload, err := m.batteryStatusPayloadPoll(payload)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadMode(true, payload, "BATTERY_STATUS", batteryReadTimeout)
|
||||
} else {
|
||||
respPayload, err = m.exchangePayloadMode(false, payload, "BATTERY_STATUS", batteryReadTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeBatteryStatusPayload(respPayload)
|
||||
}
|
||||
|
||||
func (m *managedSerial) batteryStatusPayloadPoll(payload []byte) ([]byte, error) {
|
||||
var resp []byte
|
||||
err := m.withPortPoll(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.exchangePayloadForBattery(payload, "BATTERY_STATUS")
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatteryStatusResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_BATTERY_STATUS,
|
||||
@@ -122,11 +115,11 @@ func (s *serialPort) batteryStatus(req *pb.BatteryStatusRequest) (*pb.BatterySta
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelStream(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
return m.accelStreamVia(m.withPort, req)
|
||||
return m.accelStreamExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelStreamPoll(req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
return m.accelStreamVia(m.withPortPoll, req)
|
||||
return m.accelStreamExchange(true, req)
|
||||
}
|
||||
|
||||
// SetAccelStream enables or disables the ESP-NOW accel stream for one slave (master UART).
|
||||
@@ -154,24 +147,41 @@ func (m *managedSerial) GetAccelStream(clientID uint32) (bool, error) {
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapNotify(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||
return m.tapNotifyVia(m.withPort, req)
|
||||
return m.tapNotifyExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapNotifyPoll(req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||
return m.tapNotifyVia(m.withPortPoll, req)
|
||||
return m.tapNotifyExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) tapNotifyVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.TapNotifyRequest,
|
||||
) (*pb.TapNotifyResponse, error) {
|
||||
var resp *pb.TapNotifyResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.TapNotify(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) tapNotifyExchange(poll bool, req *pb.TapNotifyRequest) (*pb.TapNotifyResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_TAP_NOTIFY,
|
||||
Payload: &pb.UartMessage_TapNotifyRequest{TapNotifyRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_TAP_NOTIFY)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "TAP_NOTIFY")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "TAP_NOTIFY")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetTapNotifyResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing tap_notify_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) readCacheStatusPoll() (*pb.CacheStatusResponse, error) {
|
||||
@@ -204,69 +214,138 @@ func decodeCacheStatusPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) accelStreamVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.AccelStreamRequest,
|
||||
) (*pb.AccelStreamResponse, error) {
|
||||
var resp *pb.AccelStreamResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.AccelStream(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) accelStreamExchange(poll bool, req *pb.AccelStreamRequest) (*pb.AccelStreamResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_STREAM,
|
||||
Payload: &pb.UartMessage_AccelStreamRequest{AccelStreamRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ACCEL_STREAM)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_STREAM")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "ACCEL_STREAM")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetAccelStreamResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_stream_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelDeadzone(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
return m.accelDeadzoneVia(m.withPort, req)
|
||||
return m.accelDeadzoneExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) AccelDeadzonePoll(req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
return m.accelDeadzoneVia(m.withPortPoll, req)
|
||||
return m.accelDeadzoneExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
return m.tapConfigVia(m.withPort, req)
|
||||
return m.tapConfigExchange(false, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) TapConfigPoll(req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
return m.tapConfigVia(m.withPortPoll, req)
|
||||
return m.tapConfigExchange(true, req)
|
||||
}
|
||||
|
||||
func (m *managedSerial) tapConfigVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.TapConfigRequest,
|
||||
) (*pb.TapConfigResponse, error) {
|
||||
var resp *pb.TapConfigResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.TapConfig(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (*pb.TapConfigResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_TAP_CONFIG,
|
||||
Payload: &pb.UartMessage_TapConfigRequest{TapConfigRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_TAP_CONFIG)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "TAP_CONFIG")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "TAP_CONFIG")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetTapConfigResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing tap_config_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) accelDeadzoneVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
req *pb.AccelDeadzoneRequest,
|
||||
) (*pb.AccelDeadzoneResponse, error) {
|
||||
var resp *pb.AccelDeadzoneResponse
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.AccelDeadzone(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ACCEL_DEADZONE,
|
||||
Payload: &pb.UartMessage_AccelDeadzoneRequest{AccelDeadzoneRequest: req},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ACCEL_DEADZONE)}, body...)
|
||||
var respPayload []byte
|
||||
if poll {
|
||||
respPayload, err = m.exchangePayloadPoll(payload, "ACCEL_DEADZONE")
|
||||
} else {
|
||||
respPayload, err = m.exchangePayload(payload, "ACCEL_DEADZONE")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetAccelDeadzoneResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing accel_deadzone_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
|
||||
var resp *pb.EspNowUnicastTestResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.EspnowUnicastTest(clientID, seq)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_ESPNOW_UNICAST_TEST,
|
||||
Payload: &pb.UartMessage_EspnowUnicastTestRequest{
|
||||
EspnowUnicastTestRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_ESPNOW_UNICAST_TEST)}, body...)
|
||||
respPayload, err := m.exchangePayload(payload, "ESPNOW_UNICAST_TEST")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetEspnowUnicastTestResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing espnow_unicast_test_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func decodeVersionPayload(payload []byte) (*pb.VersionResponse, error) {
|
||||
@@ -520,26 +599,15 @@ func (s *serialPort) echoPing(clientID uint32) (*EchoPingResult, error) {
|
||||
}
|
||||
|
||||
func (m *managedSerial) FindMe(clientID uint32) error {
|
||||
return m.withPort(func(sp *serialPort) error {
|
||||
return runFindMeClient(sp, clientID)
|
||||
})
|
||||
return hostFindMe(m, clientID)
|
||||
}
|
||||
|
||||
func (m *managedSerial) SetLogLevel(write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||
var resp *pb.SetLogLevelResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = runSetLogLevelClient(sp, write, level)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
return hostSetLogLevel(m, write, level)
|
||||
}
|
||||
|
||||
func (m *managedSerial) Restart(clientID uint32) error {
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
return runRestartClient(sp, clientID)
|
||||
})
|
||||
if err != nil {
|
||||
if err := hostRestart(m, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
if clientID == 0 {
|
||||
@@ -618,13 +686,7 @@ func (s *serialPort) EchoPing(clientID uint32) (*EchoPingResult, error) {
|
||||
}
|
||||
|
||||
func (m *managedSerial) EchoPing(clientID uint32) (*EchoPingResult, error) {
|
||||
var result *EchoPingResult
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
result, e = sp.echoPing(clientID)
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
return hostEchoPing(m, clientID)
|
||||
}
|
||||
|
||||
func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||
@@ -632,13 +694,7 @@ func (s *serialPort) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgres
|
||||
}
|
||||
|
||||
func (m *managedSerial) LedRing(req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||
var resp *pb.LedRingProgressResponse
|
||||
err := m.withPort(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.LedRing(req)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
return hostLedRing(m, req)
|
||||
}
|
||||
|
||||
func (s *serialPort) FindMe(clientID uint32) (*pb.EspNowFindMeResponse, error) {
|
||||
|
||||
+40
-10
@@ -20,21 +20,48 @@ var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
func runServe(portName string, baud int, args []string) error {
|
||||
type serveOpts struct {
|
||||
portName string
|
||||
baud int
|
||||
useBLE bool
|
||||
bleName string
|
||||
settle time.Duration
|
||||
}
|
||||
|
||||
func runServe(opts serveOpts, args []string) error {
|
||||
serveFlags := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
addr := serveFlags.String("addr", ":8080", "dashboard HTTP listen address")
|
||||
apiAddr := serveFlags.String("api-addr", ":8081", "external API HTTP listen address (empty to disable)")
|
||||
accelInterval := serveFlags.Duration("accel-interval", defaultAccelStreamInterval, "accel WebSocket sample period on API server")
|
||||
interval := serveFlags.Duration("interval", 2*time.Second, "UART poll interval")
|
||||
interval := serveFlags.Duration("interval", 2*time.Second, "host poll interval")
|
||||
if err := serveFlags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if portName == "" {
|
||||
return fmt.Errorf("serve requires -port (master UART)")
|
||||
if opts.useBLE && opts.portName != "" {
|
||||
return fmt.Errorf("use either -port or -ble, not both")
|
||||
}
|
||||
|
||||
link := newManagedSerial(portName, baud)
|
||||
link := newManagedHost(opts.baud)
|
||||
link.quiet = true
|
||||
link.settle = opts.settle
|
||||
if opts.settle <= 0 {
|
||||
link.settle = time.Second
|
||||
}
|
||||
|
||||
switch {
|
||||
case opts.portName != "":
|
||||
if err := link.ConnectUART(opts.portName, opts.baud); err != nil {
|
||||
log.Printf("initial UART connect failed (UI can retry): %v", err)
|
||||
}
|
||||
case opts.useBLE:
|
||||
name := opts.bleName
|
||||
if name == "" {
|
||||
name = bleDefaultName
|
||||
}
|
||||
if err := link.ConnectBLE("", name); err != nil {
|
||||
log.Printf("initial BLE connect failed (UI can scan/retry): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
hub := newWSHub()
|
||||
streamCtl := newAccelStreamCtl()
|
||||
@@ -48,16 +75,17 @@ func runServe(portName string, baud int, args []string) error {
|
||||
shutdownHTTPServer(dashSrv)
|
||||
shutdownAPIServer(apiSrv)
|
||||
if err := link.Close(); err != nil {
|
||||
log.Printf("UART close: %v", err)
|
||||
log.Printf("host close: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
go runPoller(link, portName, hub, streamCtl, tapCtl, *interval, stop)
|
||||
go runPoller(link, link.Endpoint(), hub, streamCtl, tapCtl, *interval, stop)
|
||||
go runBatteryPoller(link, hub, 5*time.Second, stop)
|
||||
go runCacheStatusDashboardPoller(link, hub, *accelInterval, stop)
|
||||
go runBLEStreamDashboardPoller(link, hub, streamCtl, stop)
|
||||
|
||||
if *apiAddr != "" {
|
||||
apiSrv = runAPIServer(portName, link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
|
||||
apiSrv = runAPIServer(link.Endpoint(), link, *apiAddr, *accelInterval, hub, streamCtl, tapCtl, stop)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -85,10 +113,12 @@ func runServe(portName string, baud int, args []string) error {
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(ui)))
|
||||
|
||||
log.Printf("dashboard http://localhost%s (UART %s @ %d baud, poll %s, live-stream %s, auto-reconnect)",
|
||||
*addr, portName, baud, interval.String(), accelInterval.String())
|
||||
log.Printf("dashboard http://localhost%s (transport=%s endpoint=%q poll %s, live-stream %s)",
|
||||
*addr, link.Transport(), link.Endpoint(), interval.String(), accelInterval.String())
|
||||
if *apiAddr == "" {
|
||||
log.Printf("external API disabled (-api-addr \"\")")
|
||||
} else {
|
||||
log.Printf("external API http://localhost%s ws://localhost%s/ws", *apiAddr, *apiAddr)
|
||||
}
|
||||
|
||||
dashSrv = &http.Server{Addr: *addr, Handler: mux}
|
||||
|
||||
+71
-14
@@ -66,8 +66,12 @@ type ClientView struct {
|
||||
}
|
||||
|
||||
type DashboardState struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SerialPort string `json:"serial_port"`
|
||||
/** Alias of host_endpoint for older UI. */
|
||||
HostEndpoint string `json:"host_endpoint"`
|
||||
HostTransport string `json:"host_transport"`
|
||||
HostConnected bool `json:"host_connected"`
|
||||
UARTConnected bool `json:"uart_connected"`
|
||||
SerialOK bool `json:"serial_ok"`
|
||||
SerialError string `json:"serial_error,omitempty"`
|
||||
@@ -530,20 +534,35 @@ func (h *wsHub) broadcastRaw(v any) {
|
||||
}
|
||||
|
||||
func pollDashboard(link *managedSerial, portName string, last *DashboardState, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) DashboardState {
|
||||
ep := link.Endpoint()
|
||||
if ep == "" {
|
||||
ep = portName
|
||||
}
|
||||
tr := link.Transport()
|
||||
st := DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: portName,
|
||||
Clients: []ClientView{},
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: ep,
|
||||
HostEndpoint: ep,
|
||||
HostTransport: tr,
|
||||
Clients: []ClientView{},
|
||||
}
|
||||
if !link.IsConnected() && tr == hostTransportNone {
|
||||
st.SerialError = "not connected — use UART or BLE connect"
|
||||
return st
|
||||
}
|
||||
|
||||
ver, err := link.getVersionPoll()
|
||||
if errors.Is(err, errUARTBusy) {
|
||||
return pausedPollState(portName, last)
|
||||
return pausedPollState(ep, last)
|
||||
}
|
||||
if err != nil {
|
||||
return disconnectedState(portName, err)
|
||||
return disconnectedState(ep, err)
|
||||
}
|
||||
st.UARTConnected = true
|
||||
st.HostConnected = true
|
||||
st.HostTransport = link.Transport()
|
||||
st.HostEndpoint = link.Endpoint()
|
||||
st.SerialPort = st.HostEndpoint
|
||||
st.SerialOK = true
|
||||
st.Master = MasterView{
|
||||
Version: ver.GetVersion(),
|
||||
@@ -565,7 +584,8 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
|
||||
}
|
||||
st.SerialOK = false
|
||||
st.SerialError = err.Error()
|
||||
st.UARTConnected = link.IsConnected()
|
||||
st.HostConnected = link.IsConnected()
|
||||
st.UARTConnected = st.HostConnected
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -677,6 +697,10 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
|
||||
if !hub.liveStreamEnabled() {
|
||||
continue
|
||||
}
|
||||
// BLE live path prefers BLE_STREAM_TICK pushes.
|
||||
if link.Transport() == hostTransportBLE {
|
||||
continue
|
||||
}
|
||||
cache, err := link.readCacheStatusPoll()
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -687,6 +711,36 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
|
||||
}
|
||||
}
|
||||
|
||||
// runBLEStreamDashboardPoller feeds live accel/tap from BLE_STREAM_TICK when live_stream is on.
|
||||
func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, stop <-chan struct{}) {
|
||||
_ = streamCtl
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
}
|
||||
payload, err := link.NextStreamTick(200 * time.Millisecond)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cache, err := cacheStatusFromBLEStreamPayload(payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hub.mergeAccel(accelSamplesFromCacheStatus(cache))
|
||||
hub.mergeTap(tapEventsFromCacheStatus(cache))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHub) clientCount() int {
|
||||
h.mu.RLock()
|
||||
n := len(h.clients)
|
||||
@@ -695,11 +749,14 @@ func (h *wsHub) clientCount() int {
|
||||
}
|
||||
|
||||
func pausedPollState(portName string, last *DashboardState) DashboardState {
|
||||
if last != nil && last.UARTConnected {
|
||||
if last != nil && (last.HostConnected || last.UARTConnected) {
|
||||
st := *last
|
||||
st.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
st.SerialPort = portName
|
||||
st.HostEndpoint = portName
|
||||
st.SerialOK = true
|
||||
st.HostConnected = true
|
||||
st.UARTConnected = true
|
||||
st.SerialError = "Live-Polling pausiert (OTA läuft)"
|
||||
return st
|
||||
}
|
||||
@@ -774,20 +831,20 @@ func runPoller(link *managedSerial, portName string, hub *wsHub, streamCtl *acce
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
uartUp := false
|
||||
hostUp := false
|
||||
var lastGood DashboardState
|
||||
publish := func() {
|
||||
st := pollDashboard(link, portName, &lastGood, streamCtl, tapCtl)
|
||||
hub.setState(st)
|
||||
if st.UARTConnected && st.SerialOK {
|
||||
if st.HostConnected && st.SerialOK {
|
||||
hub.mu.RLock()
|
||||
lastGood = hub.state
|
||||
hub.mu.RUnlock()
|
||||
}
|
||||
if st.UARTConnected && !uartUp {
|
||||
log.Printf("UART %s connected", portName)
|
||||
if st.HostConnected && !hostUp {
|
||||
log.Printf("host %s connected (%s)", st.HostEndpoint, st.HostTransport)
|
||||
}
|
||||
uartUp = st.UARTConnected
|
||||
hostUp = st.HostConnected
|
||||
}
|
||||
|
||||
publish()
|
||||
|
||||
+32
-2
@@ -1,6 +1,6 @@
|
||||
# REST API
|
||||
|
||||
`go run . -port /dev/ttyUSB0 serve` starts two HTTP servers on the same UART link:
|
||||
`go run . serve` (optional `-port` / `-ble`) starts two HTTP servers on the same `managedHost` (UART or BLE):
|
||||
|
||||
| Base URL | Flag | Used by |
|
||||
|----------|------|---------|
|
||||
@@ -27,6 +27,9 @@ GET /api/v1/
|
||||
"name": "powerpod-external-api",
|
||||
"version": "1",
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"transport": "uart",
|
||||
"endpoint": "/dev/ttyUSB0",
|
||||
"connected": true,
|
||||
"websocket": "/ws",
|
||||
"default_interval_ms": 16,
|
||||
"min_interval_ms": 1,
|
||||
@@ -110,7 +113,34 @@ WebSocket: `set_led_ring` with the same fields plus `"type":"set_led_ring"` →
|
||||
|
||||
Used by the web UI; safe for scripts that drive the same features.
|
||||
|
||||
### Live stream (host `CACHE_STATUS` poll ~16 ms)
|
||||
### Host link (UART / BLE)
|
||||
|
||||
```http
|
||||
GET /api/host
|
||||
→ {"transport":"none|uart|ble","endpoint":"…","connected":false,"ble_name":""}
|
||||
|
||||
GET /api/serial/ports
|
||||
→ {"ports":["/dev/ttyUSB0",…]}
|
||||
|
||||
POST /api/host/connect
|
||||
Content-Type: application/json
|
||||
{"transport":"uart","port":"/dev/ttyUSB0"}
|
||||
{"transport":"ble","address":"AA:BB:…","name":"Powerpod"}
|
||||
|
||||
POST /api/host/disconnect
|
||||
|
||||
POST /api/ble/scan
|
||||
{"timeout_s":5,"name_filter":"Powerpod"}
|
||||
→ {"devices":[{"address":"…","name":"Powerpod","rssi":-55}]}
|
||||
```
|
||||
|
||||
`name_filter` is optional substring match (case-insensitive); omit to list all LE advertisements. Active BLE is disconnected before scan (BlueZ adapter).
|
||||
|
||||
Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_endpoint` (aliases `uart_connected` / `serial_port` kept).
|
||||
|
||||
**OTA** (`POST /api/ota`) returns **400** unless `transport==uart` and connected.
|
||||
|
||||
### Live stream (host `CACHE_STATUS` poll ~16 ms; BLE uses `BLE_STREAM_TICK`)
|
||||
|
||||
```http
|
||||
GET /api/live-stream
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
External API: `ws://localhost:8081/ws` (default `-api-addr`, disable with empty string).
|
||||
|
||||
Start with `go run . -port /dev/ttyUSB0 serve`.
|
||||
Start with `go run . serve` (optional `-port` / `-ble` to pre-connect). Host can be opened later via `host_connect` / dashboard REST.
|
||||
|
||||
---
|
||||
|
||||
## Connection flow
|
||||
|
||||
1. Connect → server sends `hello` (push off; defaults and command list).
|
||||
2. Send JSON commands → one reply per message (`*_status` or `client_list`).
|
||||
3. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
|
||||
1. Connect → server sends `hello` (host status, push off; defaults and command list).
|
||||
2. Optionally `ble_scan` / `list_serial_ports` then `host_connect` if not already linked.
|
||||
3. Send JSON commands → one reply per message (`*_status`, `client_list`, `ble_scan_result`, …).
|
||||
4. After `set_stream` with `enable: true`, server may push `input` messages without a prior command.
|
||||
|
||||
Commands and pushes share one socket — always branch on `type`.
|
||||
|
||||
@@ -27,7 +28,9 @@ On disconnect, `set_stream` state for that socket is dropped. Firmware settings
|
||||
| This connection | `set_stream` | Whether you receive push JSON on this socket |
|
||||
|
||||
|
||||
UART polling runs only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
|
||||
Host polling / BLE ticks run only when at least one connection has `receive_input: true` **and** at least one slave streams input or has tap notify enabled. `set_tap_notify` alone does not enable push — you still need `set_stream`.
|
||||
|
||||
On **BLE**, live `input` prefers firmware `BLE_STREAM_TICK` pushes; otherwise falls back to `CACHE_STATUS` like UART.
|
||||
|
||||
### Push timing (per connection)
|
||||
|
||||
@@ -38,7 +41,7 @@ UART polling runs only when at least one connection has `receive_input: true` **
|
||||
| `pre_fetch` | `set_stream`, `stream_status` | Ms before each push when the host starts the UART cache read |
|
||||
|
||||
|
||||
Global UART poll interval = minimum `interval_ms` among all connections with push enabled.
|
||||
Global host poll interval = minimum `interval_ms` among all connections with push enabled.
|
||||
|
||||
Typical sequence:
|
||||
|
||||
@@ -115,10 +118,14 @@ One JSON object per message; field `type` selects the command.
|
||||
{
|
||||
"type": "hello",
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"transport": "uart",
|
||||
"endpoint": "/dev/ttyUSB0",
|
||||
"connected": true,
|
||||
"interval_ms": 16,
|
||||
"pre_fetch_ms": 2,
|
||||
"tap_display_min_ms": 2000,
|
||||
"commands": [
|
||||
"get_host", "list_serial_ports", "ble_scan", "host_connect", "host_disconnect",
|
||||
"list_clients",
|
||||
"set_stream", "get_stream",
|
||||
"set_input_stream", "get_input_stream",
|
||||
@@ -128,6 +135,23 @@ One JSON object per message; field `type` selects the command.
|
||||
}
|
||||
```
|
||||
|
||||
### Host link
|
||||
|
||||
| Client `type` | Reply `type` | Notes |
|
||||
|---------------|--------------|--------|
|
||||
| `get_host` | `host_status` | `transport`, `endpoint`, `connected` |
|
||||
| `list_serial_ports` | `serial_ports` | `{ports:[…]}` |
|
||||
| `ble_scan` | `ble_scan_result` | optional `timeout_s`, `name_filter`; disconnects active BLE first |
|
||||
| `host_connect` | `host_status` | `transport:"uart","port"` or `transport:"ble","address","name?"` |
|
||||
| `host_disconnect` | `host_status` | |
|
||||
|
||||
```json
|
||||
{"type":"host_connect","transport":"ble","address":"AA:BB:CC:DD:EE:FF","name":"Powerpod"}
|
||||
{"type":"host_status","transport":"ble","endpoint":"AA:BB:…","connected":true,"success":true}
|
||||
```
|
||||
|
||||
If the host is not connected, other commands that need the master return their usual error in the status reply.
|
||||
|
||||
### `list_clients`
|
||||
|
||||
Request: `{"type":"list_clients"}`
|
||||
|
||||
@@ -73,6 +73,120 @@ func hostAccelStream(h hostConn, req *pb.AccelStreamRequest) (*pb.AccelStreamRes
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostFindMe(h hostConn, clientID uint32) error {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_FIND_ME,
|
||||
Payload: &pb.UartMessage_EspnowFindMeRequest{
|
||||
EspnowFindMeRequest: &pb.EspNowFindMeRequest{ClientId: clientID},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_FIND_ME)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "FIND_ME")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetEspnowFindMeResponse()
|
||||
if r == nil {
|
||||
return fmt.Errorf("missing espnow_find_me_response")
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return fmt.Errorf("find-me rejected (client_id=%d)", r.GetClientId())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostRestart(h hostConn, clientID uint32) error {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_RESTART,
|
||||
Payload: &pb.UartMessage_RestartRequest{
|
||||
RestartRequest: &pb.RestartRequest{ClientId: clientID},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_RESTART)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "RESTART")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetRestartResponse()
|
||||
if r == nil {
|
||||
return fmt.Errorf("missing restart_response")
|
||||
}
|
||||
if !r.GetSuccess() {
|
||||
return fmt.Errorf("restart rejected (client_id=%d)", r.GetClientId())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostSetLogLevel(h hostConn, write bool, level uint32) (*pb.SetLogLevelResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_SET_LOG_LEVEL,
|
||||
Payload: &pb.UartMessage_SetLogLevelRequest{
|
||||
SetLogLevelRequest: &pb.SetLogLevelRequest{Write: write, Level: level},
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_SET_LOG_LEVEL)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "SET_LOG_LEVEL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetSetLogLevelResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing set_log_level_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostLedRing(h hostConn, req *pb.LedRingProgressRequest) (*pb.LedRingProgressResponse, error) {
|
||||
msg := &pb.UartMessage{
|
||||
Type: pb.MessageType_LED_RING,
|
||||
Payload: &pb.UartMessage_LedRingProgressRequest{
|
||||
LedRingProgressRequest: req,
|
||||
},
|
||||
}
|
||||
body, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
payload := append([]byte{byte(pb.MessageType_LED_RING)}, body...)
|
||||
respPayload, err := h.exchangePayload(payload, "LED_RING")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var respMsg pb.UartMessage
|
||||
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
r := respMsg.GetLedRingProgressResponse()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("missing led_ring_progress_response")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func hostEchoPing(h hostConn, clientID uint32) (*EchoPingResult, error) {
|
||||
t0 := time.Now()
|
||||
timestampUs := uint64(t0.UnixMicro())
|
||||
|
||||
@@ -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
@@ -27,7 +27,7 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, " ble-rtt Host↔Master RTT via VERSION (N samples)\n")
|
||||
fmt.Fprintf(os.Stderr, " ble-stream enable stream + measure BleStreamTick window\n")
|
||||
fmt.Fprintf(os.Stderr, " test run automated scenario (see testdata/)\n")
|
||||
fmt.Fprintf(os.Stderr, " serve web dashboard (UART only)\n")
|
||||
fmt.Fprintf(os.Stderr, " serve web dashboard (optional -port / -ble; connect from UI)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota UART OTA upload (A/B partitions; UART host only)\n")
|
||||
fmt.Fprintf(os.Stderr, " ota-progress query per-slave ESP-NOW OTA progress on master\n")
|
||||
fmt.Fprintf(os.Stderr, " led-ring set LED ring progress bar (0–100%%, rgb, intensity)\n")
|
||||
@@ -61,16 +61,17 @@ func main() {
|
||||
}
|
||||
runErr = runTest(*portName, *baud, flag.Args()[1:])
|
||||
case "serve", "web", "dashboard":
|
||||
if *useBLE {
|
||||
fmt.Fprintf(os.Stderr, "serve requires -port (UART only in this release)\n")
|
||||
if *useBLE && *portName != "" {
|
||||
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *portName == "" {
|
||||
fmt.Fprintf(os.Stderr, "command %q requires -port\n\n", cmd)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
runErr = runServe(*portName, *baud, flag.Args()[1:])
|
||||
runErr = runServe(serveOpts{
|
||||
portName: *portName,
|
||||
baud: *baud,
|
||||
useBLE: *useBLE,
|
||||
bleName: *bleName,
|
||||
settle: time.Duration(*bleSettle * float64(time.Second)),
|
||||
}, flag.Args()[1:])
|
||||
case "version", "clients", "client-info", "deadzone", "accel-deadzone", "tap-config", "tap_config", "tap-notify", "tap_notify", "cache-status", "cache_status", "accel-stream", "accel_stream", "unicast-test", "unicast_test", "echo-ping", "echo_ping", "ble-rtt", "ble_rtt", "ble-stream", "ble_stream", "led-ring", "led_ring", "find-me", "find_me", "restart", "log-level", "log_level", "ota", "ota-progress", "ota_progress":
|
||||
if *useBLE && *portName != "" {
|
||||
fmt.Fprintf(os.Stderr, "use either -port or -ble, not both\n")
|
||||
|
||||
+3
-175
@@ -1,12 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
import "errors"
|
||||
|
||||
// errUARTBusy is returned when the port is held for OTA (poller should not treat as unplug).
|
||||
var errUARTBusy = errors.New("uart busy (OTA in progress)")
|
||||
@@ -14,171 +8,5 @@ var errUARTBusy = errors.New("uart busy (OTA in progress)")
|
||||
// errOTAInProgress is returned when a second OTA upload is attempted while one is running.
|
||||
var errOTAInProgress = errors.New("OTA upload already in progress")
|
||||
|
||||
// managedSerial keeps the UART open and reconnects after I/O failures or unplug.
|
||||
type managedSerial struct {
|
||||
portName string
|
||||
baud int
|
||||
quiet bool
|
||||
|
||||
mu sync.Mutex
|
||||
sp *serialPort
|
||||
otaActive bool // UART held for firmware upload; poll/API must not interleave
|
||||
}
|
||||
|
||||
func newManagedSerial(portName string, baud int) *managedSerial {
|
||||
return &managedSerial{
|
||||
portName: portName,
|
||||
baud: baud,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managedSerial) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.closeLocked()
|
||||
}
|
||||
|
||||
func (m *managedSerial) IsConnected() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.sp != nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) openLocked() error {
|
||||
sp, err := openSerial(m.portName, m.baud)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sp.quiet = m.quiet
|
||||
m.sp = sp
|
||||
if !m.quiet {
|
||||
log.Printf("UART %s connected (%d baud)", m.portName, m.baud)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *managedSerial) closeLocked() error {
|
||||
if m.sp == nil {
|
||||
return nil
|
||||
}
|
||||
err := m.sp.port.Close()
|
||||
m.sp = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedSerial) invalidateLocked(reason error) {
|
||||
if m.sp == nil {
|
||||
return
|
||||
}
|
||||
if !m.quiet {
|
||||
log.Printf("UART %s disconnected: %v", m.portName, reason)
|
||||
}
|
||||
_ = m.closeLocked()
|
||||
}
|
||||
|
||||
func (m *managedSerial) withPort(fn func(*serialPort) error) error {
|
||||
return m.withPortLocked(false, fn)
|
||||
}
|
||||
|
||||
// withPortPoll is like withPort but returns errUARTBusy during OTA (no TryLock race).
|
||||
func (m *managedSerial) withPortPoll(fn func(*serialPort) error) error {
|
||||
return m.withPortLocked(true, fn)
|
||||
}
|
||||
|
||||
func (m *managedSerial) withPortLocked(poll bool, fn func(*serialPort) error) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.otaActive {
|
||||
return errUARTBusy
|
||||
}
|
||||
|
||||
if m.sp == nil {
|
||||
if err := m.openLocked(); err != nil {
|
||||
return fmt.Errorf("%s: %w", m.portName, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := fn(m.sp)
|
||||
if err != nil {
|
||||
m.invalidateLocked(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *managedSerial) recoverAfterMasterRestart() {
|
||||
const bootWait = 6 * time.Second
|
||||
|
||||
m.mu.Lock()
|
||||
m.closeLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("UART: master restart — waiting %s for boot", bootWait)
|
||||
time.Sleep(bootWait)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err := m.openLocked(); err != nil {
|
||||
log.Printf("UART reconnect after master restart: %v", err)
|
||||
return
|
||||
}
|
||||
flushSerialInput(m.sp)
|
||||
log.Printf("UART %s ready after master restart", m.portName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadVia(m.withPort, payload, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePayloadPoll(payload []byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangePayloadVia(m.withPortPoll, payload, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePayloadVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
payload []byte, cmdName string,
|
||||
) ([]byte, error) {
|
||||
var resp []byte
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.exchangePayloadLocked(payload, cmdName, readTimeout)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangeVia(m.withPort, cmdID, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangePoll(cmdID byte, cmdName string) ([]byte, error) {
|
||||
return m.exchangeVia(m.withPortPoll, cmdID, cmdName)
|
||||
}
|
||||
|
||||
func (m *managedSerial) exchangeVia(
|
||||
portFn func(func(*serialPort) error) error,
|
||||
cmdID byte, cmdName string,
|
||||
) ([]byte, error) {
|
||||
var resp []byte
|
||||
err := portFn(func(sp *serialPort) error {
|
||||
var e error
|
||||
resp, e = sp.exchangeLocked(cmdID, cmdName)
|
||||
return e
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func disconnectedState(portName string, err error) DashboardState {
|
||||
msg := "UART disconnected"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
return DashboardState{
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
SerialPort: portName,
|
||||
UARTConnected: false,
|
||||
SerialOK: false,
|
||||
SerialError: msg,
|
||||
Master: MasterView{OK: false, Error: msg},
|
||||
Clients: []ClientView{},
|
||||
}
|
||||
}
|
||||
// managedSerial is an alias for managedHost (UART-first history).
|
||||
type managedSerial = managedHost
|
||||
|
||||
+251
-54
@@ -179,8 +179,8 @@
|
||||
<span class="navbar-brand mb-0 h1">Powerpod</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="badge rounded-pill me-1"
|
||||
:class="state.uart_connected ? 'badge-online' : 'badge-offline'"
|
||||
x-text="state.uart_connected ? 'UART' : 'UART off'"></span>
|
||||
:class="hostUp() ? 'badge-online' : 'badge-offline'"
|
||||
x-text="hostBadge()"></span>
|
||||
<span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'"
|
||||
x-text="wsConnected ? 'WS' : 'WS off'"></span>
|
||||
<small class="text-muted ms-2" x-text="state.updated_at || '—'"></small>
|
||||
@@ -195,19 +195,94 @@
|
||||
x-text="configMsg"></div>
|
||||
</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">
|
||||
<section class="col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Master (BMA456 lokal)</div>
|
||||
<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>
|
||||
<template x-if="!state.uart_connected">
|
||||
<template x-if="!hostUp()">
|
||||
<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 x-if="state.uart_connected && !state.serial_ok">
|
||||
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div>
|
||||
<template x-if="hostUp() && !state.serial_ok">
|
||||
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Host error'"></div>
|
||||
</template>
|
||||
<template x-if="state.master?.ok">
|
||||
<dl class="row mb-0">
|
||||
@@ -242,26 +317,26 @@
|
||||
min="0" max="4095" step="1"
|
||||
placeholder="z. B. 100"
|
||||
x-model.number="masterDz"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="readMasterDeadzone()"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Lesen
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
@click="setMasterDeadzone()"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Setzen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm"
|
||||
@click="findMe()"
|
||||
:disabled="busy || !state.uart_connected"
|
||||
:disabled="busy || !hostUp()"
|
||||
title="LED-Ring: Rot/Grün/Blau je 3×">
|
||||
Find me
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="restart(0)"
|
||||
:disabled="busy || !state.uart_connected"
|
||||
:disabled="busy || !hostUp()"
|
||||
title="Master neu starten">
|
||||
Restart
|
||||
</button>
|
||||
@@ -272,7 +347,7 @@
|
||||
<div class="d-flex flex-wrap gap-2 align-items-end">
|
||||
<select id="master-log-level" class="form-select form-select-sm config-input"
|
||||
x-model.number="masterLogLevel"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
<option value="0">None (aus)</option>
|
||||
<option value="1">Error</option>
|
||||
<option value="2">Warn</option>
|
||||
@@ -282,12 +357,12 @@
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="readMasterLogLevel()"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Lesen
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
@click="setMasterLogLevel()"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Setzen
|
||||
</button>
|
||||
</div>
|
||||
@@ -297,65 +372,65 @@
|
||||
<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"
|
||||
min="0" max="15" x-model.number="masterTapCfg.tap_sens_thres"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<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"
|
||||
min="0" max="65535" x-model.number="masterTapCfg.max_gest_dur"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<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"
|
||||
min="0" max="65535" x-model.number="masterTapCfg.tap_shock_dur"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<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"
|
||||
min="0" max="65535" x-model.number="masterTapCfg.quite_time_after_gest"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<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"
|
||||
min="0" max="1" x-model.number="masterTapCfg.wait_for_timeout"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<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"
|
||||
min="0" max="2" x-model.number="masterTapCfg.axis_sel"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center mt-2 small">
|
||||
<label class="tap-toggle mb-0">
|
||||
<input type="checkbox" x-model="masterTapCfg.enable_single"
|
||||
:disabled="busy || !state.uart_connected"> Single
|
||||
:disabled="busy || !hostUp()"> Single
|
||||
</label>
|
||||
<label class="tap-toggle mb-0">
|
||||
<input type="checkbox" x-model="masterTapCfg.enable_double"
|
||||
:disabled="busy || !state.uart_connected"> Double
|
||||
:disabled="busy || !hostUp()"> Double
|
||||
</label>
|
||||
<label class="tap-toggle mb-0">
|
||||
<input type="checkbox" x-model="masterTapCfg.enable_triple"
|
||||
:disabled="busy || !state.uart_connected"> Triple
|
||||
:disabled="busy || !hostUp()"> Triple
|
||||
</label>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="readMasterTapConfig()"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Lesen
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
@click="setMasterTapConfig()"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Setzen
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected">
|
||||
UART nicht verbunden — Eingabe gesperrt.
|
||||
<p class="text-muted small mt-2 mb-0" x-show="!hostUp()">
|
||||
Host nicht verbunden — Eingabe gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,12 +448,12 @@
|
||||
:disabled="busy">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="setDeadzoneAll(allDz)"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Alle Slaves
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="setTapConfigAll()"
|
||||
:disabled="busy || !state.uart_connected"
|
||||
:disabled="busy || !hostUp()"
|
||||
title="Master-Tap-Config an alle Slaves (ESP-NOW)">
|
||||
Tap-Cfg alle
|
||||
</button>
|
||||
@@ -395,7 +470,7 @@
|
||||
class="btn btn-sm"
|
||||
:class="state.live_stream ? 'btn-warning' : 'btn-success'"
|
||||
@click="setLiveStream(!state.live_stream)"
|
||||
:disabled="busy || !state.uart_connected || !(state.clients || []).length"
|
||||
:disabled="busy || !hostUp() || !(state.clients || []).length"
|
||||
x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"></button>
|
||||
<span class="text-muted small">Tap alle Slaves:</span>
|
||||
<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>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="setTapNotifyAll(allTapSingle, allTapDouble, allTapTriple)"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Tap setzen
|
||||
</button>
|
||||
</div>
|
||||
@@ -445,7 +520,7 @@
|
||||
<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"
|
||||
@click="setTapConfig(c.id, masterTapCfg)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
title="Master-Tap-Config an diesen Slave">
|
||||
Set
|
||||
</button>
|
||||
@@ -462,7 +537,7 @@
|
||||
class="btn btn-sm"
|
||||
:class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'"
|
||||
@click="setAccelStream(c.id, !c.accel_stream)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
x-text="c.accel_stream ? 'Aus' : 'An'"
|
||||
title="ESP-NOW Accel-Stream auf Slave"></button>
|
||||
</td>
|
||||
@@ -472,19 +547,19 @@
|
||||
<input type="checkbox"
|
||||
:checked="c.tap_notify_single"
|
||||
@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 class="tap-toggle" title="Double tap">
|
||||
<input type="checkbox"
|
||||
:checked="c.tap_notify_double"
|
||||
@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 class="tap-toggle" title="Triple tap">
|
||||
<input type="checkbox"
|
||||
:checked="c.tap_notify_triple"
|
||||
@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>
|
||||
</div>
|
||||
</td>
|
||||
@@ -500,36 +575,36 @@
|
||||
:disabled="busy">
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
|
||||
:disabled="busy || !state.uart_connected || !c.available">
|
||||
:disabled="busy || !hostUp() || !c.available">
|
||||
Setzen
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="unicastTest(c.id)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
title="ESP-NOW Unicast-Test">
|
||||
Test
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
@click="echoPing(c.id)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
title="ESP-NOW Timestamp-Echo (Round-Trip-Latenz)">
|
||||
Ping
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
@click="ledRing({ clientId: c.id })"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
title="LED-Ring (aktueller Modus)">
|
||||
LED
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm"
|
||||
@click="ledRing({ clientId: c.id, mode: 'find-me' })"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
title="Find me">
|
||||
Find
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="restart(c.id)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
:disabled="busy || !hostUp() || !c.available"
|
||||
title="Neustart per ESP-NOW">
|
||||
Restart
|
||||
</button>
|
||||
@@ -602,17 +677,17 @@
|
||||
<div class="col-md-4 d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
@click="ledRing({ clientId: 0 })"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Master
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm"
|
||||
@click="ledRing({ allClients: true, slavesOnly: true })"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Alle Slaves
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="ledRing({ allClients: true })"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Alle + Master
|
||||
</button>
|
||||
</div>
|
||||
@@ -621,23 +696,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="col-12">
|
||||
<section class="col-12" x-show="isUARTHost() || !hostUp()">
|
||||
<div class="card">
|
||||
<div class="card-header">Firmware OTA (A/B)</div>
|
||||
<div class="card-body">
|
||||
<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.
|
||||
Während des Uploads pausiert das Live-Polling.
|
||||
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. Bei BLE-Host ist OTA nicht verfügbar.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
|
||||
@change="otaFile = $event.target.files[0]"
|
||||
:disabled="ota.active || busy || !state.uart_connected">
|
||||
:disabled="ota.active || busy || !isUARTHost()">
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
||||
<button type="button" class="btn btn-primary btn-sm"
|
||||
@click="uploadOTA()"
|
||||
:disabled="ota.active || busy || !state.uart_connected || !otaFile">
|
||||
:disabled="ota.active || busy || !isUARTHost() || !otaFile">
|
||||
Firmware hochladen
|
||||
</button>
|
||||
<span class="text-muted small" x-show="otaFile"
|
||||
@@ -666,7 +741,7 @@
|
||||
<td class="ota-restart-col text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="restart(0)"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Restart
|
||||
</button>
|
||||
</td>
|
||||
@@ -703,7 +778,7 @@
|
||||
<td class="ota-restart-col text-end">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="restart(row.id)"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
:disabled="busy || !hostUp()">
|
||||
Restart
|
||||
</button>
|
||||
</td>
|
||||
@@ -733,6 +808,13 @@
|
||||
state: { master: {}, clients: [] },
|
||||
ws: null,
|
||||
wsConnected: false,
|
||||
connTab: 'uart',
|
||||
uartPort: '',
|
||||
serialPorts: [],
|
||||
bleDevices: [],
|
||||
bleScanning: false,
|
||||
bleScanErr: '',
|
||||
hostBusy: false,
|
||||
masterDz: 100,
|
||||
masterDzSeeded: false,
|
||||
masterLogLevel: 0,
|
||||
@@ -779,9 +861,121 @@
|
||||
blinkMs: 350,
|
||||
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() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = proto + '//' + location.host + '/ws';
|
||||
this.refreshSerialPorts();
|
||||
if (this._batteryTimer) clearInterval(this._batteryTimer);
|
||||
this._batteryTimer = setInterval(() => this.refreshBattery(), 5000);
|
||||
if (this._tapTimer) clearInterval(this._tapTimer);
|
||||
@@ -791,6 +985,7 @@
|
||||
this.ws.onopen = () => {
|
||||
this.wsConnected = true;
|
||||
this.refreshBattery();
|
||||
this.refreshSerialPorts();
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.wsConnected = false;
|
||||
@@ -809,6 +1004,8 @@
|
||||
}
|
||||
const prev = this.state;
|
||||
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.syncTapDisplay(msg.clients || []);
|
||||
if (!this.masterDzSeeded && msg.master?.deadzone != null) {
|
||||
@@ -898,7 +1095,7 @@
|
||||
};
|
||||
},
|
||||
async refreshBattery() {
|
||||
if (!this.state?.uart_connected) return;
|
||||
if (!this.hostUp()) return;
|
||||
try {
|
||||
const r = await fetch('/api/battery?all_clients=1');
|
||||
if (!r.ok) return;
|
||||
|
||||
Reference in New Issue
Block a user