Add BMA456 tap detection with ESP-NOW notify and host snapshot API.
Slaves forward configured tap kinds to the master; goTool exposes CLI, dashboard, REST, and WebSocket with separate notify vs receive and 2s display cache. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+227
-3
@@ -62,6 +62,25 @@
|
||||
}
|
||||
.accel-stale { color: var(--pp-text-muted); }
|
||||
|
||||
.tap-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--pp-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tap-toggle input { margin: 0; }
|
||||
.tap-hit {
|
||||
color: #ffd166;
|
||||
font-weight: 600;
|
||||
animation: tap-flash 2s ease-out;
|
||||
}
|
||||
@keyframes tap-flash {
|
||||
from { color: #fff; transform: scale(1.08); }
|
||||
to { color: #ffd166; transform: scale(1); }
|
||||
}
|
||||
|
||||
.pp-table {
|
||||
--bs-table-color: var(--pp-text);
|
||||
--bs-table-bg: transparent;
|
||||
@@ -271,9 +290,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small px-3 pt-2 mb-0">
|
||||
Accel-Stream pro Slave per „Stream an“ aktivieren (~16 ms ESP-NOW). Ohne Aktivierung keine Werte.
|
||||
Accel-Stream pro Slave per „Stream an“ aktivieren (~16 ms ESP-NOW). Tap-Notify (S/D/T)
|
||||
konfiguriert den Slave; „Empfang an“ startet das Abfragen von Tap-Events (~16 ms).
|
||||
</p>
|
||||
<div class="card-body p-0 pt-2">
|
||||
<div class="px-3 pb-2 d-flex flex-wrap gap-2 align-items-center">
|
||||
<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>
|
||||
<label class="tap-toggle"><input type="checkbox" x-model="allTapTriple" :disabled="busy"> T</label>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@click="setTapNotifyAll(allTapSingle, allTapDouble, allTapTriple)"
|
||||
:disabled="busy || !state.uart_connected">
|
||||
Tap setzen
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0 pt-1">
|
||||
<div class="table-responsive">
|
||||
<table class="table pp-table table-hover">
|
||||
<thead>
|
||||
@@ -286,12 +317,14 @@
|
||||
<th>Accel (LSB)</th>
|
||||
<th>Akku</th>
|
||||
<th>Stream</th>
|
||||
<th>Tap-Notify</th>
|
||||
<th>Tap</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="!(state.clients || []).length">
|
||||
<tr><td colspan="9" class="text-muted text-center py-4">No clients</td></tr>
|
||||
<tr><td colspan="11" class="text-muted text-center py-4">No clients</td></tr>
|
||||
</template>
|
||||
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
|
||||
<tr>
|
||||
@@ -319,6 +352,40 @@
|
||||
:disabled="busy || !state.uart_connected || !c.available"
|
||||
x-text="c.accel_stream ? 'Aus' : 'An'"></button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<label class="tap-toggle" title="Single tap">
|
||||
<input type="checkbox"
|
||||
:checked="c.tap_notify_single"
|
||||
@change="setTapNotify(c.id, $event.target.checked, c.tap_notify_double, c.tap_notify_triple)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"> S
|
||||
</label>
|
||||
<label class="tap-toggle" title="Double tap">
|
||||
<input type="checkbox"
|
||||
:checked="c.tap_notify_double"
|
||||
@change="setTapNotify(c.id, c.tap_notify_single, $event.target.checked, c.tap_notify_triple)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"> D
|
||||
</label>
|
||||
<label class="tap-toggle" title="Triple tap">
|
||||
<input type="checkbox"
|
||||
:checked="c.tap_notify_triple"
|
||||
@change="setTapNotify(c.id, c.tap_notify_single, c.tap_notify_double, $event.target.checked)"
|
||||
:disabled="busy || !state.uart_connected || !c.available"> T
|
||||
</label>
|
||||
</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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center">
|
||||
<input type="number" class="form-control form-control-sm dz-input"
|
||||
@@ -556,7 +623,13 @@
|
||||
wsConnected: false,
|
||||
masterDz: 100,
|
||||
allDz: 100,
|
||||
allTapSingle: false,
|
||||
allTapDouble: false,
|
||||
allTapTriple: false,
|
||||
slaveDz: {},
|
||||
TAP_DISPLAY_MS: 2000,
|
||||
tapDisplay: {},
|
||||
_tapClock: 0,
|
||||
otaFile: null,
|
||||
ota: {
|
||||
active: false, phase: '', step: '', percent: 0,
|
||||
@@ -584,6 +657,8 @@
|
||||
const url = proto + '//' + location.host + '/ws';
|
||||
if (this._batteryTimer) clearInterval(this._batteryTimer);
|
||||
this._batteryTimer = setInterval(() => this.refreshBattery(), 5000);
|
||||
if (this._tapTimer) clearInterval(this._tapTimer);
|
||||
this._tapTimer = setInterval(() => { this._tapClock++; }, 250);
|
||||
const connect = () => {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = () => {
|
||||
@@ -608,6 +683,7 @@
|
||||
const prev = this.state;
|
||||
this.state = msg;
|
||||
this.preserveBatteryInState(prev, this.state);
|
||||
this.syncTapDisplay(msg.clients || []);
|
||||
if (msg.master?.deadzone != null) {
|
||||
this.masterDz = msg.master.deadzone;
|
||||
}
|
||||
@@ -718,6 +794,56 @@
|
||||
if (c.accel_age_ms != null && c.accel_age_ms > 200) return 'accel-stale';
|
||||
return '';
|
||||
},
|
||||
tapNotifyAny(c) {
|
||||
return !!(c?.tap_notify_single || c?.tap_notify_double || c?.tap_notify_triple);
|
||||
},
|
||||
syncTapDisplay(clients) {
|
||||
const now = Date.now();
|
||||
for (const c of clients) {
|
||||
if (!c?.last_tap || !c?.last_tap_at) continue;
|
||||
const prev = this.tapDisplay[c.id];
|
||||
if (!prev || c.last_tap_at >= prev.shownAt) {
|
||||
this.tapDisplay[c.id] = { kind: c.last_tap, shownAt: c.last_tap_at };
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(this.tapDisplay)) {
|
||||
if (now - this.tapDisplay[id].shownAt > this.TAP_DISPLAY_MS + 500) {
|
||||
delete this.tapDisplay[id];
|
||||
}
|
||||
}
|
||||
},
|
||||
activeTapDisplay(c) {
|
||||
void this._tapClock;
|
||||
const d = this.tapDisplay[c?.id];
|
||||
if (!d) return null;
|
||||
if (Date.now() - d.shownAt >= this.TAP_DISPLAY_MS) return null;
|
||||
return d;
|
||||
},
|
||||
formatLastTap(c) {
|
||||
if (!c?.tap_receive) return '—';
|
||||
if (!this.tapNotifyAny(c)) return '—';
|
||||
const labels = { single: 'Single', double: 'Double', triple: 'Triple' };
|
||||
const d = this.activeTapDisplay(c);
|
||||
if (d) return labels[d.kind] || d.kind;
|
||||
if (!c?.last_tap) return '…';
|
||||
return labels[c.last_tap] || c.last_tap;
|
||||
},
|
||||
tapTitle(c) {
|
||||
if (!c?.tap_receive) return 'Tap-Empfang aus — „An“ klicken';
|
||||
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…';
|
||||
const kind = d?.kind || c?.last_tap;
|
||||
const at = d?.shownAt || c?.last_tap_at;
|
||||
const age = at ? (Date.now() - at) + ' ms her' : '';
|
||||
return `Letzter Tap: ${kind}${age ? ' · ' + age : ''}`;
|
||||
},
|
||||
tapCellClass(c) {
|
||||
if (this.activeTapDisplay(c)) return 'tap-hit';
|
||||
if (!c?.last_tap || !c?.last_tap_at) return 'text-muted';
|
||||
if (Date.now() - c.last_tap_at < this.TAP_DISPLAY_MS) return 'tap-hit';
|
||||
return '';
|
||||
},
|
||||
formatSize(n) {
|
||||
if (n == null) return '';
|
||||
if (n < 1024) return n + ' B';
|
||||
@@ -992,6 +1118,104 @@
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
patchClientTapNotify(clientId, single, doubleTap, triple) {
|
||||
const clients = (this.state.clients || []).map((c) => {
|
||||
if (c.id !== clientId) return c;
|
||||
const next = {
|
||||
...c,
|
||||
tap_notify_single: single,
|
||||
tap_notify_double: doubleTap,
|
||||
tap_notify_triple: triple
|
||||
};
|
||||
if (!single && !doubleTap && !triple) {
|
||||
next.last_tap = '';
|
||||
next.last_tap_at = 0;
|
||||
delete this.tapDisplay[c.id];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
this.state = { ...this.state, clients };
|
||||
},
|
||||
async setTapNotify(clientId, single, doubleTap, triple) {
|
||||
this.busy = true;
|
||||
try {
|
||||
const r = await fetch(`/api/clients/${clientId}/tap-notify`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ single, double_tap: doubleTap, triple })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
this.flash(data.error || `Tap-Notify Slave ${clientId} fehlgeschlagen`, false);
|
||||
return;
|
||||
}
|
||||
this.patchClientTapNotify(clientId, !!data.single, !!data.double_tap, !!data.triple);
|
||||
const on = [data.single && 'S', data.double_tap && 'D', data.triple && 'T'].filter(Boolean).join('/') || 'aus';
|
||||
this.flash(`Slave ${clientId}: Tap-Notify ${on}`, true);
|
||||
} catch (e) {
|
||||
this.flash(String(e), false);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async setTapNotifyAll(single, doubleTap, triple) {
|
||||
this.busy = true;
|
||||
try {
|
||||
const r = await fetch('/api/tap-notify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ all_clients: true, single, double_tap: doubleTap, triple })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
this.flash(data.error || 'Tap-Notify für alle Slaves fehlgeschlagen', false);
|
||||
return;
|
||||
}
|
||||
for (const c of (this.state.clients || [])) {
|
||||
this.patchClientTapNotify(c.id, !!single, !!doubleTap, !!triple);
|
||||
}
|
||||
const on = [single && 'S', doubleTap && 'D', triple && 'T'].filter(Boolean).join('/') || 'aus';
|
||||
this.flash(`Alle Slaves: Tap-Notify ${on} (${data.slaves_updated} aktualisiert)`, true);
|
||||
} catch (e) {
|
||||
this.flash(String(e), false);
|
||||
} finally {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user