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
+71 -14
View File
@@ -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()