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