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

+ Scan startet eine kurze Suche (trennt aktive BLE-Verbindung). +

+
+
+
+
Master (BMA456 lokal)
-

+

Deadzone nur am Master — Slaves bleiben unverändert.

-