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:
@@ -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)
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ type otaAPIResponse struct {
|
|||||||
|
|
||||||
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCtl *accelStreamCtl, tapCtl *tapNotifyCtl) {
|
||||||
mountHostAPI(mux, link)
|
mountHostAPI(mux, link)
|
||||||
|
mountBleTestAPI(mux, link)
|
||||||
mountLiveStreamAPI(mux, hub)
|
mountLiveStreamAPI(mux, hub)
|
||||||
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
mountAccelStreamAPI(mux, link, hub, streamCtl)
|
||||||
mountTapAPI(mux, link, hub, tapCtl)
|
mountTapAPI(mux, link, hub, tapCtl)
|
||||||
|
|||||||
+99
-100
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -180,12 +181,31 @@ type cachedTapEvent struct {
|
|||||||
|
|
||||||
type wsSubscriber struct {
|
type wsSubscriber struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
|
writeMu sync.Mutex // gorilla: one writer per connection
|
||||||
receiveInput bool
|
receiveInput bool
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
preFetch time.Duration
|
preFetch time.Duration
|
||||||
lastInputSent time.Time
|
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 {
|
type pendingInputCache struct {
|
||||||
cache *pb.CacheStatusResponse
|
cache *pb.CacheStatusResponse
|
||||||
readAt time.Time
|
readAt time.Time
|
||||||
@@ -269,9 +289,7 @@ func (h *accelStreamHub) register(conn *websocket.Conn, link *managedSerial) *ws
|
|||||||
"set_led_ring", "get_battery",
|
"set_led_ring", "get_battery",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if data, err := json.Marshal(hello); err == nil {
|
sub.writeJSON(hello)
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
|
||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +485,11 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
type pending struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
sub *wsSubscriber
|
||||||
|
}
|
||||||
|
var out []pending
|
||||||
for conn, sub := range h.clients {
|
for conn, sub := range h.clients {
|
||||||
if !sub.receiveInput {
|
if !sub.receiveInput {
|
||||||
continue
|
continue
|
||||||
@@ -476,13 +498,26 @@ func (h *accelStreamHub) deliverInput(msg InputStreamMessage) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sub.lastInputSent = now
|
sub.lastInputSent = now
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
out = append(out, pending{conn: conn, sub: sub})
|
||||||
delete(h.clients, conn)
|
}
|
||||||
_ = conn.Close()
|
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{}) {
|
func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) {
|
||||||
ticker := time.NewTicker(minAPIStreamInterval)
|
ticker := time.NewTicker(minAPIStreamInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -496,7 +531,7 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
|
|||||||
case <-hub.configChanged:
|
case <-hub.configChanged:
|
||||||
pending = nil
|
pending = nil
|
||||||
case now := <-ticker.C:
|
case now := <-ticker.C:
|
||||||
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) {
|
if !hub.anyWantsInput() || !inputPollingActive(dash, ctl, tapCtl) || link.PollPaused() {
|
||||||
pending = nil
|
pending = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -508,14 +543,11 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
|
|||||||
cache, cerr := cacheStatusFromBLEStreamPayload(payload)
|
cache, cerr := cacheStatusFromBLEStreamPayload(payload)
|
||||||
if cerr == nil {
|
if cerr == nil {
|
||||||
hub.ingestTapFromCache(cache)
|
hub.ingestTapFromCache(cache)
|
||||||
hub.mu.RLock()
|
|
||||||
clients := hub.inputClientsFromCacheLocked(cache, time.Now())
|
|
||||||
hub.mu.RUnlock()
|
|
||||||
hub.deliverInput(InputStreamMessage{
|
hub.deliverInput(InputStreamMessage{
|
||||||
Type: "input",
|
Type: "input",
|
||||||
T: time.Now().UnixNano(),
|
T: time.Now().UnixNano(),
|
||||||
Success: true,
|
Success: true,
|
||||||
Clients: clients,
|
Clients: hub.clientsFromCache(cache),
|
||||||
})
|
})
|
||||||
pending = nil
|
pending = nil
|
||||||
continue
|
continue
|
||||||
@@ -557,14 +589,11 @@ func runInputStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if pending.cache != nil && now.Sub(pending.readAt) >= waitPreFetch {
|
if pending.cache != nil && now.Sub(pending.readAt) >= waitPreFetch {
|
||||||
hub.mu.RLock()
|
|
||||||
clients := hub.inputClientsFromCacheLocked(pending.cache, now)
|
|
||||||
hub.mu.RUnlock()
|
|
||||||
hub.deliverInput(InputStreamMessage{
|
hub.deliverInput(InputStreamMessage{
|
||||||
Type: "input",
|
Type: "input",
|
||||||
T: ts,
|
T: ts,
|
||||||
Success: true,
|
Success: true,
|
||||||
Clients: clients,
|
Clients: hub.clientsFromCache(pending.cache),
|
||||||
})
|
})
|
||||||
pending = nil
|
pending = nil
|
||||||
}
|
}
|
||||||
@@ -582,46 +611,29 @@ func inputPollingActive(dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl)
|
|||||||
return dash != nil && dash.anyAccelStreamEnabled()
|
return dash != nil && dash.anyAccelStreamEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) {
|
func writeStreamStatus(sub *wsSubscriber, msg StreamStatusMessage) {
|
||||||
data, err := json.Marshal(msg)
|
sub.writeJSON(msg)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeBatteryStatus(conn *websocket.Conn, out batteryAPIResponse) {
|
func writeBatteryStatus(sub *wsSubscriber, out batteryAPIResponse) {
|
||||||
out.Type = "battery_status"
|
out.Type = "battery_status"
|
||||||
data, err := json.Marshal(out)
|
sub.writeJSON(out)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) {
|
func writeLedRingStatus(sub *wsSubscriber, out ledRingAPIResponse) {
|
||||||
out.Type = "led_ring_status"
|
out.Type = "led_ring_status"
|
||||||
data, err := json.Marshal(out)
|
sub.writeJSON(out)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeInputStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) {
|
func writeInputStreamStatus(sub *wsSubscriber, out accelStreamAPIResponse) {
|
||||||
msg := InputStreamStatusMessage{
|
sub.writeJSON(InputStreamStatusMessage{
|
||||||
Type: "input_stream_status",
|
Type: "input_stream_status",
|
||||||
ClientID: out.ClientID,
|
ClientID: out.ClientID,
|
||||||
Enabled: out.Enabled,
|
Enabled: out.Enabled,
|
||||||
Success: out.Success,
|
Success: out.Success,
|
||||||
SlavesUpdated: out.SlavesUpdated,
|
SlavesUpdated: out.SlavesUpdated,
|
||||||
Error: out.Error,
|
Error: out.Error,
|
||||||
}
|
})
|
||||||
data, err := json.Marshal(msg)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
|
func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
|
||||||
@@ -640,16 +652,12 @@ func clientInfoToAPI(c *pb.ClientInfo) APIClientInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeClientList(conn *websocket.Conn, msg ClientListMessage) {
|
func writeClientList(sub *wsSubscriber, msg ClientListMessage) {
|
||||||
data, err := json.Marshal(msg)
|
sub.writeJSON(msg)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
|
func writeTapNotifyStatus(sub *wsSubscriber, out tapNotifyAPIResponse) {
|
||||||
msg := TapNotifyStatusMessage{
|
sub.writeJSON(TapNotifyStatusMessage{
|
||||||
Type: "tap_notify_status",
|
Type: "tap_notify_status",
|
||||||
ClientID: out.ClientID,
|
ClientID: out.ClientID,
|
||||||
Single: out.Single,
|
Single: out.Single,
|
||||||
@@ -658,12 +666,7 @@ func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) {
|
|||||||
Success: out.Success,
|
Success: out.Success,
|
||||||
SlavesUpdated: out.SlavesUpdated,
|
SlavesUpdated: out.SlavesUpdated,
|
||||||
Error: out.Error,
|
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 {
|
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
|
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
|
var cmd accelWSCommand
|
||||||
if err := json.Unmarshal(data, &cmd); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cmd.Type {
|
switch cmd.Type {
|
||||||
case "get_host":
|
case "get_host":
|
||||||
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
|
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
|
||||||
|
|
||||||
case "list_serial_ports":
|
case "list_serial_ports":
|
||||||
ports, err := listSerialPorts()
|
ports, err := listSerialPorts()
|
||||||
@@ -714,7 +717,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
msg.Error = err.Error()
|
msg.Error = err.Error()
|
||||||
msg.Ports = []string{}
|
msg.Ports = []string{}
|
||||||
}
|
}
|
||||||
writeWSJSON(conn, msg)
|
writeWSJSON(sub, msg)
|
||||||
|
|
||||||
case "ble_scan":
|
case "ble_scan":
|
||||||
timeout := time.Duration(cmd.TimeoutS * float64(time.Second))
|
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.Error = err.Error()
|
||||||
msg.Devices = []bleScanDevice{}
|
msg.Devices = []bleScanDevice{}
|
||||||
}
|
}
|
||||||
writeWSJSON(conn, msg)
|
writeWSJSON(sub, msg)
|
||||||
|
|
||||||
case "host_connect":
|
case "host_connect":
|
||||||
var err error
|
var err error
|
||||||
@@ -740,26 +743,26 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
case hostTransportBLE:
|
case hostTransportBLE:
|
||||||
err = link.ConnectBLE(cmd.Address, cmd.Name)
|
err = link.ConnectBLE(cmd.Address, cmd.Name)
|
||||||
default:
|
default:
|
||||||
writeHostStatus(conn, HostStatusMessage{
|
writeHostStatus(sub, HostStatusMessage{
|
||||||
Type: "host_status",
|
Type: "host_status",
|
||||||
Error: "transport must be uart or ble",
|
Error: "transport must be uart or ble",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeHostStatus(conn, hostStatusFromLink(link, false, err.Error()))
|
writeHostStatus(sub, hostStatusFromLink(link, false, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
|
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
|
||||||
|
|
||||||
case "host_disconnect":
|
case "host_disconnect":
|
||||||
_ = link.Disconnect()
|
_ = link.Disconnect()
|
||||||
writeHostStatus(conn, hostStatusFromLink(link, true, ""))
|
writeHostStatus(sub, hostStatusFromLink(link, true, ""))
|
||||||
|
|
||||||
case "list_clients":
|
case "list_clients":
|
||||||
clients, err := link.listClientsPoll()
|
clients, err := link.listClientsPoll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeClientList(conn, ClientListMessage{
|
writeClientList(sub, ClientListMessage{
|
||||||
Type: "client_list",
|
Type: "client_list",
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
@@ -769,7 +772,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
out = append(out, clientInfoToAPI(c))
|
out = append(out, clientInfoToAPI(c))
|
||||||
}
|
}
|
||||||
writeClientList(conn, ClientListMessage{
|
writeClientList(sub, ClientListMessage{
|
||||||
Type: "client_list",
|
Type: "client_list",
|
||||||
Success: true,
|
Success: true,
|
||||||
Clients: out,
|
Clients: out,
|
||||||
@@ -777,34 +780,34 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
|
|
||||||
case "set_stream":
|
case "set_stream":
|
||||||
if cmd.Enable == nil {
|
if cmd.Enable == nil {
|
||||||
writeStreamStatus(conn, StreamStatusMessage{
|
writeStreamStatus(sub, StreamStatusMessage{
|
||||||
Type: "stream_status",
|
Type: "stream_status",
|
||||||
Error: "enable required",
|
Error: "enable required",
|
||||||
})
|
})
|
||||||
return
|
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":
|
case "get_stream":
|
||||||
writeStreamStatus(conn, hub.getStream(sub))
|
writeStreamStatus(sub, hub.getStream(sub))
|
||||||
|
|
||||||
case "set_input_stream":
|
case "set_input_stream":
|
||||||
if cmd.ClientID == 0 {
|
if cmd.ClientID == 0 {
|
||||||
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
|
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cmd.Enable == nil {
|
if cmd.Enable == nil {
|
||||||
writeInputStreamStatus(conn, accelStreamAPIResponse{
|
writeInputStreamStatus(sub, accelStreamAPIResponse{
|
||||||
ClientID: cmd.ClientID,
|
ClientID: cmd.ClientID,
|
||||||
Error: "enable required",
|
Error: "enable required",
|
||||||
})
|
})
|
||||||
return
|
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":
|
case "get_input_stream":
|
||||||
if cmd.ClientID == 0 {
|
if cmd.ClientID == 0 {
|
||||||
writeInputStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"})
|
writeInputStreamStatus(sub, accelStreamAPIResponse{Error: "client_id required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
|
resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{
|
||||||
@@ -812,7 +815,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
ClientId: cmd.ClientID,
|
ClientId: cmd.ClientID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInputStreamStatus(conn, accelStreamAPIResponse{
|
writeInputStreamStatus(sub, accelStreamAPIResponse{
|
||||||
ClientID: cmd.ClientID,
|
ClientID: cmd.ClientID,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
@@ -821,7 +824,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
if ctl != nil {
|
if ctl != nil {
|
||||||
ctl.Set(cmd.ClientID, resp.GetEnabled())
|
ctl.Set(cmd.ClientID, resp.GetEnabled())
|
||||||
}
|
}
|
||||||
writeInputStreamStatus(conn, accelStreamAPIResponse{
|
writeInputStreamStatus(sub, accelStreamAPIResponse{
|
||||||
Enabled: resp.GetEnabled(),
|
Enabled: resp.GetEnabled(),
|
||||||
ClientID: resp.GetClientId(),
|
ClientID: resp.GetClientId(),
|
||||||
Success: resp.GetSuccess(),
|
Success: resp.GetSuccess(),
|
||||||
@@ -830,15 +833,15 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
case "set_tap_notify":
|
case "set_tap_notify":
|
||||||
if cmd.AllClients {
|
if cmd.AllClients {
|
||||||
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)
|
updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: err.Error()})
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||||
Success: updated > 0,
|
Success: updated > 0,
|
||||||
SlavesUpdated: updated,
|
SlavesUpdated: updated,
|
||||||
Single: *cmd.Single,
|
Single: *cmd.Single,
|
||||||
@@ -848,21 +851,21 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cmd.ClientID == 0 {
|
if cmd.ClientID == 0 {
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
|
if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil {
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||||
ClientID: cmd.ClientID,
|
ClientID: cmd.ClientID,
|
||||||
Error: "single, double_tap, triple required",
|
Error: "single, double_tap, triple required",
|
||||||
})
|
})
|
||||||
return
|
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":
|
case "get_tap_notify":
|
||||||
if cmd.ClientID == 0 {
|
if cmd.ClientID == 0 {
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"})
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{Error: "client_id required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
|
resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{
|
||||||
@@ -870,7 +873,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
ClientId: cmd.ClientID,
|
ClientId: cmd.ClientID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||||
ClientID: cmd.ClientID,
|
ClientID: cmd.ClientID,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
@@ -879,7 +882,7 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
if tapCtl != nil {
|
if tapCtl != nil {
|
||||||
tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple())
|
tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple())
|
||||||
}
|
}
|
||||||
writeTapNotifyStatus(conn, tapNotifyAPIResponse{
|
writeTapNotifyStatus(sub, tapNotifyAPIResponse{
|
||||||
ClientID: cmd.ClientID,
|
ClientID: cmd.ClientID,
|
||||||
Success: resp.GetSuccess(),
|
Success: resp.GetSuccess(),
|
||||||
Single: resp.GetSingle(),
|
Single: resp.GetSingle(),
|
||||||
@@ -890,28 +893,28 @@ func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte,
|
|||||||
case "set_led_ring":
|
case "set_led_ring":
|
||||||
var body ledRingAPIRequest
|
var body ledRingAPIRequest
|
||||||
if err := json.Unmarshal(data, &body); err != nil {
|
if err := json.Unmarshal(data, &body); err != nil {
|
||||||
writeLedRingStatus(conn, ledRingAPIResponse{Error: "invalid JSON"})
|
writeLedRingStatus(sub, ledRingAPIResponse{Error: "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Mode == "" {
|
if body.Mode == "" {
|
||||||
writeLedRingStatus(conn, ledRingAPIResponse{Error: "mode required"})
|
writeLedRingStatus(sub, ledRingAPIResponse{Error: "mode required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeLedRingStatus(conn, applyLedRing(link, body))
|
writeLedRingStatus(sub, applyLedRing(link, body))
|
||||||
|
|
||||||
case "get_battery":
|
case "get_battery":
|
||||||
var body batteryAPIRequest
|
var body batteryAPIRequest
|
||||||
if err := json.Unmarshal(data, &body); err != nil {
|
if err := json.Unmarshal(data, &body); err != nil {
|
||||||
writeBatteryStatus(conn, batteryAPIResponse{Error: "invalid JSON"})
|
writeBatteryStatus(sub, batteryAPIResponse{Error: "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !body.AllClients && body.ClientID == 0 {
|
if !body.AllClients && body.ClientID == 0 {
|
||||||
body.AllClients = true
|
body.AllClients = true
|
||||||
}
|
}
|
||||||
writeBatteryStatus(conn, applyBatteryStatus(link, body))
|
writeBatteryStatus(sub, applyBatteryStatus(link, body))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
writeStreamStatus(conn, StreamStatusMessage{
|
writeStreamStatus(sub, StreamStatusMessage{
|
||||||
Type: "stream_status",
|
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)",
|
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) {
|
func writeHostStatus(sub *wsSubscriber, msg HostStatusMessage) {
|
||||||
writeWSJSON(conn, msg)
|
sub.writeJSON(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeWSJSON(conn *websocket.Conn, msg any) {
|
func writeWSJSON(sub *wsSubscriber, msg any) {
|
||||||
data, err := json.Marshal(msg)
|
sub.writeJSON(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) {
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleAccelWSCommand(conn, sub, data, link, dash, ctl, tapCtl, hub)
|
handleAccelWSCommand(sub, data, link, dash, ctl, tapCtl, hub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -174,10 +174,20 @@ func (b *bleLink) onNotify(buf []byte) {
|
|||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case b.respCh <- payload:
|
case b.respCh <- payload:
|
||||||
|
default:
|
||||||
|
// 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:
|
default:
|
||||||
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
|
log.Printf("BLE: response channel full, dropping frame type=0x%02x", payload[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bleLink) Close() error {
|
func (b *bleLink) Close() error {
|
||||||
@@ -189,9 +199,21 @@ func (b *bleLink) exchange(cmdID byte, cmdName string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bleLink) exchangePayload(payload []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()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
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)
|
frame, err := uartframe.EncodeFrame(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode frame: %w", err)
|
return nil, fmt.Errorf("encode frame: %w", err)
|
||||||
@@ -212,10 +234,26 @@ sent:
|
|||||||
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
|
return nil, fmt.Errorf("BLE write: %w (check DIP8=BLE)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
timer := time.NewTimer(readTimeout)
|
deadline := time.Now().Add(timeout)
|
||||||
defer timer.Stop()
|
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 {
|
select {
|
||||||
case resp := <-b.respCh:
|
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 {
|
if !b.quiet {
|
||||||
log.Printf("BLE response (%d bytes)", len(resp))
|
log.Printf("BLE response (%d bytes)", len(resp))
|
||||||
}
|
}
|
||||||
@@ -223,6 +261,7 @@ sent:
|
|||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
return nil, fmt.Errorf("BLE read timeout for %s (check DIP8=BLE / -ble)", cmdName)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
func (b *bleLink) NextStreamTick(timeout time.Duration) ([]byte, error) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -57,6 +57,14 @@ func tapEventsFromCacheStatus(r *pb.CacheStatusResponse) []*pb.TapEvent {
|
|||||||
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
|
// cacheStatusFromBLEStreamPayload parses a framed BLE_STREAM_TICK payload
|
||||||
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
|
// (type byte + protobuf UartMessage) into a CacheStatusResponse-shaped view.
|
||||||
func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, error) {
|
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 {
|
if len(payload) < 2 {
|
||||||
return nil, fmt.Errorf("BLE stream tick too short")
|
return nil, fmt.Errorf("BLE stream tick too short")
|
||||||
}
|
}
|
||||||
@@ -71,5 +79,5 @@ func cacheStatusFromBLEStreamPayload(payload []byte) (*pb.CacheStatusResponse, e
|
|||||||
if tick == nil {
|
if tick == nil {
|
||||||
return nil, fmt.Errorf("missing ble_stream_tick")
|
return nil, fmt.Errorf("missing ble_stream_tick")
|
||||||
}
|
}
|
||||||
return &pb.CacheStatusResponse{Clients: tick.GetClients()}, nil
|
return tick, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-56
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,62 +14,13 @@ func runBleRtt(h hostConn, args []string) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
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 {
|
out := measureBleRtt(h, *count, *warmup, time.Duration(*settle*float64(time.Second)))
|
||||||
fmt.Printf("settling %.1fs…\n", *settle)
|
if !out.Success {
|
||||||
time.Sleep(time.Duration(*settle * float64(time.Second)))
|
return fmt.Errorf("%s", out.Error)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("ble-rtt (VERSION): n=%d warmup=%d\n", out.Stats.N, out.Warmup)
|
||||||
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(" min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
|
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
|
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
@@ -3,12 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
pb "powerpod/gotool/pb"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runBleStream(h hostConn, args []string) error {
|
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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
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)
|
fmt.Printf("accel stream on; period_ms=%d; collecting %d ticks…\n", *periodMs, *count)
|
||||||
|
out := measureBleStream(h, uint32(*clientID), uint32(*periodMs), *count, *tolMs, *duration)
|
||||||
type sample struct {
|
if !out.Success {
|
||||||
espUs uint64
|
return fmt.Errorf("%s", out.Error)
|
||||||
pcT float64
|
|
||||||
}
|
}
|
||||||
samples := make([]sample, 0, *count)
|
fmt.Printf("received=%d/%d period_ms=%d tol=±%.1f ms\n", out.Received, out.Requested, out.PeriodMs, out.TolMs)
|
||||||
deadline := time.Now().Add(time.Duration(*duration * float64(time.Second)))
|
printStat("dT ESP inter-depart", out.DTEsp)
|
||||||
|
printStat("dR PC inter-arrive", out.DRPc)
|
||||||
for len(samples) < *count {
|
printStat("extra vs first", out.ExtraVsFirst)
|
||||||
remain := time.Until(deadline)
|
fmt.Printf("outside_tol=%d/%d window_ok=%s\n", out.OutsideTol, out.DTEsp.N,
|
||||||
if remain <= 0 {
|
map[bool]string{true: "yes", false: "no"}[out.WindowOK])
|
||||||
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])
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func printStat(name string, v []float64) {
|
func printStat(name string, s latencyStats) {
|
||||||
if len(v) == 0 {
|
if s.N == 0 {
|
||||||
fmt.Printf("%s: (none)\n", name)
|
fmt.Printf("%s: (none)\n", name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s := append([]float64(nil), v...)
|
|
||||||
sort.Float64s(s)
|
|
||||||
fmt.Printf("%s: n=%d min=%.3f median=%.3f mean=%.3f max=%.3f ms\n",
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-10
@@ -77,15 +77,80 @@ type DashboardState struct {
|
|||||||
SerialError string `json:"serial_error,omitempty"`
|
SerialError string `json:"serial_error,omitempty"`
|
||||||
/** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */
|
/** Host: fast CACHE_STATUS poll (~16 ms) for accel + tap. */
|
||||||
LiveStream bool `json:"live_stream"`
|
LiveStream bool `json:"live_stream"`
|
||||||
|
/** BLE live path: inter-tick stats from BleStreamTick.esp_timestamp_us. */
|
||||||
|
BleTick *BleTickView `json:"ble_tick,omitempty"`
|
||||||
Master MasterView `json:"master"`
|
Master MasterView `json:"master"`
|
||||||
Clients []ClientView `json:"clients"`
|
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 {
|
type wsHub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
writeMu sync.Mutex // gorilla websocket: one writer at a time
|
||||||
clients map[*websocket.Conn]struct{}
|
clients map[*websocket.Conn]struct{}
|
||||||
state DashboardState
|
state DashboardState
|
||||||
liveStream bool
|
liveStream bool
|
||||||
|
bleTicks bleTickTracker
|
||||||
|
lastPush time.Time // throttle BLE tick WS pushes
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWSHub() *wsHub {
|
func newWSHub() *wsHub {
|
||||||
@@ -96,6 +161,10 @@ func (h *wsHub) setState(st DashboardState) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
prev := h.state
|
prev := h.state
|
||||||
st.LiveStream = prev.LiveStream
|
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 = preserveClientAccel(st.Clients, prev.Clients, st.LiveStream)
|
||||||
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
|
st.Clients = preserveClientBattery(st.Clients, prev.Clients)
|
||||||
st.Clients = preserveClientTap(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()
|
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) {
|
func (h *wsHub) writeJSON(c *websocket.Conn, data []byte) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if recover() != nil {
|
if recover() != nil {
|
||||||
h.unregister(c)
|
h.dropClient(c)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
|
h.writeMu.Lock()
|
||||||
h.unregister(c)
|
_ = 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 := h.state
|
||||||
st.LiveStream = enabled
|
st.LiveStream = enabled
|
||||||
if !enabled {
|
if !enabled {
|
||||||
|
h.bleTicks.reset()
|
||||||
|
st.BleTick = nil
|
||||||
for i := range st.Clients {
|
for i := range st.Clients {
|
||||||
st.Clients[i].AccelValid = false
|
st.Clients[i].AccelValid = false
|
||||||
st.Clients[i].AccelX = 0
|
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) {
|
func (h *wsHub) mergeTap(events []*pb.TapEvent) {
|
||||||
if len(events) == 0 || !h.liveStreamEnabled() {
|
if len(events) == 0 || !h.liveStreamEnabled() {
|
||||||
return
|
return
|
||||||
@@ -673,7 +797,7 @@ func runBatteryPoller(link *managedSerial, hub *wsHub, interval time.Duration, s
|
|||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if !link.IsConnected() {
|
if !link.IsConnected() || link.PollPaused() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
|
bat, err := link.BatteryStatusPoll(&pb.BatteryStatusRequest{AllClients: true})
|
||||||
@@ -694,7 +818,7 @@ func runCacheStatusDashboardPoller(link *managedSerial, hub *wsHub, interval tim
|
|||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if !hub.liveStreamEnabled() {
|
if !hub.liveStreamEnabled() || link.PollPaused() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// BLE live path prefers BLE_STREAM_TICK pushes.
|
// BLE live path prefers BLE_STREAM_TICK pushes.
|
||||||
@@ -720,7 +844,7 @@ func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *acc
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() {
|
if link.Transport() != hostTransportBLE || !hub.liveStreamEnabled() || link.PollPaused() {
|
||||||
select {
|
select {
|
||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
@@ -732,12 +856,11 @@ func runBLEStreamDashboardPoller(link *managedSerial, hub *wsHub, streamCtl *acc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cache, err := cacheStatusFromBLEStreamPayload(payload)
|
tick, err := parseBleStreamTickPayload(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hub.mergeAccel(accelSamplesFromCacheStatus(cache))
|
hub.mergeBleStreamTick(tick)
|
||||||
hub.mergeTap(tapEventsFromCacheStatus(cache))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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`)
|
### Live stream (host `CACHE_STATUS` poll ~16 ms; BLE uses `BLE_STREAM_TICK`)
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
+25
-2
@@ -33,6 +33,7 @@ type managedHost struct {
|
|||||||
sp *serialPort
|
sp *serialPort
|
||||||
ble *bleLink
|
ble *bleLink
|
||||||
otaActive bool
|
otaActive bool
|
||||||
|
pausePoll bool // true while BLE latency tests run (pollers yield)
|
||||||
uartPort string // remembered UART port for reconnect
|
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)
|
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) {
|
func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName string, timeout time.Duration) ([]byte, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.otaActive {
|
if m.otaActive || (poll && m.pausePoll) {
|
||||||
return nil, errHostBusy
|
return nil, errHostBusy
|
||||||
}
|
}
|
||||||
switch m.transport {
|
switch m.transport {
|
||||||
@@ -302,7 +325,7 @@ func (m *managedHost) exchangePayloadMode(poll bool, payload []byte, cmdName str
|
|||||||
if m.ble == nil {
|
if m.ble == nil {
|
||||||
return nil, fmt.Errorf("BLE not connected")
|
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 {
|
if err != nil {
|
||||||
m.invalidateLocked(err)
|
m.invalidateLocked(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-3
@@ -269,6 +269,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div class="row g-4">
|
||||||
<section class="col-lg-4">
|
<section class="col-lg-4">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
@@ -461,9 +544,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small px-3 pt-2 mb-0">
|
<p class="text-muted small px-3 pt-2 mb-0">
|
||||||
<strong>Live-Stream</strong> startet die schnelle <code>CACHE_STATUS</code>-Abfrage (~16 ms).
|
<strong>Live-Stream</strong>: UART ≈ <code>CACHE_STATUS</code>-Poll (~16 ms); BLE ≈
|
||||||
Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Accel-Stream; Tap-Notify (S/D/T) steuert
|
<code>BLE_STREAM_TICK</code>-Push. Pro Slave <strong>Accel</strong> aktiviert den ESP-NOW-Stream;
|
||||||
die Tap-Arten auf dem Slave.
|
Tap-Notify (S/D/T) steuert die Tap-Arten auf dem Slave.
|
||||||
</p>
|
</p>
|
||||||
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
|
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -472,6 +555,9 @@
|
|||||||
@click="setLiveStream(!state.live_stream)"
|
@click="setLiveStream(!state.live_stream)"
|
||||||
:disabled="busy || !hostUp() || !(state.clients || []).length"
|
:disabled="busy || !hostUp() || !(state.clients || []).length"
|
||||||
x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"></button>
|
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>
|
<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="allTapSingle" :disabled="busy"> S</label>
|
||||||
<label class="tap-toggle"><input type="checkbox" x-model="allTapDouble" :disabled="busy"> D</label>
|
<label class="tap-toggle"><input type="checkbox" x-model="allTapDouble" :disabled="busy"> D</label>
|
||||||
@@ -815,6 +901,9 @@
|
|||||||
bleScanning: false,
|
bleScanning: false,
|
||||||
bleScanErr: '',
|
bleScanErr: '',
|
||||||
hostBusy: false,
|
hostBusy: false,
|
||||||
|
bleTestBusy: false,
|
||||||
|
bleRtt: { count: 50, warmup: 5, result: null },
|
||||||
|
bleStream: { count: 50, period_ms: 16, tol_ms: 2, result: null },
|
||||||
masterDz: 100,
|
masterDz: 100,
|
||||||
masterDzSeeded: false,
|
masterDzSeeded: false,
|
||||||
masterLogLevel: 0,
|
masterLogLevel: 0,
|
||||||
@@ -867,6 +956,82 @@
|
|||||||
isUARTHost() {
|
isUARTHost() {
|
||||||
return this.hostUp() && (this.state?.host_transport || 'uart') === 'uart';
|
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() {
|
hostBadge() {
|
||||||
const t = this.state?.host_transport || 'none';
|
const t = this.state?.host_transport || 'none';
|
||||||
if (!this.hostUp()) return 'off';
|
if (!this.hostUp()) return 'off';
|
||||||
@@ -981,9 +1146,14 @@
|
|||||||
if (this._tapTimer) clearInterval(this._tapTimer);
|
if (this._tapTimer) clearInterval(this._tapTimer);
|
||||||
this._tapTimer = setInterval(() => { this._tapClock++; }, 250);
|
this._tapTimer = setInterval(() => { this._tapClock++; }, 250);
|
||||||
const connect = () => {
|
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 = new WebSocket(url);
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.wsConnected = true;
|
this.wsConnected = true;
|
||||||
|
this._lastWsAt = Date.now();
|
||||||
this.refreshBattery();
|
this.refreshBattery();
|
||||||
this.refreshSerialPorts();
|
this.refreshSerialPorts();
|
||||||
};
|
};
|
||||||
@@ -993,6 +1163,7 @@
|
|||||||
};
|
};
|
||||||
this.ws.onmessage = (e) => {
|
this.ws.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
|
this._lastWsAt = Date.now();
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'ota_progress') {
|
if (msg.type === 'ota_progress') {
|
||||||
this.applyOTAProgress(msg);
|
this.applyOTAProgress(msg);
|
||||||
@@ -1024,6 +1195,14 @@
|
|||||||
} catch (_) {}
|
} 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();
|
connect();
|
||||||
},
|
},
|
||||||
preserveBatteryInState(prev, next) {
|
preserveBatteryInState(prev, next) {
|
||||||
|
|||||||
+5
-7
@@ -140,15 +140,13 @@ static void ble_request_fast_conn_params(uint16_t handle) {
|
|||||||
static void ble_optimize_link(uint16_t handle) {
|
static void ble_optimize_link(uint16_t handle) {
|
||||||
int rc;
|
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);
|
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);
|
rc = ble_gap_set_data_len(handle, 251, 2120);
|
||||||
if (rc != 0) {
|
if (rc != 0) {
|
||||||
ESP_LOGW(TAG, "data len request failed: %d", rc);
|
ESP_LOGW(TAG, "data len request failed: %d", rc);
|
||||||
|
|||||||
@@ -435,11 +435,13 @@ static void handle_accel_sample(const uint8_t mac[CLIENT_MAC_LEN],
|
|||||||
esp_err_t err = client_registry_update_accel(
|
esp_err_t err = client_registry_update_accel(
|
||||||
mac, sample->slave_id, (int16_t)sample->x, (int16_t)sample->y,
|
mac, sample->slave_id, (int16_t)sample->x, (int16_t)sample->y,
|
||||||
(int16_t)sample->z);
|
(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;
|
return;
|
||||||
}
|
}
|
||||||
if (err != ESP_OK) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user