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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 22:40:28 +02:00
co-authored by Cursor
parent 92a13d7fd0
commit aa67edfe94
18 changed files with 1573 additions and 404 deletions
+160 -8
View File
@@ -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)
})
}