Harden BLE host link and expose latency tools in the dashboard.

Add BLE RTT/stream tests and live tick median in the UI, fix concurrent WebSocket writes and late BATTERY replies, and skip 2M PHY updates that assert under ESP-NOW coexistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 23:27:39 +02:00
co-authored by Cursor
parent aa67edfe94
commit af20635047
14 changed files with 893 additions and 293 deletions
+135 -12
View File
@@ -77,15 +77,80 @@ type DashboardState struct {
SerialError string `json:"serial_error,omitempty"`
/** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */
LiveStream bool `json:"live_stream"`
Master MasterView `json:"master"`
Clients []ClientView `json:"clients"`
/** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */
BleTick *BleTickView `json:"ble_tick,omitempty"`
Master MasterView `json:"master"`
Clients []ClientView `json:"clients"`
}
// BleTickView is median/mean inter-departure of BLE_STREAM_TICK (ESP timer).
type BleTickView struct {
PeriodMs uint32 `json:"period_ms"`
MedianMs float64 `json:"median_ms"`
MeanMs float64 `json:"mean_ms"`
MinMs float64 `json:"min_ms"`
MaxMs float64 `json:"max_ms"`
Samples int `json:"samples"`
LastEspUs uint64 `json:"last_esp_us,omitempty"`
}
const bleTickDeltaCap = 64
type bleTickTracker struct {
lastEspUs uint64
haveLast bool
periodMs uint32
deltas [bleTickDeltaCap]float64
next int
count int
}
func (t *bleTickTracker) reset() {
*t = bleTickTracker{}
}
func (t *bleTickTracker) add(espUs uint64, periodMs uint32) BleTickView {
if periodMs != 0 {
t.periodMs = periodMs
}
if t.haveLast && espUs > t.lastEspUs {
dt := float64(espUs-t.lastEspUs) / 1000.0
t.deltas[t.next] = dt
t.next = (t.next + 1) % bleTickDeltaCap
if t.count < bleTickDeltaCap {
t.count++
}
}
t.lastEspUs = espUs
t.haveLast = true
samples := make([]float64, 0, t.count)
if t.count < bleTickDeltaCap {
samples = append(samples, t.deltas[:t.count]...)
} else {
samples = append(samples, t.deltas[t.next:]...)
samples = append(samples, t.deltas[:t.next]...)
}
st := makeLatencyStats(samples)
return BleTickView{
PeriodMs: t.periodMs,
MedianMs: st.MedMs,
MeanMs: st.MeanMs,
MinMs: st.MinMs,
MaxMs: st.MaxMs,
Samples: st.N,
LastEspUs: espUs,
}
}
type wsHub struct {
mu sync.RWMutex
writeMu sync.Mutex // gorilla websocket: one writer at a time
clients map[*websocket.Conn]struct{}
state DashboardState
liveStream bool
bleTicks bleTickTracker
lastPush time.Time // throttle BLE tick WS pushes
}
func newWSHub() *wsHub {
@@ -96,6 +161,10 @@ func (h *wsHub) setState(st DashboardState) {
h.mu.Lock()
prev := h.state
st.LiveStream = prev.LiveStream
if st.BleTick == nil && prev.BleTick != nil && st.LiveStream {
cp := *prev.BleTick
st.BleTick = &cp
}
st.Clients = preserveClientAccel(st.Clients, prev.Clients, st.LiveStream)
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
st.Clients = preserveClientTap(st.Clients, prev.Clients)
@@ -140,15 +209,25 @@ func (h *wsHub) unregister(c *websocket.Conn) {
h.mu.Unlock()
}
// writeJSON sends to one client; removes it on error or panic (closed connection).
// dropClient removes the client and closes the socket so the browser reconnects.
func (h *wsHub) dropClient(c *websocket.Conn) {
h.unregister(c)
_ = c.Close()
}
// writeJSON sends to one client; removes+closes it on error or panic.
func (h *wsHub) writeJSON(c *websocket.Conn, data []byte) {
defer func() {
if recover() != nil {
h.unregister(c)
h.dropClient(c)
}
}()
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
h.unregister(c)
h.writeMu.Lock()
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
err := c.WriteMessage(websocket.TextMessage, data)
h.writeMu.Unlock()
if err != nil {
h.dropClient(c)
}
}
@@ -417,6 +496,8 @@ func (h *wsHub) patchLiveStream(enabled bool) {
st := h.state
st.LiveStream = enabled
if !enabled {
h.bleTicks.reset()
st.BleTick = nil
for i := range st.Clients {
st.Clients[i].AccelValid = false
st.Clients[i].AccelX = 0
@@ -501,6 +582,49 @@ func (h *wsHub) mergeAccel(samples []*pb.AccelSample) {
}
}
// mergeBleStreamTick applies accel/tap from a BLE tick and updates inter-tick stats.
// State is updated every tick; WebSocket push is throttled (~50 Hz) so concurrent
// writers (poller/battery) cannot flood or race the gorilla conn.
func (h *wsHub) mergeBleStreamTick(tick *pb.BleStreamTick) {
if tick == nil || !h.liveStreamEnabled() {
return
}
cache := &pb.CacheStatusResponse{Clients: tick.GetClients()}
const minPushGap = 20 * time.Millisecond
h.mu.Lock()
view := h.bleTicks.add(tick.GetEspTimestampUs(), tick.GetPeriodMs())
st := h.state
st.Clients = applyAccelSamples(st.Clients, accelSamplesFromCacheStatus(cache))
st.Clients = applyTapEvents(st.Clients, tapEventsFromCacheStatus(cache))
st.BleTick = &view
st.UpdatedAt = time.Now().Format(time.RFC3339)
h.state = st
push := time.Since(h.lastPush) >= minPushGap
if push {
h.lastPush = time.Now()
}
var conns []*websocket.Conn
if push {
conns = make([]*websocket.Conn, 0, len(h.clients))
for c := range h.clients {
conns = append(conns, c)
}
}
h.mu.Unlock()
if !push {
return
}
data, err := json.Marshal(st)
if err != nil {
return
}
for _, c := range conns {
h.writeJSON(c, data)
}
}
func (h *wsHub) mergeTap(events []*pb.TapEvent) {
if len(events) == 0 || !h.liveStreamEnabled() {
return
@@ -673,7 +797,7 @@ func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, s
case <-stop:
return
case <-ticker.C:
if !link.IsConnected() {
if !link.IsConnected() || link.PollPaused() {
continue
}
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
@@ -694,7 +818,7 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
case <-stop:
return
case <-ticker.C:
if !hub.liveStreamEnabled() {
if !hub.liveStreamEnabled() || link.PollPaused() {
continue
}
// BLE live path prefers BLE_STREAM_TICK pushes.
@@ -720,7 +844,7 @@ func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *acc
return
default:
}
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() {
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() || link.PollPaused() {
select {
case <-stop:
return
@@ -732,12 +856,11 @@ func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *acc
if err != nil {
continue
}
cache, err := cacheStatusFromBLEStreamPayload(payload)
tick, err := parseBleStreamTickPayload(payload)
if err != nil {
continue
}
hub.mergeAccel(accelSamplesFromCacheStatus(cache))
hub.mergeTap(tapEventsFromCacheStatus(cache))
hub.mergeBleStreamTick(tick)
}
}