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
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type bleRttAPIRequest struct {
Count *int `json:"count"`
Warmup *int `json:"warmup"`
SettleS *float64 `json:"settle_s"`
}
type bleStreamAPIRequest struct {
ClientID *uint32 `json:"client_id"`
PeriodMs *uint32 `json:"period_ms"`
Count *int `json:"count"`
TolMs *float64 `json:"tol_ms"`
TimeoutS *float64 `json:"timeout_s"`
}
func mountBleTestAPI(mux *http.ServeMux, link *managedHost) {
mux.HandleFunc("/api/ble-rtt", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveBleRtt(w, r, link)
})
mux.HandleFunc("/api/ble-stream", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
serveBleStreamTest(w, r, link)
})
}
func requireBLEHost(w http.ResponseWriter, link *managedHost) bool {
if link.Transport() != hostTransportBLE || !link.IsConnected() {
writeJSON(w, http.StatusBadRequest, bleRttResult{
Error: "BLE host required (connect via BLE, DIP8=1)",
})
return false
}
return true
}
func serveBleRtt(w http.ResponseWriter, r *http.Request, link *managedHost) {
if !requireBLEHost(w, link) {
return
}
var body bleRttAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, bleRttResult{Error: "invalid JSON"})
return
}
count, warmup := 50, 5
settle := 0.0
if body.Count != nil {
count = *body.Count
}
if body.Warmup != nil {
warmup = *body.Warmup
}
if body.SettleS != nil {
settle = *body.SettleS
}
if !link.beginPausePoll() {
writeJSON(w, http.StatusConflict, bleRttResult{Error: "host busy"})
return
}
defer link.endPausePoll()
out := measureBleRtt(link, count, warmup, time.Duration(settle*float64(time.Second)))
status := http.StatusOK
if !out.Success {
status = http.StatusBadRequest
}
writeJSON(w, status, out)
}
func serveBleStreamTest(w http.ResponseWriter, r *http.Request, link *managedHost) {
if !requireBLEHost(w, link) {
return
}
var body bleStreamAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, bleStreamResult{Error: "invalid JSON"})
return
}
count := 50
periodMs := uint32(16)
tolMs := 2.0
timeoutS := 15.0
var clientID uint32
if body.Count != nil {
count = *body.Count
}
if body.PeriodMs != nil {
periodMs = *body.PeriodMs
}
if body.TolMs != nil {
tolMs = *body.TolMs
}
if body.TimeoutS != nil {
timeoutS = *body.TimeoutS
}
if body.ClientID != nil {
clientID = *body.ClientID
}
if !link.beginPausePoll() {
writeJSON(w, http.StatusConflict, bleStreamResult{Error: "host busy"})
return
}
defer link.endPausePoll()
out := measureBleStream(link, clientID, periodMs, count, tolMs, timeoutS)
status := http.StatusOK
if !out.Success {
status = http.StatusBadRequest
}
writeJSON(w, status, out)
}
+1
View File
@@ -94,6 +94,7 @@ type otaAPIResponse struct {
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
mountHostAPI(mux, link)
mountBleTestAPI(mux, link)
mountLiveStreamAPI(mux, hub)
mountAccelStreamAPI(mux, link, hub, streamCtl)
mountTapAPI(mux, link, hub, tapCtl)
+99 -100
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sync"
@@ -180,12 +181,31 @@ type cachedTapEvent struct {
type wsSubscriber struct {
conn *websocket.Conn
writeMu sync.Mutex // gorilla: one writer per connection
receiveInput bool
interval time.Duration
preFetch time.Duration
lastInputSent time.Time
}
func (sub *wsSubscriber) writeBytes(data []byte) error {
if sub == nil {
return fmt.Errorf("nil subscriber")
}
sub.writeMu.Lock()
defer sub.writeMu.Unlock()
_ = sub.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
return sub.conn.WriteMessage(websocket.TextMessage, data)
}
func (sub *wsSubscriber) writeJSON(v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
_ = sub.writeBytes(data)
}
type pendingInputCache struct {
cache *pb.CacheStatusResponse
readAt time.Time
@@ -269,9 +289,7 @@ func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *ws
"set_led_ring", "get_battery",
},
}
if data, err := json.Marshal(hello); err == nil {
_ = conn.WriteMessage(websocket.TextMessage, data)
}
sub.writeJSON(hello)
return sub
}
@@ -467,7 +485,11 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
now := time.Now()
h.mu.Lock()
defer h.mu.Unlock()
type pending struct {
conn *websocket.Conn
sub *wsSubscriber
}
var out []pending
for conn, sub := range h.clients {
if !sub.receiveInput {
continue
@@ -476,13 +498,26 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
continue
}
sub.lastInputSent = now
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
delete(h.clients, conn)
_ = conn.Close()
out = append(out, pending{conn: conn, sub: sub})
}
h.mu.Unlock()
for _, p := range out {
if err := p.sub.writeBytes(data); err != nil {
h.mu.Lock()
delete(h.clients, p.conn)
h.mu.Unlock()
_ = p.conn.Close()
}
}
}
func (h *accelStreamHub) clientsFromCache(cache *pb.CacheStatusResponse) []InputClientSample {
h.mu.Lock()
defer h.mu.Unlock()
return h.inputClientsFromCacheLocked(cache, time.Now())
}
func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
ticker := time.NewTicker(minAPIStreamInterval)
defer ticker.Stop()
@@ -496,7 +531,7 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
case <-hub.configChanged:
pending = nil
case now := <-ticker.C:
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) {
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) || link.PollPaused() {
pending = nil
continue
}
@@ -508,14 +543,11 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
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,
Clients: hub.clientsFromCache(cache),
})
pending = nil
continue
@@ -557,14 +589,11 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
}
if pending.cache != nil && now.Sub(pending.readAt) >= waitPreFetch {
hub.mu.RLock()
clients := hub.inputClientsFromCacheLocked(pending.cache, now)
hub.mu.RUnlock()
hub.deliverInput(InputStreamMessage{
Type: "input",
T: ts,
Success: true,
Clients: clients,
Clients: hub.clientsFromCache(pending.cache),
})
pending = nil
}
@@ -582,46 +611,29 @@ func inputPollingActive(dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl)
return dash != nil && dash.anyAccelStreamEnabled()
}
func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
func writeStreamStatus(sub *wsSubscriber, msg StreamStatusMessage) {
sub.writeJSON(msg)
}
func writeBatteryStatus(conn *websocket.Conn, out batteryAPIResponse) {
func writeBatteryStatus(sub *wsSubscriber, out batteryAPIResponse) {
out.Type = "battery_status"
data, err := json.Marshal(out)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
sub.writeJSON(out)
}
func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) {
func writeLedRingStatus(sub *wsSubscriber, out ledRingAPIResponse) {
out.Type = "led_ring_status"
data, err := json.Marshal(out)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
sub.writeJSON(out)
}
func writeInputStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
msg := InputStreamStatusMessage{
func writeInputStreamStatus(sub *wsSubscriber, out accelStreamAPIResponse) {
sub.writeJSON(InputStreamStatusMessage{
Type: "input_stream_status",
ClientID: out.ClientID,
Enabled: out.Enabled,
Success: out.Success,
SlavesUpdated: out.SlavesUpdated,
Error: out.Error,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
})
}
func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
@@ -640,16 +652,12 @@ func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
}
}
func writeClientList(conn *websocket.Conn, msg ClientListMessage) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
func writeClientList(sub *wsSubscriber, msg ClientListMessage) {
sub.writeJSON(msg)
}
func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
msg := TapNotifyStatusMessage{
func writeTapNotifyStatus(sub *wsSubscriber, out tapNotifyAPIResponse) {
sub.writeJSON(TapNotifyStatusMessage{
Type: "tap_notify_status",
ClientID: out.ClientID,
Single: out.Single,
@@ -658,12 +666,7 @@ func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
Success: out.Success,
SlavesUpdated: out.SlavesUpdated,
Error: out.Error,
}
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
})
}
func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse {
@@ -696,16 +699,16 @@ func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyC
return out
}
func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
func handleAccelWSCommand(sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
var cmd accelWSCommand
if err := json.Unmarshal(data, &cmd); err != nil {
writeStreamStatus(conn, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
writeStreamStatus(sub, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"})
return
}
switch cmd.Type {
case "get_host":
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
case "list_serial_ports":
ports, err := listSerialPorts()
@@ -714,7 +717,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
msg.Error = err.Error()
msg.Ports = []string{}
}
writeWSJSON(conn, msg)
writeWSJSON(sub, msg)
case "ble_scan":
timeout := time.Duration(cmd.TimeoutS * float64(time.Second))
@@ -730,7 +733,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
msg.Error = err.Error()
msg.Devices = []bleScanDevice{}
}
writeWSJSON(conn, msg)
writeWSJSON(sub, msg)
case "host_connect":
var err error
@@ -740,26 +743,26 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case hostTransportBLE:
err = link.ConnectBLE(cmd.Address, cmd.Name)
default:
writeHostStatus(conn, HostStatusMessage{
writeHostStatus(sub, HostStatusMessage{
Type: "host_status",
Error: "transport must be uart or ble",
})
return
}
if err != nil {
writeHostStatus(conn, hostStatusFromLink(link, false, err.Error()))
writeHostStatus(sub, hostStatusFromLink(link, false, err.Error()))
return
}
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
case "host_disconnect":
_ = link.Disconnect()
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
case "list_clients":
clients, err := link.listClientsPoll()
if err != nil {
writeClientList(conn, ClientListMessage{
writeClientList(sub, ClientListMessage{
Type: "client_list",
Error: err.Error(),
})
@@ -769,7 +772,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
for _, c := range clients {
out = append(out, clientInfoToAPI(c))
}
writeClientList(conn, ClientListMessage{
writeClientList(sub, ClientListMessage{
Type: "client_list",
Success: true,
Clients: out,
@@ -777,34 +780,34 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case "set_stream":
if cmd.Enable == nil {
writeStreamStatus(conn, StreamStatusMessage{
writeStreamStatus(sub, StreamStatusMessage{
Type: "stream_status",
Error: "enable required",
})
return
}
writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
writeStreamStatus(sub, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs, cmd.PreFetch))
case "get_stream":
writeStreamStatus(conn, hub.getStream(sub))
writeStreamStatus(sub, hub.getStream(sub))
case "set_input_stream":
if cmd.ClientID == 0 {
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
return
}
if cmd.Enable == nil {
writeInputStreamStatus(conn, accelStreamAPIResponse{
writeInputStreamStatus(sub, accelStreamAPIResponse{
ClientID: cmd.ClientID,
Error: "enable required",
})
return
}
writeInputStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
writeInputStreamStatus(sub, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable))
case "get_input_stream":
if cmd.ClientID == 0 {
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
return
}
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
@@ -812,7 +815,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
ClientId: cmd.ClientID,
})
if err != nil {
writeInputStreamStatus(conn, accelStreamAPIResponse{
writeInputStreamStatus(sub, accelStreamAPIResponse{
ClientID: cmd.ClientID,
Error: err.Error(),
})
@@ -821,7 +824,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
if ctl != nil {
ctl.Set(cmd.ClientID, resp.GetEnabled())
}
writeInputStreamStatus(conn, accelStreamAPIResponse{
writeInputStreamStatus(sub, accelStreamAPIResponse{
Enabled: resp.GetEnabled(),
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
@@ -830,15 +833,15 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case "set_tap_notify":
if cmd.AllClients {
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "single, double_tap, triple required"})
return
}
updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)
if err != nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: err.Error()})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: err.Error()})
return
}
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
Success: updated > 0,
SlavesUpdated: updated,
Single: *cmd.Single,
@@ -848,21 +851,21 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
return
}
if cmd.ClientID == 0 {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
return
}
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Error: "single, double_tap, triple required",
})
return
}
writeTapNotifyStatus(conn, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
writeTapNotifyStatus(sub, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple))
case "get_tap_notify":
if cmd.ClientID == 0 {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
return
}
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
@@ -870,7 +873,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
ClientId: cmd.ClientID,
})
if err != nil {
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Error: err.Error(),
})
@@ -879,7 +882,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
if tapCtl != nil {
tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple())
}
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
ClientID: cmd.ClientID,
Success: resp.GetSuccess(),
Single: resp.GetSingle(),
@@ -890,28 +893,28 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
case "set_led_ring":
var body ledRingAPIRequest
if err := json.Unmarshal(data, &body); err != nil {
writeLedRingStatus(conn, ledRingAPIResponse{Error: "invalid JSON"})
writeLedRingStatus(sub, ledRingAPIResponse{Error: "invalid JSON"})
return
}
if body.Mode == "" {
writeLedRingStatus(conn, ledRingAPIResponse{Error: "mode required"})
writeLedRingStatus(sub, ledRingAPIResponse{Error: "mode required"})
return
}
writeLedRingStatus(conn, applyLedRing(link, body))
writeLedRingStatus(sub, applyLedRing(link, body))
case "get_battery":
var body batteryAPIRequest
if err := json.Unmarshal(data, &body); err != nil {
writeBatteryStatus(conn, batteryAPIResponse{Error: "invalid JSON"})
writeBatteryStatus(sub, batteryAPIResponse{Error: "invalid JSON"})
return
}
if !body.AllClients && body.ClientID == 0 {
body.AllClients = true
}
writeBatteryStatus(conn, applyBatteryStatus(link, body))
writeBatteryStatus(sub, applyBatteryStatus(link, body))
default:
writeStreamStatus(conn, StreamStatusMessage{
writeStreamStatus(sub, StreamStatusMessage{
Type: "stream_status",
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)",
})
@@ -931,16 +934,12 @@ func hostStatusFromLink(link *managedSerial, success bool, errMsg string) HostSt
}
}
func writeHostStatus(conn *websocket.Conn, msg HostStatusMessage) {
writeWSJSON(conn, msg)
func writeHostStatus(sub *wsSubscriber, msg HostStatusMessage) {
sub.writeJSON(msg)
}
func writeWSJSON(conn *websocket.Conn, msg any) {
data, err := json.Marshal(msg)
if err != nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, data)
func writeWSJSON(sub *wsSubscriber, msg any) {
sub.writeJSON(msg)
}
func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) {
@@ -953,7 +952,7 @@ func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl
if err != nil {
return
}
handleAccelWSCommand(conn, sub, data, link, dash, ctl, tapCtl, hub)
handleAccelWSCommand(sub, data, link, dash, ctl, tapCtl, hub)
}
}
+49 -10
View File
@@ -175,7 +175,17 @@ func (b *bleLink) onNotify(buf []byte) {
select {
case b.respCh <- payload:
default:
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
// Prefer dropping the oldest queued frame so a late/stale reply
// does not block the matching response for the in-flight command.
select {
case <-b.respCh:
default:
}
select {
case b.respCh <- payload:
default:
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
}
}
}
}
@@ -189,9 +199,21 @@ func (b *bleLink) exchange(cmdID byte, cmdName string) ([]byte, error) {
}
func (b *bleLink) exchangePayload(payload []byte, cmdName string) ([]byte, error) {
return b.exchangePayloadTimeout(payload, cmdName, readTimeout)
}
func (b *bleLink) exchangePayloadTimeout(payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
b.mu.Lock()
defer b.mu.Unlock()
if len(payload) == 0 {
return nil, fmt.Errorf("empty payload")
}
expect := payload[0]
if timeout <= 0 {
timeout = readTimeout
}
frame, err := uartframe.EncodeFrame(payload)
if err != nil {
return nil, fmt.Errorf("encode frame: %w", err)
@@ -212,16 +234,33 @@ sent:
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
}
timer := time.NewTimer(readTimeout)
defer timer.Stop()
select {
case resp := <-b.respCh:
if !b.quiet {
log.Printf("BLE response (%d bytes)", len(resp))
deadline := time.Now().Add(timeout)
for {
remain := time.Until(deadline)
if remain <= 0 {
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
}
timer := time.NewTimer(remain)
select {
case resp := <-b.respCh:
timer.Stop()
if len(resp) == 0 {
continue
}
if resp[0] != expect {
// Stale reply (e.g. late BATTERY_STATUS after a short timeout).
if !b.quiet {
log.Printf("BLE: discard type=0x%02x while waiting for 0x%02x (%s)", resp[0], expect, cmdName)
}
continue
}
if !b.quiet {
log.Printf("BLE response (%d bytes)", len(resp))
}
return resp, nil
case <-timer.C:
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
}
return resp, nil
case <-timer.C:
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
}
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"fmt"
"math"
"sort"
"time"
pb "powerpod/gotool/pb"
"google.golang.org/protobuf/proto"
)
type latencyStats struct {
N int `json:"n"`
MinMs float64 `json:"min_ms"`
MedMs float64 `json:"median_ms"`
MeanMs float64 `json:"mean_ms"`
MaxMs float64 `json:"max_ms"`
}
type bleRttResult struct {
Success bool `json:"success"`
Count int `json:"count"`
Warmup int `json:"warmup"`
Stats latencyStats `json:"stats"`
Error string `json:"error,omitempty"`
}
type bleStreamResult struct {
Success bool `json:"success"`
Received int `json:"received"`
Requested int `json:"requested"`
PeriodMs uint32 `json:"period_ms"`
TolMs float64 `json:"tol_ms"`
OutsideTol int `json:"outside_tol"`
WindowOK bool `json:"window_ok"`
DTEsp latencyStats `json:"dt_esp_ms"`
DRPc latencyStats `json:"dr_pc_ms"`
ExtraVsFirst latencyStats `json:"extra_vs_first_ms"`
Error string `json:"error,omitempty"`
}
func measureBleRtt(h hostConn, count, warmup int, settle time.Duration) bleRttResult {
out := bleRttResult{Count: count, Warmup: warmup}
if count < 1 {
out.Error = "count must be >= 1"
return out
}
if warmup < 0 {
out.Error = "warmup must be >= 0"
return out
}
if settle > 0 {
time.Sleep(settle)
}
samples := make([]float64, 0, count)
total := warmup + count
for i := 0; i < total; i++ {
t0 := time.Now()
if _, err := hostGetVersion(h); err != nil {
out.Error = fmt.Sprintf("VERSION round-trip %d: %v", i, err)
return out
}
ms := float64(time.Since(t0).Microseconds()) / 1000.0
if i >= warmup {
samples = append(samples, ms)
}
}
out.Stats = makeLatencyStats(samples)
out.Success = true
return out
}
func measureBleStream(h hostConn, clientID, periodMs uint32, count int, tolMs, timeoutSec float64) bleStreamResult {
out := bleStreamResult{
Requested: count,
PeriodMs: periodMs,
TolMs: tolMs,
}
if periodMs < 8 || periodMs > 16 {
out.Error = "period_ms must be 8..16"
return out
}
if count < 2 {
out.Error = "count must be >= 2"
return out
}
sc, ok := h.(hostStreamConn)
if !ok {
out.Error = "BLE stream ticks require BLE host"
return out
}
req := &pb.AccelStreamRequest{
Write: true,
Enable: true,
PeriodMs: periodMs,
}
if clientID == 0 {
req.AllClients = true
} else {
req.ClientId = clientID
}
resp, err := hostAccelStream(h, req)
if err != nil {
out.Error = err.Error()
return out
}
if !resp.GetSuccess() {
out.Error = "accel stream enable failed"
return out
}
type sample struct {
espUs uint64
pcT float64
}
samples := make([]sample, 0, count)
deadline := time.Now().Add(time.Duration(timeoutSec * float64(time.Second)))
if timeoutSec <= 0 {
deadline = time.Now().Add(15 * time.Second)
}
for len(samples) < count {
remain := time.Until(deadline)
if remain <= 0 {
break
}
payload, err := sc.NextStreamTick(remain)
if err != nil {
break
}
pcT := time.Now().UnixMicro()
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
continue
}
tick := msg.GetBleStreamTick()
if tick == nil {
continue
}
samples = append(samples, sample{espUs: tick.GetEspTimestampUs(), pcT: float64(pcT)})
}
off := &pb.AccelStreamRequest{Write: true, Enable: false, AllClients: req.AllClients, ClientId: req.ClientId}
_, _ = hostAccelStream(h, off)
out.Received = len(samples)
if len(samples) < 2 {
out.Error = fmt.Sprintf("need >=2 ticks, got %d (stream running / DIP8=BLE?)", len(samples))
return out
}
var dT, dR, extra []float64
esp0 := samples[0].espUs
pc0 := samples[0].pcT
for i := 1; i < len(samples); i++ {
dT = append(dT, float64(samples[i].espUs-samples[i-1].espUs)/1000.0)
dR = append(dR, (samples[i].pcT-samples[i-1].pcT)/1000.0)
relEsp := float64(samples[i].espUs-esp0) / 1000.0
relPc := (samples[i].pcT - pc0) / 1000.0
extra = append(extra, relPc-relEsp)
}
period := float64(periodMs)
outCount := 0
for _, dt := range dT {
if math.Abs(dt-period) > tolMs {
outCount++
}
}
out.OutsideTol = outCount
out.WindowOK = outCount*2 <= len(dT)
out.DTEsp = makeLatencyStats(dT)
out.DRPc = makeLatencyStats(dR)
out.ExtraVsFirst = makeLatencyStats(extra)
out.Success = true
return out
}
func makeLatencyStats(v []float64) latencyStats {
if len(v) == 0 {
return latencyStats{}
}
s := append([]float64(nil), v...)
sort.Float64s(s)
return latencyStats{
N: len(s),
MinMs: s[0],
MedMs: percentile(s, 0.5),
MeanMs: mean(s),
MaxMs: s[len(s)-1],
}
}
func mean(v []float64) float64 {
if len(v) == 0 {
return math.NaN()
}
var s float64
for _, x := range v {
s += x
}
return s / float64(len(v))
}
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return math.NaN()
}
if len(sorted) == 1 {
return sorted[0]
}
idx := p * float64(len(sorted)-1)
lo := int(idx)
hi := lo + 1
if hi >= len(sorted) {
return sorted[lo]
}
frac := idx - float64(lo)
return sorted[lo]*(1-frac) + sorted[hi]*frac
}
+9 -1
View File
@@ -57,6 +57,14 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
tick, err := parseBleStreamTickPayload(payload)
if err != nil {
return nil, err
}
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
}
func parseBleStreamTickPayload(payload []byte) (*pb.BleStreamTick, error) {
if len(payload) < 2 {
return nil, fmt.Errorf("BLE stream tick too short")
}
@@ -71,5 +79,5 @@ func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, e
if tick == nil {
return nil, fmt.Errorf("missing ble_stream_tick")
}
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
return tick, nil
}
+5 -56
View File
@@ -3,8 +3,6 @@ package main
import (
"flag"
"fmt"
"math"
"sort"
"time"
)
@@ -16,62 +14,13 @@ func runBleRtt(h hostConn, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if *count < 1 {
return fmt.Errorf("--count must be >= 1")
}
if *warmup < 0 {
return fmt.Errorf("--warmup must be >= 0")
}
if *settle > 0 {
fmt.Printf("settling %.1fs…\n", *settle)
time.Sleep(time.Duration(*settle * float64(time.Second)))
out := measureBleRtt(h, *count, *warmup, time.Duration(*settle*float64(time.Second)))
if !out.Success {
return fmt.Errorf("%s", out.Error)
}
total := *warmup + *count
samples := make([]float64, 0, *count)
for i := 0; i < total; i++ {
t0 := time.Now()
if _, err := hostGetVersion(h); err != nil {
return fmt.Errorf("VERSION round-trip %d: %w", i, err)
}
ms := float64(time.Since(t0).Microseconds()) / 1000.0
if i >= *warmup {
samples = append(samples, ms)
}
}
sort.Float64s(samples)
fmt.Printf("ble-rtt (VERSION): n=%d warmup=%d\n", len(samples), *warmup)
fmt.Printf("ble-rtt (VERSION): n=%d warmup=%d\n", out.Stats.N, out.Warmup)
fmt.Printf(" min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
samples[0], percentile(samples, 0.5), mean(samples), samples[len(samples)-1])
out.Stats.MinMs, out.Stats.MedMs, out.Stats.MeanMs, out.Stats.MaxMs)
return nil
}
func mean(v []float64) float64 {
if len(v) == 0 {
return math.NaN()
}
var s float64
for _, x := range v {
s += x
}
return s / float64(len(v))
}
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return math.NaN()
}
if len(sorted) == 1 {
return sorted[0]
}
idx := p * float64(len(sorted)-1)
lo := int(idx)
hi := lo + 1
if hi >= len(sorted) {
return sorted[lo]
}
frac := idx - float64(lo)
return sorted[lo]*(1-frac) + sorted[hi]*frac
}
+12 -100
View File
@@ -3,12 +3,6 @@ package main
import (
"flag"
"fmt"
"math"
"sort"
"time"
pb "powerpod/gotool/pb"
"google.golang.org/protobuf/proto"
)
func runBleStream(h hostConn, args []string) error {
@@ -21,108 +15,26 @@ func runBleStream(h hostConn, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if *periodMs < 8 || *periodMs > 16 {
return fmt.Errorf("--period must be 8..16")
}
if *count < 2 {
return fmt.Errorf("--count must be >= 2")
}
sc, ok := h.(hostStreamConn)
if !ok {
return fmt.Errorf("ble-stream requires -ble (UART has no push ticks)")
}
req := &pb.AccelStreamRequest{
Write: true,
Enable: true,
PeriodMs: uint32(*periodMs),
}
if *clientID == 0 {
req.AllClients = true
} else {
req.ClientId = uint32(*clientID)
}
resp, err := hostAccelStream(h, req)
if err != nil {
return err
}
if !resp.GetSuccess() {
return fmt.Errorf("accel stream enable failed")
}
fmt.Printf("accel stream on; period_ms=%d; collecting %d ticks…\n", *periodMs, *count)
type sample struct {
espUs uint64
pcT float64
out := measureBleStream(h, uint32(*clientID), uint32(*periodMs), *count, *tolMs, *duration)
if !out.Success {
return fmt.Errorf("%s", out.Error)
}
samples := make([]sample, 0, *count)
deadline := time.Now().Add(time.Duration(*duration * float64(time.Second)))
for len(samples) < *count {
remain := time.Until(deadline)
if remain <= 0 {
break
}
payload, err := sc.NextStreamTick(remain)
if err != nil {
break
}
pcT := time.Now().UnixMicro()
var msg pb.UartMessage
if err := proto.Unmarshal(payload[1:], &msg); err != nil {
continue
}
tick := msg.GetBleStreamTick()
if tick == nil {
continue
}
samples = append(samples, sample{espUs: tick.GetEspTimestampUs(), pcT: float64(pcT)})
}
// Best-effort disable
off := &pb.AccelStreamRequest{Write: true, Enable: false, AllClients: req.AllClients, ClientId: req.ClientId}
_, _ = hostAccelStream(h, off)
if len(samples) < 2 {
return fmt.Errorf("need >=2 ticks, got %d (is stream running / DIP8=BLE?)", len(samples))
}
var dT, dR, extra []float64
esp0 := samples[0].espUs
pc0 := samples[0].pcT
for i := 1; i < len(samples); i++ {
dT = append(dT, float64(samples[i].espUs-samples[i-1].espUs)/1000.0)
dR = append(dR, (samples[i].pcT-samples[i-1].pcT)/1000.0)
relEsp := float64(samples[i].espUs-esp0) / 1000.0
relPc := (samples[i].pcT - pc0) / 1000.0
extra = append(extra, relPc-relEsp)
}
period := float64(*periodMs)
outCount := 0
for _, dt := range dT {
if math.Abs(dt-period) > *tolMs {
outCount++
}
}
windowOK := outCount*2 <= len(dT) // majority inside tolerance
fmt.Printf("received=%d/%d period_ms=%d tol=±%.1f ms\n", len(samples), *count, *periodMs, *tolMs)
printStat("dT ESP inter-depart", dT)
printStat("dR PC inter-arrive", dR)
printStat("extra vs first", extra)
fmt.Printf("outside_tol=%d/%d window_ok=%s\n", outCount, len(dT), map[bool]string{true: "yes", false: "no"}[windowOK])
fmt.Printf("received=%d/%d period_ms=%d tol=±%.1f ms\n", out.Received, out.Requested, out.PeriodMs, out.TolMs)
printStat("dT ESP inter-depart", out.DTEsp)
printStat("dR PC inter-arrive", out.DRPc)
printStat("extra vs first", out.ExtraVsFirst)
fmt.Printf("outside_tol=%d/%d window_ok=%s\n", out.OutsideTol, out.DTEsp.N,
map[bool]string{true: "yes", false: "no"}[out.WindowOK])
return nil
}
func printStat(name string, v []float64) {
if len(v) == 0 {
func printStat(name string, s latencyStats) {
if s.N == 0 {
fmt.Printf("%s: (none)\n", name)
return
}
s := append([]float64(nil), v...)
sort.Float64s(s)
fmt.Printf("%s: n=%d min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
name, len(s), s[0], percentile(s, 0.5), mean(s), s[len(s)-1])
name, s.N, s.MinMs, s.MedMs, s.MeanMs, s.MaxMs)
}
+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)
}
}
+14
View File
@@ -140,6 +140,20 @@ Dashboard WebSocket state includes `host_connected`, `host_transport`, `host_end
**OTA** (`POST /api/ota`) returns **400** unless `transport==uart` and connected.
### BLE latency tests (BLE host only)
```http
POST /api/ble-rtt
{"count":50,"warmup":5,"settle_s":0}
→ {"success":true,"count":50,"warmup":5,"stats":{"n":50,"min_ms":…,"median_ms":…,"mean_ms":…,"max_ms":…}}
POST /api/ble-stream
{"count":50,"period_ms":16,"tol_ms":2,"timeout_s":15,"client_id":0}
→ {"success":true,"received":50,"requested":50,"window_ok":true,"dt_esp_ms":{…},"dr_pc_ms":{…},…}
```
Pauses dashboard/API polling while running. Requires active BLE host (`DIP8=1`).
### Live stream (host `CACHE_STATUS` poll ~16 ms; BLE uses `BLE_STREAM_TICK`)
```http
+25 -2
View File
@@ -33,6 +33,7 @@ type managedHost struct {
sp *serialPort
ble *bleLink
otaActive bool
pausePoll bool // true while BLE latency tests run (pollers yield)
uartPort string // remembered UART port for reconnect
}
@@ -291,10 +292,32 @@ func (m *managedHost) exchangePayloadPoll(payload []byte, cmdName string) ([]byt
return m.exchangePayloadMode(true, payload, cmdName, readTimeout)
}
func (m *managedHost) beginPausePoll() bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.otaActive || m.pausePoll {
return false
}
m.pausePoll = true
return true
}
func (m *managedHost) endPausePoll() {
m.mu.Lock()
m.pausePoll = false
m.mu.Unlock()
}
func (m *managedHost) PollPaused() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.pausePoll || m.otaActive
}
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 {
if m.otaActive || (poll && m.pausePoll) {
return nil, errHostBusy
}
switch m.transport {
@@ -302,7 +325,7 @@ func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName str
if m.ble == nil {
return nil, fmt.Errorf("BLE not connected")
}
resp, err := m.ble.exchangePayload(payload, cmdName)
resp, err := m.ble.exchangePayloadTimeout(payload, cmdName, timeout)
if err != nil {
m.invalidateLocked(err)
}
+182 -3
View File
@@ -269,6 +269,89 @@
</div>
</section>
<section class="card mb-4" x-show="isBLEHost()">
<div class="card-header">BLE-Tests</div>
<div class="card-body">
<p class="text-muted small mb-3">
Host↔Master-Latenz und Stream-Tick-Fenster (wie CLI <code>ble-rtt</code> / <code>ble-stream</code>).
Dashboard-Polling pausiert während des Laufs.
</p>
<div class="row g-3">
<div class="col-md-6">
<h6 class="text-secondary mb-2">RTT (VERSION)</h6>
<div class="d-flex flex-wrap gap-2 align-items-end mb-2">
<div>
<label class="form-label text-muted small mb-1" for="ble-rtt-count">Samples</label>
<input id="ble-rtt-count" type="number" class="form-control form-control-sm config-input"
min="1" max="500" x-model.number="bleRtt.count" :disabled="bleTestBusy">
</div>
<div>
<label class="form-label text-muted small mb-1" for="ble-rtt-warmup">Warmup</label>
<input id="ble-rtt-warmup" type="number" class="form-control form-control-sm config-input"
min="0" max="100" x-model.number="bleRtt.warmup" :disabled="bleTestBusy">
</div>
<button type="button" class="btn btn-primary btn-sm"
@click="runBleRtt()" :disabled="bleTestBusy || busy"
x-text="bleTestBusy === 'rtt' ? 'Läuft…' : 'Start RTT'"></button>
</div>
<template x-if="bleRtt.result">
<dl class="row small mb-0">
<dt class="col-5 text-muted">n / warmup</dt>
<dd class="col-7" x-text="bleRtt.result.stats?.n + ' / ' + bleRtt.result.warmup"></dd>
<dt class="col-5 text-muted">min / median</dt>
<dd class="col-7" x-text="fmtMs(bleRtt.result.stats?.min_ms) + ' / ' + fmtMs(bleRtt.result.stats?.median_ms)"></dd>
<dt class="col-5 text-muted">mean / max</dt>
<dd class="col-7" x-text="fmtMs(bleRtt.result.stats?.mean_ms) + ' / ' + fmtMs(bleRtt.result.stats?.max_ms)"></dd>
<dt class="col-5 text-muted" x-show="bleRtt.result.error">Fehler</dt>
<dd class="col-7 text-danger" x-show="bleRtt.result.error" x-text="bleRtt.result.error"></dd>
</dl>
</template>
</div>
<div class="col-md-6">
<h6 class="text-secondary mb-2">Stream-Ticks</h6>
<div class="d-flex flex-wrap gap-2 align-items-end mb-2">
<div>
<label class="form-label text-muted small mb-1" for="ble-stream-count">Ticks</label>
<input id="ble-stream-count" type="number" class="form-control form-control-sm config-input"
min="2" max="500" x-model.number="bleStream.count" :disabled="bleTestBusy">
</div>
<div>
<label class="form-label text-muted small mb-1" for="ble-stream-period">Period ms</label>
<input id="ble-stream-period" type="number" class="form-control form-control-sm config-input"
min="8" max="16" x-model.number="bleStream.period_ms" :disabled="bleTestBusy">
</div>
<div>
<label class="form-label text-muted small mb-1" for="ble-stream-tol">Tol ms</label>
<input id="ble-stream-tol" type="number" class="form-control form-control-sm config-input"
min="0" max="20" step="0.5" x-model.number="bleStream.tol_ms" :disabled="bleTestBusy">
</div>
<button type="button" class="btn btn-primary btn-sm"
@click="runBleStream()" :disabled="bleTestBusy || busy"
x-text="bleTestBusy === 'stream' ? 'Läuft…' : 'Start Stream'"></button>
</div>
<template x-if="bleStream.result">
<dl class="row small mb-0">
<dt class="col-5 text-muted">received</dt>
<dd class="col-7" x-text="bleStream.result.received + ' / ' + bleStream.result.requested"></dd>
<dt class="col-5 text-muted">window_ok</dt>
<dd class="col-7"
:class="bleStream.result.window_ok ? 'text-success' : 'text-warning'"
x-text="bleStream.result.window_ok ? 'yes' : 'no'"></dd>
<dt class="col-5 text-muted">dT ESP</dt>
<dd class="col-7" x-text="fmtStats(bleStream.result.dt_esp_ms)"></dd>
<dt class="col-5 text-muted">dR PC</dt>
<dd class="col-7" x-text="fmtStats(bleStream.result.dr_pc_ms)"></dd>
<dt class="col-5 text-muted">outside tol</dt>
<dd class="col-7" x-text="bleStream.result.outside_tol + ' / ' + (bleStream.result.dt_esp_ms?.n || 0)"></dd>
<dt class="col-5 text-muted" x-show="bleStream.result.error">Fehler</dt>
<dd class="col-7 text-danger" x-show="bleStream.result.error" x-text="bleStream.result.error"></dd>
</dl>
</template>
</div>
</div>
</div>
</section>
<div class="row g-4">
<section class="col-lg-4">
<div class="card h-100">
@@ -461,9 +544,9 @@
</div>
</div>
<p class="text-muted small px-3 pt-2 mb-0">
<strong>Live-Stream</strong> startet die schnelle <code>CACHE_STATUS</code>-Abfrage (~16&nbsp;ms).
Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Accel-Stream; Tap-Notify (S/D/T) steuert
die Tap-Arten auf dem Slave.
<strong>Live-Stream</strong>: UART ≈ <code>CACHE_STATUS</code>-Poll (~16&nbsp;ms); BLE ≈
<code>BLE_STREAM_TICK</code>-Push. Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Stream;
Tap-Notify (S/D/T) steuert die Tap-Arten auf dem Slave.
</p>
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
<button type="button"
@@ -472,6 +555,9 @@
@click="setLiveStream(!state.live_stream)"
:disabled="busy || !hostUp() || !(state.clients || []).length"
x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"></button>
<span class="text-muted small font-monospace" x-show="isBLEHost() && state.live_stream"
x-text="bleTickLabel()"
title="Median Δt zwischen BLE_STREAM_TICK (esp_timestamp_us)"></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="allTapDouble" :disabled="busy"> D</label>
@@ -815,6 +901,9 @@
bleScanning: false,
bleScanErr: '',
hostBusy: false,
bleTestBusy: false,
bleRtt: { count: 50, warmup: 5, result: null },
bleStream: { count: 50, period_ms: 16, tol_ms: 2, result: null },
masterDz: 100,
masterDzSeeded: false,
masterLogLevel: 0,
@@ -867,6 +956,82 @@
isUARTHost() {
return this.hostUp() && (this.state?.host_transport || 'uart') === 'uart';
},
isBLEHost() {
return this.hostUp() && this.state?.host_transport === 'ble';
},
bleTickLabel() {
const t = this.state?.ble_tick;
if (!t || !t.samples) return 'BLE tick: …';
const med = Number(t.median_ms).toFixed(2);
const period = t.period_ms != null ? t.period_ms : '—';
return `BLE tick median ${med} ms (n=${t.samples}, period ${period} ms)`;
},
fmtMs(v) {
if (v == null || Number.isNaN(v)) return '—';
return Number(v).toFixed(3) + ' ms';
},
fmtStats(s) {
if (!s?.n) return '—';
return `min ${this.fmtMs(s.min_ms)} · med ${this.fmtMs(s.median_ms)} · mean ${this.fmtMs(s.mean_ms)} · max ${this.fmtMs(s.max_ms)}`;
},
async runBleRtt() {
this.bleTestBusy = 'rtt';
this.bleRtt.result = null;
try {
const r = await fetch('/api/ble-rtt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
count: this.bleRtt.count,
warmup: this.bleRtt.warmup,
}),
});
const data = await r.json();
this.bleRtt.result = data;
if (!data.success) {
this.flash(data.error || 'BLE RTT fehlgeschlagen', false);
} else {
this.flash(
`BLE RTT: mean ${Number(data.stats?.mean_ms).toFixed(3)} ms (n=${data.stats?.n})`,
true
);
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.bleTestBusy = false;
}
},
async runBleStream() {
this.bleTestBusy = 'stream';
this.bleStream.result = null;
try {
const r = await fetch('/api/ble-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
count: this.bleStream.count,
period_ms: this.bleStream.period_ms,
tol_ms: this.bleStream.tol_ms,
timeout_s: 20,
}),
});
const data = await r.json();
this.bleStream.result = data;
if (!data.success) {
this.flash(data.error || 'BLE Stream-Test fehlgeschlagen', false);
} else {
this.flash(
`BLE Stream: ${data.received}/${data.requested} ticks, window_ok=${data.window_ok ? 'yes' : 'no'}`,
true
);
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.bleTestBusy = false;
}
},
hostBadge() {
const t = this.state?.host_transport || 'none';
if (!this.hostUp()) return 'off';
@@ -981,9 +1146,14 @@
if (this._tapTimer) clearInterval(this._tapTimer);
this._tapTimer = setInterval(() => { this._tapClock++; }, 250);
const connect = () => {
if (this.ws && (this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)) {
try { this.ws.close(); } catch (_) {}
}
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.wsConnected = true;
this._lastWsAt = Date.now();
this.refreshBattery();
this.refreshSerialPorts();
};
@@ -993,6 +1163,7 @@
};
this.ws.onmessage = (e) => {
try {
this._lastWsAt = Date.now();
const msg = JSON.parse(e.data);
if (msg.type === 'ota_progress') {
this.applyOTAProgress(msg);
@@ -1024,6 +1195,14 @@
} catch (_) {}
};
};
if (this._wsWatch) clearInterval(this._wsWatch);
// If server dropped us from the hub without closing, force reconnect.
this._wsWatch = setInterval(() => {
if (!this.wsConnected || !this.state?.live_stream) return;
if (Date.now() - (this._lastWsAt || 0) > 4000) {
try { this.ws?.close(); } catch (_) {}
}
}, 2000);
connect();
},
preserveBatteryInState(prev, next) {
+5 -7
View File
@@ -140,15 +140,13 @@ static void ble_request_fast_conn_params(uint16_t handle) {
static void ble_optimize_link(uint16_t handle) {
int rc;
/*
* Do NOT request 2M PHY here. With ESP-NOW/WiFi coexistence the controller
* can assert in llc_phy_upd (e.g. "BLE assert llc_phy_upd.c …") and the
* host link dies — no further responses. Stay on default 1M.
*/
ble_request_fast_conn_params(handle);
rc = ble_gap_set_prefered_le_phy(handle, BLE_GAP_LE_PHY_2M_MASK,
BLE_GAP_LE_PHY_2M_MASK,
BLE_GAP_LE_PHY_CODED_ANY);
if (rc != 0) {
ESP_LOGW(TAG, "2M PHY request failed: %d", rc);
}
rc = ble_gap_set_data_len(handle, 251, 2120);
if (rc != 0) {
ESP_LOGW(TAG, "data len request failed: %d", rc);
+4 -2
View File
@@ -435,11 +435,13 @@ static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
esp_err_t err = client_registry_update_accel(
mac, sample->slave_id, (int16_t)sample->x, (int16_t)sample->y,
(int16_t)sample->z);
if (err == ESP_ERR_NOT_FOUND) {
if (err == ESP_ERR_NOT_FOUND || err == ESP_ERR_INVALID_STATE) {
// Unknown MAC, or stream already off (in-flight samples after disable).
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x", mac[0], mac[5]);
ESP_LOGW(TAG, "accel sample id mismatch from %02x:…:%02x (slave_id=%lu)",
mac[0], mac[5], (unsigned long)sample->slave_id);
}
}