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
+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) {