powerpods/goTool/webui/index.html
simon a8d4d42920 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>
2026-05-29 20:42:57 +02:00

1336 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Powerpod Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<style>
:root {
--pp-bg: #0f1419;
--pp-surface: #1a1f26;
--pp-surface-raised: #222831;
--pp-border: #3d4449;
--pp-text: #f0f3f5;
--pp-text-secondary: #c5cdd6;
--pp-text-muted: #9aa8b5;
--pp-heading: #ffffff;
--pp-accent: #8ec8ff;
}
body {
background: var(--pp-bg);
color: var(--pp-text);
min-height: 100vh;
}
.navbar {
background: var(--pp-surface) !important;
border-bottom: 1px solid var(--pp-border);
}
.navbar-brand { color: var(--pp-heading) !important; }
.card {
background: var(--pp-surface);
border-color: var(--pp-border);
color: var(--pp-text);
}
.card-header {
background: var(--pp-surface-raised);
border-color: var(--pp-border);
color: var(--pp-heading);
font-weight: 600;
}
.text-muted { color: var(--pp-text-muted) !important; }
dl dt.text-muted {
color: var(--pp-text-secondary) !important;
font-weight: 500;
}
dl dd { color: var(--pp-text); }
.badge-online { background: #00a86b; color: #fff; }
.badge-offline { background: #5c6570; color: #f0f3f5; }
.badge.bg-secondary { background: #4a5560 !important; color: #f0f3f5; }
.mac, .accel {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--pp-accent);
}
.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;
--bs-table-border-color: var(--pp-border);
--bs-table-hover-color: var(--pp-heading);
--bs-table-hover-bg: rgba(255, 255, 255, 0.04);
margin-bottom: 0;
}
.pp-table thead th {
color: var(--pp-text-secondary);
font-weight: 600;
border-color: var(--pp-border);
background: var(--pp-surface-raised);
}
.pp-table tbody td {
color: var(--pp-text);
border-color: var(--pp-border);
vertical-align: middle;
}
.pp-table tbody tr:hover td { color: var(--pp-heading); }
.alert-danger {
background: rgba(220, 53, 69, 0.2);
border-color: #e35d6a;
color: #ffb3ba;
}
.alert-warning {
background: rgba(255, 193, 7, 0.15);
border-color: #d4a012;
color: #ffe08a;
}
.alert-success {
background: rgba(0, 168, 107, 0.15);
border-color: #00a86b;
color: #b8f0d8;
}
.form-control, .form-control:focus, .btn-outline-secondary {
background: var(--pp-surface-raised);
border-color: var(--pp-border);
color: var(--pp-text);
}
.form-control:focus {
box-shadow: 0 0 0 0.2rem rgba(142, 200, 255, 0.2);
border-color: var(--pp-accent);
}
.form-control::placeholder { color: var(--pp-text-muted); }
.btn-outline-secondary:hover {
background: var(--pp-border);
color: var(--pp-heading);
}
.btn-primary {
background: #2d6cdf;
border-color: #2d6cdf;
}
.btn-sm { font-size: 0.8rem; }
.dz-input { width: 5.5rem; }
.config-input { max-width: 8rem; }
.config-block {
border-top: 1px solid var(--pp-border);
margin-top: 1rem;
padding-top: 1rem;
}
.progress {
background: var(--pp-surface-raised);
border: 1px solid var(--pp-border);
}
.progress-bar {
background: #2d6cdf;
}
.progress-bar.bg-success { background: #00a86b !important; }
.progress-bar.bg-danger { background: #e35d6a !important; }
.progress-bar.bg-info { background: #2d6cdf !important; }
.progress-bar.bg-secondary { background: #5c6570 !important; }
.ota-progress-table .ota-progress-col {
width: 100%;
}
.ota-progress-table .ota-restart-col {
width: 6.5rem;
white-space: nowrap;
vertical-align: middle !important;
}
.form-control[type="file"]::file-selector-button {
background: var(--pp-border);
border: none;
color: var(--pp-text);
margin-right: 0.5rem;
padding: 0.25rem 0.5rem;
}
</style>
</head>
<body x-data="dashboard()" x-init="connect()">
<nav class="navbar navbar-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">Powerpod</span>
<span class="d-flex align-items-center gap-2">
<span class="badge rounded-pill me-1"
:class="state.uart_connected ? 'badge-online' : 'badge-offline'"
x-text="state.uart_connected ? 'UART' : 'UART off'"></span>
<span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'"
x-text="wsConnected ? 'WS' : 'WS off'"></span>
<small class="text-muted ms-2" x-text="state.updated_at || '—'"></small>
</span>
</div>
</nav>
<main class="container pb-5">
<template x-if="configMsg">
<div class="alert py-2 mb-3"
:class="configMsgOk ? 'alert-success' : 'alert-danger'"
x-text="configMsg"></div>
</template>
<div class="row g-4">
<section class="col-lg-4">
<div class="card h-100">
<div class="card-header">Master (BMA456 lokal)</div>
<div class="card-body">
<p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p>
<p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p>
<template x-if="!state.uart_connected">
<div class="alert alert-warning py-2 mb-2"
x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div>
</template>
<template x-if="state.uart_connected && !state.serial_ok">
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div>
</template>
<template x-if="state.master?.ok">
<dl class="row mb-0">
<dt class="col-5 text-muted">Version</dt>
<dd class="col-7" x-text="state.master.version"></dd>
<dt class="col-5 text-muted">Hash</dt>
<dd class="col-7 text-break" x-text="state.master.git_hash"></dd>
<dt class="col-5 text-muted">Partition</dt>
<dd class="col-7" x-text="state.master.running_partition || '—'"></dd>
<dt class="col-5 text-muted">Deadzone</dt>
<dd class="col-7" x-text="state.master.deadzone != null ? state.master.deadzone + ' LSB' : '—'"></dd>
<dt class="col-5 text-muted">LiPo 1</dt>
<dd class="col-7" x-text="formatLipo(state.master?.lipo1)"></dd>
<dt class="col-5 text-muted">LiPo 2</dt>
<dd class="col-7" x-text="formatLipo(state.master?.lipo2)"></dd>
</dl>
</template>
<template x-if="state.master && !state.master.ok">
<div class="alert alert-warning py-2 mb-0" x-text="state.master.error"></div>
</template>
<div class="config-block">
<h6 class="text-secondary mb-2">Konfiguration Master</h6>
<label class="form-label text-muted small mb-1" for="master-dz-input">
Accel Deadzone (LSB)
</label>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input id="master-dz-input" type="number"
class="form-control form-control-sm config-input"
min="0" max="4095" step="1"
placeholder="z. B. 100"
x-model.number="masterDz"
:disabled="busy || !state.uart_connected">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Setzen
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="findMe()"
:disabled="busy || !state.uart_connected"
title="LED-Ring: Rot/Grün/Blau je 3×">
Find me
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)"
:disabled="busy || !state.uart_connected"
title="Master neu starten">
Restart
</button>
</div>
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected">
UART nicht verbunden — Eingabe gesperrt.
</p>
</div>
</div>
</div>
</section>
<section class="col-lg-8">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>ESP-NOW Slaves</span>
<div class="d-flex align-items-center gap-2">
<input type="number" class="form-control form-control-sm dz-input"
min="0" max="4095" placeholder="LSB"
x-model.number="allDz"
:disabled="busy">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setDeadzoneAll(allDz)"
:disabled="busy || !state.uart_connected">
Alle Slaves
</button>
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
</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).
</p>
<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>
<tr>
<th>ID</th>
<th>MAC</th>
<th>Ver</th>
<th>Status</th>
<th>Deadzone</th>
<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="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>
<td x-text="c.id"></td>
<td class="mac" x-text="formatMac(c.mac)"></td>
<td x-text="c.version"></td>
<td>
<span class="badge rounded-pill"
:class="c.available ? 'badge-online' : 'badge-offline'"
x-text="c.available ? 'available' : 'inactive'"></span>
</td>
<td x-text="c.deadzone != null ? c.deadzone : '—'"></td>
<td>
<span class="accel"
:class="accelCellClass(c)"
x-text="formatAccel(c)"
:title="accelTitle(c)"></span>
</td>
<td class="small" x-text="formatLipoPair(c)" :title="lipoTitle(c)"></td>
<td>
<button type="button"
class="btn btn-sm"
: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>
</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"
min="0" max="4095"
:placeholder="String(c.deadzone || 100)"
x-model.number="slaveDz[c.id]"
:disabled="busy">
<button type="button" class="btn btn-primary btn-sm"
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
:disabled="busy || !state.uart_connected || !c.available">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="unicastTest(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
title="ESP-NOW Unicast-Test">
Test
</button>
<button type="button" class="btn btn-outline-info btn-sm"
@click="ledRing({ clientId: c.id })"
:disabled="busy || !state.uart_connected || !c.available"
title="LED-Ring (aktueller Modus)">
LED
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="ledRing({ clientId: c.id, mode: 'find-me' })"
:disabled="busy || !state.uart_connected || !c.available"
title="Find me">
Find
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
title="Neustart per ESP-NOW">
Restart
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">LED-Ring</div>
<div class="card-body">
<p class="text-muted small mb-3">
Modi: <code>clear</code>, <code>color</code> (ganzer Ring), <code>progress</code> (0100&nbsp;%),
<code>digit</code> (010), <code>blink</code>, <code>find-me</code>.
Ziel: Master (<code>client_id=0</code>), ein Slave oder alle Slaves (Broadcast).
</p>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label class="form-label small text-muted">Modus</label>
<select class="form-select form-select-sm" x-model="led.mode" :disabled="busy">
<option value="color">Farbe (alle LEDs)</option>
<option value="clear">Aus (clear)</option>
<option value="progress">Progress</option>
<option value="digit">Ziffer/Symbol</option>
<option value="blink">Blink</option>
<option value="find-me">Find me</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">RGB / Intensität</label>
<div class="d-flex flex-wrap gap-2">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="R" x-model.number="led.r" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="G" x-model.number="led.g" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="B" x-model.number="led.b" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:5rem" min="0" max="255"
title="0 = Geräte-Default (~5 %)"
placeholder="Int." x-model.number="led.intensity" :disabled="busy">
</div>
</div>
<div class="col-md-2" x-show="led.mode === 'progress'">
<label class="form-label small text-muted">Progress %</label>
<input type="number" class="form-control form-control-sm" min="0" max="100"
x-model.number="led.progress" :disabled="busy">
</div>
<div class="col-md-2" x-show="led.mode === 'digit'">
<label class="form-label small text-muted">Ziffer 010</label>
<input type="number" class="form-control form-control-sm" min="0" max="10"
x-model.number="led.digit" :disabled="busy">
</div>
<div class="col-md-2" x-show="led.mode === 'blink'">
<label class="form-label small text-muted">Blink ms × Anzahl</label>
<div class="d-flex gap-1">
<input type="number" class="form-control form-control-sm" min="1"
x-model.number="led.blinkMs" :disabled="busy">
<input type="number" class="form-control form-control-sm" min="1"
x-model.number="led.blinkCount" :disabled="busy">
</div>
</div>
<div class="col-md-4 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm"
@click="ledRing({ clientId: 0 })"
:disabled="busy || !state.uart_connected">
Master
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
@click="ledRing({ allClients: true, slavesOnly: true })"
:disabled="busy || !state.uart_connected">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="ledRing({ allClients: true })"
:disabled="busy || !state.uart_connected">
Alle + Master
</button>
</div>
</div>
</div>
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body">
<p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf den Master (UART), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling.
</p>
<div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !state.uart_connected">
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()"
:disabled="ota.active || busy || !state.uart_connected || !otaFile">
Firmware hochladen
</button>
<span class="text-muted small" x-show="otaFile"
x-text="otaFile ? otaFile.name + ' (' + formatSize(otaFile.size) + ')' : ''"></span>
</div>
<div class="ota-progress-panel mt-3"
x-show="ota.active || ota.phase === 'distributing' || ota.phase === 'done' || ota.phase === 'error'">
<p class="small text-muted mb-2">Master (UART)</p>
<table class="table table-sm pp-table ota-progress-table mb-3">
<tbody>
<tr>
<td class="ota-progress-col">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Master</span>
<span x-text="otaMasterPct() + '%'"></span>
</div>
<div class="progress mb-1" style="height: 1.1rem;">
<div class="progress-bar" role="progressbar"
:style="'width: ' + otaMasterPct() + '%'"
:class="otaMasterBarClass()"
x-text="otaMasterPct() + '%'"></div>
</div>
<p class="small text-muted mb-0"
x-text="ota.masterMessage || (ota.step === 'master' ? ota.message : '')"></p>
</td>
<td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)"
:disabled="busy || !state.uart_connected">
Restart
</button>
</td>
</tr>
</tbody>
</table>
<p class="small text-muted mb-2">
Slaves (ESP-NOW)
<span x-text="'(' + otaSlaveRows().length + ')'"></span>
</p>
<p class="small text-muted mb-2" x-show="ota.message && ota.step === 'slaves'"
x-text="ota.message"></p>
<table class="table table-sm pp-table ota-progress-table mb-2">
<tbody>
<template x-for="row in otaSlaveRows()" :key="'ota-slave-' + row.id">
<tr>
<td class="ota-progress-col">
<div class="d-flex justify-content-between small mb-1">
<span>
Slave <span x-text="row.id"></span>
<span class="text-muted mac ms-1" x-text="row.mac"></span>
<span class="badge bg-secondary ms-1" x-text="row.statusLabel"></span>
</span>
<span x-text="row.percent + '%'"></span>
</div>
<div class="progress mb-1" style="height: 0.85rem;">
<div class="progress-bar" role="progressbar"
:class="row.barClass"
:style="'width: ' + row.percent + '%'"></div>
</div>
<p class="small text-muted mb-0" x-text="row.bytesLabel"></p>
</td>
<td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(row.id)"
:disabled="busy || !state.uart_connected">
Restart
</button>
</td>
</tr>
</template>
<tr x-show="otaSlaveRows().length === 0">
<td colspan="2" class="text-muted small py-3">
Warte auf Slave-Fortschritt…
</td>
</tr>
</tbody>
</table>
<p class="small mb-0" x-show="ota.phase === 'done' || ota.phase === 'error'"
:class="ota.phase === 'error' ? 'text-danger' : 'text-success'"
x-text="ota.message"></p>
</div>
</div>
</div>
</section>
</div>
</main>
<script>
function dashboard() {
return {
state: { master: {}, clients: [] },
ws: null,
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,
masterPercent: 0, masterDone: false, masterMessage: '',
message: '', slaves: 0, imageSize: 0,
slaveProgress: {},
slaveDetails: {}
},
busy: false,
configMsg: '',
configMsgOk: false,
led: {
mode: 'color',
r: 0,
g: 120,
b: 255,
intensity: 0,
progress: 50,
digit: 0,
blinkMs: 350,
blinkCount: 1
},
connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
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 = () => {
this.wsConnected = true;
this.refreshBattery();
};
this.ws.onclose = () => {
this.wsConnected = false;
setTimeout(connect, 2000);
};
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'ota_progress') {
this.applyOTAProgress(msg);
return;
}
if (msg.type === 'battery_status') {
if (msg.samples?.length) this.applyBatterySamples(msg.samples);
return;
}
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;
}
for (const c of (msg.clients || [])) {
if (c.deadzone != null && this.slaveDz[c.id] == null) {
this.slaveDz[c.id] = c.deadzone;
}
}
} catch (_) {}
};
};
connect();
},
preserveBatteryInState(prev, next) {
if (!prev || !next) return;
const keepLipo = (oldL, newL) => {
if (newL?.valid) return newL;
if (oldL?.valid) return oldL;
return newL ?? oldL;
};
const keepAge = (oldAge, newAge, hasValid) => {
if (hasValid && newAge != null) return newAge;
if (oldAge != null && !hasValid) return oldAge;
return newAge ?? oldAge;
};
if (next.master) {
const pm = prev.master || {};
const l1 = keepLipo(pm.lipo1, next.master.lipo1);
const l2 = keepLipo(pm.lipo2, next.master.lipo2);
next.master.lipo1 = l1;
next.master.lipo2 = l2;
next.master.battery_age_ms = keepAge(
pm.battery_age_ms, next.master.battery_age_ms, !!(l1?.valid || l2?.valid));
}
if (!Array.isArray(next.clients)) return;
const prevById = Object.fromEntries((prev.clients || []).map((c) => [c.id, c]));
next.clients = next.clients.map((c) => {
const p = prevById[c.id];
if (!p) return c;
const l1 = keepLipo(p.lipo1, c.lipo1);
const l2 = keepLipo(p.lipo2, c.lipo2);
return {
...c,
lipo1: l1,
lipo2: l2,
battery_age_ms: keepAge(p.battery_age_ms, c.battery_age_ms, !!(l1?.valid || l2?.valid))
};
});
},
applyBatterySamples(samples) {
if (!samples?.length) return;
for (const s of samples) {
if (s.client_id === 0) {
if (!this.state.master) this.state.master = {};
this.state.master.lipo1 = s.lipo1;
this.state.master.lipo2 = s.lipo2;
this.state.master.battery_age_ms = s.age_ms;
continue;
}
const c = (this.state.clients || []).find((x) => x.id === s.client_id);
if (c) {
c.lipo1 = s.lipo1;
c.lipo2 = s.lipo2;
c.battery_age_ms = s.age_ms;
}
}
},
async refreshBattery() {
if (!this.state?.uart_connected) return;
try {
const r = await fetch('/api/battery?all_clients=1');
if (!r.ok) return;
const data = await r.json();
if (data.samples?.length) this.applyBatterySamples(data.samples);
} catch (_) {}
},
formatMac(hex) {
if (!hex || hex.length !== 12) return hex || '';
return hex.match(/.{2}/g).join(':');
},
formatLipo(l) {
if (!l?.valid) return '—';
const v = (l.voltage_mv / 1000).toFixed(2);
return l.percent != null ? `${v} V (${l.percent}%)` : `${v} V`;
},
formatLipoPair(c) {
return `1: ${this.formatLipo(c?.lipo1)} · 2: ${this.formatLipo(c?.lipo2)}`;
},
lipoTitle(c) {
if (!c?.lipo1?.valid && !c?.lipo2?.valid) return 'Keine ADC-Daten (Cache ~30 s)';
let t = `LiPo1 ${c.lipo1?.voltage_mv ?? '—'} mV, LiPo2 ${c.lipo2?.voltage_mv ?? '—'} mV`;
if (c.battery_age_ms != null) t += `, Alter ${c.battery_age_ms} ms`;
return t;
},
formatAccel(c) {
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 (!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 : ''}`;
},
accelCellClass(c) {
if (!c?.accel_valid) return 'accel-stale';
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';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
return (n / (1024 * 1024)).toFixed(2) + ' MiB';
},
otaMasterPct() {
if (this.ota.masterDone || this.ota.step === 'slaves' ||
this.ota.phase === 'distributing' || this.ota.phase === 'done') {
return this.ota.masterPercent >= 100 ? 100 : (this.ota.masterPercent || 100);
}
if (this.ota.step === 'master' || this.ota.phase === 'preparing' ||
this.ota.phase === 'ready' || this.ota.phase === 'uploading') {
return this.ota.masterPercent ?? this.ota.percent ?? 0;
}
return this.ota.masterPercent || 0;
},
otaMasterBarClass() {
if (this.ota.phase === 'error' && this.ota.step === 'master') return 'bg-danger';
if (this.otaMasterPct() >= 100) return 'bg-success';
return '';
},
mergeSlaveDetails(incoming) {
const out = { ...(this.ota.slaveDetails || {}) };
if (!incoming) return out;
for (const [k, v] of Object.entries(incoming)) {
const id = Number(k);
out[id] = { ...(out[id] || {}), ...v };
}
return out;
},
otaSlaveStatusLabel(status) {
const labels = {
0: 'idle', 1: 'vorbereiten', 2: 'bereit', 3: 'lädt',
4: 'fertig', 5: 'fehler'
};
return labels[status] || 'status ' + status;
},
otaSlaveBarClass(status) {
if (status === 4) return 'bg-success';
if (status === 5) return 'bg-danger';
if (status === 1 || status === 2) return 'bg-secondary';
return 'bg-info';
},
otaSlaveRows() {
const details = this.mergeSlaveDetails(this.ota.slaveDetails);
const ids = new Set(Object.keys(details).map(Number).filter(id => !Number.isNaN(id)));
for (const [k, v] of Object.entries(this.ota.slaveProgress || {})) {
const id = Number(k);
if (!Number.isNaN(id)) {
ids.add(id);
if (!details[id]) {
details[id] = { bytes_written: v, status: 3 };
}
}
}
const rows = [];
for (const id of [...ids].sort((a, b) => a - b)) {
const c = (this.state.clients || []).find(x => x.id === id);
const d = details[id] || {};
const total = d.total_bytes || this.ota.imageSize || this.otaFile?.size || 0;
const bytes = d.bytes_written ?? 0;
const status = d.status ?? 0;
let percent = 0;
if (total > 0) percent = Math.min(100, Math.round(bytes * 100 / total));
if (status === 4) percent = 100;
rows.push({
id,
mac: c?.mac ? this.formatMac(c.mac) : '—',
percent,
status,
statusLabel: this.otaSlaveStatusLabel(status),
barClass: this.otaSlaveBarClass(status),
bytesLabel: total ? `${bytes} / ${total} bytes` : `${bytes} bytes`
});
}
if (rows.length === 0 && (this.ota.phase === 'distributing' || this.ota.step === 'slaves')) {
const targets = (this.state.clients || []).filter(c => c.available);
const limit = this.ota.slaves > 0 ? this.ota.slaves : targets.length;
for (const c of targets.slice(0, limit)) {
rows.push({
id: c.id,
mac: c.mac ? this.formatMac(c.mac) : '—',
percent: 0,
status: 1,
statusLabel: this.otaSlaveStatusLabel(1),
barClass: this.otaSlaveBarClass(1),
bytesLabel: '—'
});
}
}
return rows;
},
applyOTAProgress(p) {
this.ota.phase = p.phase || '';
this.ota.step = p.step || this.ota.step || '';
this.ota.percent = p.percent ?? this.ota.percent;
this.ota.message = p.message || '';
if (p.image_size) this.ota.imageSize = p.image_size;
if (p.master_done) this.ota.masterDone = true;
if (p.step === 'master' || p.phase === 'preparing' || p.phase === 'ready' || p.phase === 'uploading') {
if (p.master_percent != null) this.ota.masterPercent = p.master_percent;
else if (p.percent != null) this.ota.masterPercent = p.percent;
if (p.master_message) this.ota.masterMessage = p.master_message;
else if (p.message) this.ota.masterMessage = p.message;
} else if (p.step === 'slaves' || p.phase === 'distributing' || p.phase === 'done') {
this.ota.masterDone = true;
if (p.master_percent != null) this.ota.masterPercent = p.master_percent;
else if (this.ota.masterPercent < 100) this.ota.masterPercent = 100;
if (p.master_message) this.ota.masterMessage = p.master_message;
}
if (p.slaves != null) this.ota.slaves = p.slaves;
if (p.phase === 'distributing') {
this.ota.step = 'slaves';
}
if (p.slave_details) {
const merged = this.mergeSlaveDetails(p.slave_details);
this.ota.slaveDetails = { ...merged };
if (Object.keys(this.ota.slaveDetails).length > 0) {
this.ota.step = 'slaves';
}
}
if (p.slave_progress) {
if (!this.ota.slaveProgress) this.ota.slaveProgress = {};
for (const [k, v] of Object.entries(p.slave_progress)) {
this.ota.slaveProgress[Number(k)] = v;
}
}
if (p.phase === 'preparing' || p.phase === 'ready' || p.phase === 'uploading' ||
p.phase === 'distributing') {
this.ota.active = true;
}
if (p.phase === 'done' || p.phase === 'error') {
this.ota.active = false;
if (p.phase === 'done') {
this.ota.masterPercent = 100;
}
}
},
async uploadOTA() {
if (!this.otaFile) return;
this.ota = {
active: true, phase: 'preparing', step: 'master', percent: 0,
masterPercent: 0, masterDone: false, masterMessage: 'Upload startet…',
message: 'Upload startet…', slaves: 0,
imageSize: this.otaFile?.size || 0,
slaveProgress: {}, slaveDetails: {}
};
this.busy = true;
const form = new FormData();
form.append('firmware', this.otaFile);
try {
const r = await fetch('/api/ota', { method: 'POST', body: form });
const data = await r.json();
if (!r.ok || !data.success) {
this.applyOTAProgress({
phase: 'error', step: '', percent: 0,
message: data.error || 'OTA fehlgeschlagen'
});
return;
}
if (this.ota.phase !== 'done') {
const slot = data.target_slot != null ? 'ota_' + data.target_slot : '?';
this.applyOTAProgress({
phase: 'done', step: '', percent: 100,
message: `OK — ${data.bytes_written} Bytes, Slot ${slot}. Alle Knoten neu starten.`
});
}
} catch (e) {
this.applyOTAProgress({ phase: 'error', step: '', percent: 0, message: String(e) });
} finally {
this.busy = false;
}
},
flash(msg, ok) {
this.configMsg = msg;
this.configMsgOk = ok;
setTimeout(() => { this.configMsg = ''; }, 5000);
},
async setDeadzone(clientId, deadzone, opts = {}) {
if (deadzone == null || deadzone < 0) {
this.flash('Ungültiger Deadzone-Wert', false);
return;
}
this.busy = true;
try {
const r = await fetch('/api/deadzone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
write: true,
deadzone: deadzone,
client_id: clientId,
all_clients: !!opts.allClients,
slaves_only: !!opts.slavesOnly
})
});
const data = await r.json();
if (!r.ok || !data.success) {
const err = data.error ||
(opts.slavesOnly ? 'Deadzone für Slaves fehlgeschlagen' : `Deadzone für Client ${clientId} fehlgeschlagen`);
this.flash(err, false);
return;
}
if (opts.slavesOnly) {
this.flash(`Slaves: Deadzone ${data.deadzone} LSB (${data.slaves_updated} per ESP-NOW, Master unverändert)`, true);
return;
}
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`${who}: Deadzone ${data.deadzone} LSB gesetzt`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async readMasterDeadzone() {
this.busy = true;
try {
const r = await fetch('/api/deadzone?client_id=0');
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Master-Deadzone lesen fehlgeschlagen', false);
return;
}
this.masterDz = data.deadzone;
this.flash(`Master: Deadzone ${data.deadzone} LSB`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setMasterDeadzone() {
await this.setDeadzone(0, this.masterDz);
},
patchClientAccelStream(clientId, enabled) {
const clients = (this.state.clients || []).map((c) => {
if (c.id !== clientId) {
return c;
}
const next = { ...c, accel_stream: enabled };
if (!enabled) {
next.accel_valid = false;
next.accel_x = 0;
next.accel_y = 0;
next.accel_z = 0;
next.accel_age_ms = 0;
}
return next;
});
this.state = { ...this.state, clients };
},
async setAccelStream(clientId, enable) {
this.busy = true;
try {
const r = await fetch(`/api/clients/${clientId}/accel-stream`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable: enable })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `Accel-Stream Slave ${clientId} fehlgeschlagen`, false);
return;
}
this.patchClientAccelStream(clientId, !!data.enabled);
this.flash(`Slave ${clientId}: Accel-Stream ${data.enabled ? 'an' : 'aus'}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
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);
return;
}
await this.setDeadzone(0, deadzone, { allClients: true, slavesOnly: true });
},
async unicastTest(clientId) {
this.busy = true;
try {
const r = await fetch('/api/unicast-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId, seq: Date.now() % 100000 })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `Unicast-Test Slave ${clientId} fehlgeschlagen`, false);
return;
}
this.flash(`Unicast-Test Slave ${clientId} OK (seq ${data.seq})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async restart(clientId = 0) {
this.busy = true;
try {
const r = await fetch('/api/restart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Restart fehlgeschlagen', false);
return;
}
const label = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`Restart ausgelöst (${label})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async ledRing(opts = {}) {
const clientId = opts.clientId ?? 0;
const mode = opts.mode ?? this.led.mode;
this.busy = true;
try {
const r = await fetch('/api/led-ring', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode,
client_id: clientId,
all_clients: !!opts.allClients,
slaves_only: !!opts.slavesOnly,
r: this.led.r,
g: this.led.g,
b: this.led.b,
intensity: this.led.intensity,
progress: this.led.progress,
digit: this.led.digit,
blink_ms: this.led.blinkMs,
blink_count: this.led.blinkCount
})
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'LED-Ring fehlgeschlagen', false);
return;
}
let label = 'Master';
if (opts.allClients) {
label = opts.slavesOnly
? `Alle Slaves (${data.slaves_updated})`
: `Alle + Master (${data.slaves_updated} Slaves)`;
} else if (clientId > 0) {
label = `Slave ${clientId}`;
}
this.flash(`LED ${mode}${label}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async findMe(clientId = 0) {
this.busy = true;
try {
const r = await fetch('/api/find-me', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Find me fehlgeschlagen', false);
return;
}
const label = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`Find me gestartet (${label})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
}
};
}
</script>
</body>
</html>