Add CACHE_STATUS UART poll and dashboard live stream.

Combine cached accel and tap in one low-overhead master command for ~16 ms
host polling. The dashboard uses a single live-stream toggle plus per-slave
accel-stream controls; fix live_stream state so polling is not cleared every
slow client refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 20:57:54 +02:00
co-authored by Cursor
parent a8d4d42920
commit f512936d97
21 changed files with 764 additions and 377 deletions
+58 -55
View File
@@ -290,10 +290,17 @@
</div>
</div>
<p class="text-muted small px-3 pt-2 mb-0">
Accel-Stream pro Slave per „Stream an“ aktivieren (~16&nbsp;ms ESP-NOW). Tap-Notify (S/D/T)
konfiguriert den Slave; „Empfang an“ startet das Abfragen von Tap-Events (~16&nbsp;ms).
<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.
</p>
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
<button type="button"
class="btn btn-sm"
:class="state.live_stream ? 'btn-warning' : 'btn-success'"
@click="setLiveStream(!state.live_stream)"
:disabled="busy || !state.uart_connected || !(state.clients || []).length"
x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"></button>
<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>
@@ -316,7 +323,7 @@
<th>Deadzone</th>
<th>Accel (LSB)</th>
<th>Akku</th>
<th>Stream</th>
<th>Accel</th>
<th>Tap-Notify</th>
<th>Tap</th>
<th>Aktion</th>
@@ -350,7 +357,8 @@
:class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'"
@click="setAccelStream(c.id, !c.accel_stream)"
:disabled="busy || !state.uart_connected || !c.available"
x-text="c.accel_stream ? 'Aus' : 'An'"></button>
x-text="c.accel_stream ? 'Aus' : 'An'"
title="ESP-NOW Accel-Stream auf Slave"></button>
</td>
<td>
<div class="d-flex flex-wrap gap-1 align-items-center">
@@ -375,16 +383,7 @@
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1 align-items-center">
<button type="button"
class="btn btn-sm"
:class="c.tap_receive ? 'btn-warning' : 'btn-outline-success'"
@click="setTapReceive(c.id, !c.tap_receive)"
:disabled="busy || !state.uart_connected || !c.available || !tapNotifyAny(c)"
x-text="c.tap_receive ? 'Aus' : 'An'"
title="Tap-Events vom Master abfragen"></button>
<span :class="tapCellClass(c)" x-text="formatLastTap(c)" :title="tapTitle(c)"></span>
</div>
<span :class="tapCellClass(c)" x-text="formatLastTap(c)" :title="tapTitle(c)"></span>
</td>
<td>
<div class="d-flex flex-wrap gap-1 align-items-center">
@@ -779,12 +778,14 @@
return t;
},
formatAccel(c) {
if (!this.state?.live_stream) return '—';
if (!c?.accel_stream) return '—';
if (!c?.accel_valid) return '…';
return `${c.accel_x} / ${c.accel_y} / ${c.accel_z}`;
},
accelTitle(c) {
if (!c?.accel_stream) return 'Accel-Stream nicht aktiviert';
if (!this.state?.live_stream) return 'Live-Stream aus — oben einschalten';
if (!c?.accel_stream) return 'Accel-Stream für diesen Slave nicht aktiv';
if (!c?.accel_valid) return 'Warte auf erste ESP-NOW Samples…';
const age = c.accel_age_ms != null ? `${c.accel_age_ms} ms alt` : '';
return `x=${c.accel_x} y=${c.accel_y} z=${c.accel_z} (raw LSB, ±2g)${age ? ' · ' + age : ''}`;
@@ -820,7 +821,7 @@
return d;
},
formatLastTap(c) {
if (!c?.tap_receive) return '—';
if (!this.state?.live_stream) return '—';
if (!this.tapNotifyAny(c)) return '—';
const labels = { single: 'Single', double: 'Double', triple: 'Triple' };
const d = this.activeTapDisplay(c);
@@ -829,7 +830,7 @@
return labels[c.last_tap] || c.last_tap;
},
tapTitle(c) {
if (!c?.tap_receive) return 'Tap-Empfang aus — „An“ klicken';
if (!this.state?.live_stream) return 'Live-Stream aus';
if (!this.tapNotifyAny(c)) return 'Tap-Notify nicht konfiguriert (S/D/T)';
const d = this.activeTapDisplay(c);
if (!d && !c?.last_tap) return 'Warte auf Tap-Event…';
@@ -1080,11 +1081,47 @@
async setMasterDeadzone() {
await this.setDeadzone(0, this.masterDz);
},
patchLiveStream(enabled) {
let clients = this.state.clients || [];
if (!enabled) {
clients = clients.map((c) => ({
...c,
accel_valid: false,
accel_x: 0,
accel_y: 0,
accel_z: 0,
accel_age_ms: 0,
last_tap: '',
last_tap_at: 0
}));
this.tapDisplay = {};
}
this.state = { ...this.state, live_stream: enabled, clients };
},
async setLiveStream(enable) {
this.busy = true;
try {
const r = await fetch('/api/live-stream', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Live-Stream fehlgeschlagen', false);
return;
}
this.patchLiveStream(!!data.enabled);
this.flash(`Live-Stream ${data.enabled ? 'an' : 'aus'}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
patchClientAccelStream(clientId, enabled) {
const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) {
return c;
}
if (c.id !== clientId) return c;
const next = { ...c, accel_stream: enabled };
if (!enabled) {
next.accel_valid = false;
@@ -1103,7 +1140,7 @@
const r = await fetch(`/api/clients/${clientId}/accel-stream`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable: enable })
body: JSON.stringify({ enable })
});
const data = await r.json();
if (!r.ok || !data.success) {
@@ -1182,40 +1219,6 @@
this.busy = false;
}
},
patchClientTapReceive(clientId, enabled) {
const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) return c;
const next = { ...c, tap_receive: enabled };
if (!enabled) {
next.last_tap = '';
next.last_tap_at = 0;
delete this.tapDisplay[clientId];
}
return next;
});
this.state = { ...this.state, clients };
},
async setTapReceive(clientId, enable) {
this.busy = true;
try {
const r = await fetch(`/api/clients/${clientId}/tap-receive`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `Tap-Empfang Slave ${clientId} fehlgeschlagen`, false);
return;
}
this.patchClientTapReceive(clientId, !!data.enabled);
this.flash(`Slave ${clientId}: Tap-Empfang ${data.enabled ? 'an' : 'aus'}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setDeadzoneAll(deadzone) {
if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false);