Files
powerpods/goTool/webui/index.html
T
skrueckenandCursor af20635047 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>
2026-10-23 23:27:39 +02:00

2050 lines
87 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="hostUp() ? 'badge-online' : 'badge-offline'"
x-text="hostBadge()"></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>
<section class="card mb-4">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Host-Verbindung</span>
<span class="text-muted small" x-text="hostEndpointLabel()"></span>
</div>
<div class="card-body">
<ul class="nav nav-pills mb-3 gap-1">
<li class="nav-item">
<button type="button" class="nav-link"
:class="connTab === 'uart' ? 'active' : ''"
@click="connTab = 'uart'">UART</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link"
:class="connTab === 'ble' ? 'active' : ''"
@click="connTab = 'ble'">BLE</button>
</li>
</ul>
<div x-show="connTab === 'uart'" class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label text-muted small mb-1" for="uart-port">Port</label>
<select id="uart-port" class="form-control form-control-sm" style="min-width:12rem"
x-model="uartPort" :disabled="hostBusy">
<option value="">— Port wählen —</option>
<template x-for="p in serialPorts" :key="p">
<option :value="p" x-text="p"></option>
</template>
</select>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="refreshSerialPorts()" :disabled="hostBusy">Aktualisieren</button>
<button type="button" class="btn btn-primary btn-sm"
@click="connectUART()" :disabled="hostBusy || !uartPort">Connect</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="disconnectHost()" :disabled="hostBusy || !hostUp()">Disconnect</button>
</div>
<div x-show="connTab === 'ble'">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="scanBLE()" :disabled="hostBusy || bleScanning"
x-text="bleScanning ? 'Scan…' : 'Scan'"></button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="disconnectHost()" :disabled="hostBusy || !hostUp()">Disconnect</button>
<span class="text-muted small" x-show="bleScanErr" x-text="bleScanErr"></span>
</div>
<div class="table-responsive" x-show="bleDevices.length">
<table class="table table-sm pp-table mb-0">
<thead>
<tr><th>Name</th><th>Adresse</th><th>RSSI</th><th></th></tr>
</thead>
<tbody>
<template x-for="d in bleDevices" :key="d.address">
<tr>
<td x-text="d.name || '—'"></td>
<td class="mac" x-text="d.address"></td>
<td x-text="d.rssi"></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-primary"
@click="connectBLE(d)" :disabled="hostBusy">Connect</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="text-muted small mb-0" x-show="!bleDevices.length && !bleScanning">
Scan startet eine kurze Suche (trennt aktive BLE-Verbindung).
</p>
</div>
</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">
<div class="card-header">Master (BMA456 lokal)</div>
<div class="card-body">
<p class="text-muted small mb-2"
x-text="(state.host_transport || 'none').toUpperCase() + ' ' + (state.host_endpoint || state.serial_port || '—')"></p>
<p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p>
<template x-if="!hostUp()">
<div class="alert alert-warning py-2 mb-2"
x-text="state.serial_error || 'Host nicht verbunden — oben UART oder BLE wählen.'"></div>
</template>
<template x-if="hostUp() && !state.serial_ok">
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Host 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">Log-Level</dt>
<dd class="col-7" x-text="formatLogLevel(masterLogLevel)"></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 || !hostUp()">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()"
:disabled="busy || !hostUp()">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()"
:disabled="busy || !hostUp()">
Setzen
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="findMe()"
:disabled="busy || !hostUp()"
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 || !hostUp()"
title="Master neu starten">
Restart
</button>
</div>
<label class="form-label text-muted small mb-1 mt-3" for="master-log-level">
ESP Log-Level (global)
</label>
<div class="d-flex flex-wrap gap-2 align-items-end">
<select id="master-log-level" class="form-select form-select-sm config-input"
x-model.number="masterLogLevel"
:disabled="busy || !hostUp()">
<option value="0">None (aus)</option>
<option value="1">Error</option>
<option value="2">Warn</option>
<option value="3">Info</option>
<option value="4">Debug</option>
<option value="5">Verbose</option>
</select>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterLogLevel()"
:disabled="busy || !hostUp()">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterLogLevel()"
:disabled="busy || !hostUp()">
Setzen
</button>
</div>
<h6 class="text-secondary mb-2 mt-3">Tap-Erkennung (BMA456)</h6>
<div class="row g-2 small">
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-sens">sens (0–15)</label>
<input id="tap-sens" type="number" class="form-control form-control-sm"
min="0" max="15" x-model.number="masterTapCfg.tap_sens_thres"
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-max-gest">max_gest (×5ms)</label>
<input id="tap-max-gest" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.max_gest_dur"
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-shock">shock (×5ms)</label>
<input id="tap-shock" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.tap_shock_dur"
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-quiet">quiet (×5ms)</label>
<input id="tap-quiet" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.quite_time_after_gest"
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-wait">wait (0|1)</label>
<input id="tap-wait" type="number" class="form-control form-control-sm"
min="0" max="1" x-model.number="masterTapCfg.wait_for_timeout"
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-axis">axis (0=X…2=Z)</label>
<input id="tap-axis" type="number" class="form-control form-control-sm"
min="0" max="2" x-model.number="masterTapCfg.axis_sel"
:disabled="busy || !hostUp()">
</div>
</div>
<div class="d-flex flex-wrap gap-3 align-items-center mt-2 small">
<label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_single"
:disabled="busy || !hostUp()"> Single
</label>
<label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_double"
:disabled="busy || !hostUp()"> Double
</label>
<label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_triple"
:disabled="busy || !hostUp()"> Triple
</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterTapConfig()"
:disabled="busy || !hostUp()">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterTapConfig()"
:disabled="busy || !hostUp()">
Setzen
</button>
</div>
<p class="text-muted small mt-2 mb-0" x-show="!hostUp()">
Host 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 || !hostUp()">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setTapConfigAll()"
:disabled="busy || !hostUp()"
title="Master-Tap-Config an alle Slaves (ESP-NOW)">
Tap-Cfg alle
</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">
<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"
class="btn btn-sm"
:class="state.live_stream ? 'btn-warning' : 'btn-success'"
@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>
<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 || !hostUp()">
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>Tap-Cfg</th>
<th>Accel (LSB)</th>
<th>Akku</th>
<th>Accel</th>
<th>Tap-Notify</th>
<th>Tap</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<template x-if="!(state.clients || []).length">
<tr><td colspan="12" 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 class="small">
<span x-text="formatTapCfg(c.tap_config)" :title="tapCfgTitle(c.tap_config)"></span>
<button type="button" class="btn btn-outline-primary btn-sm ms-1 py-0 px-1"
@click="setTapConfig(c.id, masterTapCfg)"
:disabled="busy || !hostUp() || !c.available"
title="Master-Tap-Config an diesen Slave">
Set
</button>
</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 || !hostUp() || !c.available"
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">
<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 || !hostUp() || !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 || !hostUp() || !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 || !hostUp() || !c.available"> T
</label>
</div>
</td>
<td>
<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">
<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 || !hostUp() || !c.available">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="unicastTest(c.id)"
:disabled="busy || !hostUp() || !c.available"
title="ESP-NOW Unicast-Test">
Test
</button>
<button type="button" class="btn btn-outline-info btn-sm"
@click="echoPing(c.id)"
:disabled="busy || !hostUp() || !c.available"
title="ESP-NOW Timestamp-Echo (Round-Trip-Latenz)">
Ping
</button>
<button type="button" class="btn btn-outline-info btn-sm"
@click="ledRing({ clientId: c.id })"
:disabled="busy || !hostUp() || !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 || !hostUp() || !c.available"
title="Find me">
Find
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(c.id)"
:disabled="busy || !hostUp() || !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> (0–100&nbsp;%),
<code>digit</code> (0–10), <code>blink</code>, <code>find-me</code>, <code>battery-low</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>
<option value="battery-low">Battery low</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 0–10</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 || !hostUp()">
Master
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
@click="ledRing({ allClients: true, slavesOnly: true })"
:disabled="busy || !hostUp()">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="ledRing({ allClients: true })"
:disabled="busy || !hostUp()">
Alle + Master
</button>
</div>
</div>
</div>
</div>
</section>
<section class="col-12" x-show="isUARTHost() || !hostUp()">
<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 (nur UART-Host, DIP8=0), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling. Bei BLE-Host ist OTA nicht verfügbar.
</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 || !isUARTHost()">
</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 || !isUARTHost() || !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 || !hostUp()">
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 || !hostUp()">
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,
connTab: 'uart',
uartPort: '',
serialPorts: [],
bleDevices: [],
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,
masterTapCfg: {
tap_sens_thres: 5,
max_gest_dur: 100,
tap_shock_dur: 6,
quite_time_after_gest: 60,
wait_for_timeout: 0,
axis_sel: 2,
enable_single: true,
enable_double: true,
enable_triple: true
},
masterTapCfgSeeded: false,
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,
_flashTimer: null,
led: {
mode: 'color',
r: 0,
g: 120,
b: 255,
intensity: 0,
progress: 50,
digit: 0,
blinkMs: 350,
blinkCount: 1
},
hostUp() {
return !!(this.state?.host_connected || this.state?.uart_connected);
},
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';
if (t === 'ble') return 'BLE';
if (t === 'uart') return 'UART';
return 'on';
},
hostEndpointLabel() {
const ep = this.state?.host_endpoint || this.state?.serial_port || '';
const t = this.state?.host_transport || 'none';
return ep ? `${t}: ${ep}` : t;
},
async refreshSerialPorts() {
try {
const r = await fetch('/api/serial/ports');
const data = await r.json();
this.serialPorts = data.ports || [];
if (!this.uartPort && this.serialPorts.length) {
this.uartPort = this.serialPorts[0];
}
} catch (_) {
this.serialPorts = [];
}
},
async connectUART() {
if (!this.uartPort) return;
this.hostBusy = true;
this.bleScanErr = '';
try {
const r = await fetch('/api/host/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transport: 'uart', port: this.uartPort }),
});
const data = await r.json();
if (!r.ok || data.error) {
this.flash(data.error || 'UART connect failed', false);
} else {
this.flash('UART verbunden: ' + (data.endpoint || this.uartPort), true);
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
async scanBLE() {
this.bleScanning = true;
this.bleScanErr = '';
this.bleDevices = [];
this.hostBusy = true;
try {
const r = await fetch('/api/ble/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeout_s: 5 }),
});
const data = await r.json();
if (data.error) this.bleScanErr = data.error;
this.bleDevices = (data.devices || []).slice().sort((a, b) => (b.rssi || 0) - (a.rssi || 0));
} catch (e) {
this.bleScanErr = String(e);
} finally {
this.bleScanning = false;
this.hostBusy = false;
}
},
async connectBLE(d) {
if (!d?.address) return;
this.hostBusy = true;
this.bleScanErr = '';
try {
const r = await fetch('/api/host/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transport: 'ble',
address: d.address,
name: d.name || '',
}),
});
const data = await r.json();
if (!r.ok || data.error) {
this.flash(data.error || 'BLE connect failed', false);
} else {
this.flash('BLE verbunden: ' + (data.endpoint || d.address), true);
this.connTab = 'ble';
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
async disconnectHost() {
this.hostBusy = true;
try {
await fetch('/api/host/disconnect', { method: 'POST' });
this.flash('Host getrennt', true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws';
this.refreshSerialPorts();
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 = () => {
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();
};
this.ws.onclose = () => {
this.wsConnected = false;
setTimeout(connect, 2000);
};
this.ws.onmessage = (e) => {
try {
this._lastWsAt = Date.now();
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;
if (msg.host_transport === 'ble') this.connTab = 'ble';
else if (msg.host_transport === 'uart') this.connTab = 'uart';
this.preserveBatteryInState(prev, this.state);
this.syncTapDisplay(msg.clients || []);
if (!this.masterDzSeeded && msg.master?.deadzone != null) {
this.masterDz = msg.master.deadzone;
this.masterDzSeeded = true;
}
if (!this.masterTapCfgSeeded && msg.master?.tap_config) {
this.applyMasterTapCfg(msg.master.tap_config);
this.masterTapCfgSeeded = true;
}
for (const c of (msg.clients || [])) {
if (c.deadzone != null && this.slaveDz[c.id] == null) {
this.slaveDz[c.id] = c.deadzone;
}
}
} 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) {
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;
const master = { ...(this.state.master || {}) };
const clients = [...(this.state.clients || [])];
let masterChanged = false;
let clientsChanged = false;
for (const s of samples) {
if (s.client_id === 0) {
master.lipo1 = s.lipo1;
master.lipo2 = s.lipo2;
master.battery_age_ms = s.age_ms;
masterChanged = true;
continue;
}
const idx = clients.findIndex((x) => x.id === s.client_id);
if (idx >= 0) {
clients[idx] = {
...clients[idx],
lipo1: s.lipo1,
lipo2: s.lipo2,
battery_age_ms: s.age_ms,
};
clientsChanged = true;
}
}
if (!masterChanged && !clientsChanged) return;
this.state = {
...this.state,
...(masterChanged ? { master } : {}),
...(clientsChanged ? { clients } : {}),
};
},
async refreshBattery() {
if (!this.hostUp()) 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 (_) {}
},
formatLogLevel(level) {
const labels = ['None', 'Error', 'Warn', 'Info', 'Debug', 'Verbose'];
if (level == null || level < 0 || level > 5) return '—';
return `${labels[level]} (${level})`;
},
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 (!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 (!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 : ''}`;
},
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 (!this.state?.live_stream) 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 '—';
},
tapTitle(c) {
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) {
const age = (Date.now() - d.shownAt) + ' ms her';
return `Tap: ${d.kind} · ${age}`;
}
if (!c?.last_tap) return 'Warte auf Tap-Event…';
return 'Bereit — letzter Tap ausgeblendet';
},
tapCellClass(c) {
if (this.activeTapDisplay(c)) return 'tap-hit';
return 'text-muted';
},
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) {
const prevPhase = this.ota.phase;
const prevStep = this.ota.step;
if (p.phase) {
// Ignore out-of-order master upload updates after distribution started.
if (!(p.phase === 'uploading' && prevPhase === 'distributing')) {
this.ota.phase = p.phase;
}
}
if (p.step) {
if (!(p.step === 'master' && (prevStep === 'slaves' || prevPhase === 'distributing'))) {
this.ota.step = p.step;
}
} else if (!this.ota.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, durationMs = 5000) {
this.configMsg = msg;
this.configMsgOk = ok;
if (this._flashTimer) clearTimeout(this._flashTimer);
this._flashTimer = setTimeout(() => {
this.configMsg = '';
this._flashTimer = null;
}, durationMs);
},
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;
}
if (clientId === 0) {
this.masterDz = data.deadzone;
this.masterDzSeeded = true;
}
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.masterDzSeeded = true;
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);
},
formatTapCfg(tc) {
if (!tc) return '—';
return `s${tc.tap_sens_thres}`;
},
tapCfgTitle(tc) {
if (!tc) return '';
const kinds = [tc.enable_single && 'S', tc.enable_double && 'D', tc.enable_triple && 'T'].filter(Boolean).join('/');
return `sens=${tc.tap_sens_thres} max_gest=${tc.max_gest_dur} shock=${tc.tap_shock_dur} quiet=${tc.quite_time_after_gest} wait=${tc.wait_for_timeout} axis=${tc.axis_sel} (${kinds || 'aus'})`;
},
applyMasterTapCfg(data) {
if (!data) return;
this.masterTapCfg = {
tap_sens_thres: data.tap_sens_thres,
max_gest_dur: data.max_gest_dur,
tap_shock_dur: data.tap_shock_dur,
quite_time_after_gest: data.quite_time_after_gest,
wait_for_timeout: data.wait_for_timeout,
axis_sel: data.axis_sel,
enable_single: data.enable_single,
enable_double: data.enable_double,
enable_triple: data.enable_triple
};
this.masterTapCfgSeeded = true;
},
async setTapConfig(clientId, cfg, opts = {}) {
if (!cfg) {
this.flash('Keine Tap-Config', false);
return;
}
this.busy = true;
try {
const r = await fetch('/api/tap-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
write: true,
client_id: clientId,
all_clients: !!opts.allClients,
slaves_only: !!opts.slavesOnly,
...cfg
})
});
const data = await r.json();
if (!r.ok || !data.success) {
const err = data.error ||
(opts.slavesOnly ? 'Tap-Config für Slaves fehlgeschlagen' : `Tap-Config für Client ${clientId} fehlgeschlagen`);
this.flash(err, false);
return;
}
if (opts.slavesOnly) {
this.flash(`Slaves: Tap-Config gesetzt (${data.slaves_updated} per ESP-NOW, Master unverändert)`, true);
return;
}
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`${who}: Tap-Config gesetzt (sens=${data.tap_sens_thres})`, true);
if (clientId === 0) {
this.applyMasterTapCfg(data);
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async readMasterTapConfig() {
this.busy = true;
try {
const r = await fetch('/api/tap-config?client_id=0');
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Master-Tap-Config lesen fehlgeschlagen', false);
return;
}
this.applyMasterTapCfg(data);
this.flash(`Master: Tap-Config gelesen (sens=${data.tap_sens_thres})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setMasterTapConfig() {
await this.setTapConfig(0, this.masterTapCfg);
},
async setTapConfigAll() {
await this.setTapConfig(0, this.masterTapCfg, { allClients: true, slavesOnly: true });
},
async readMasterLogLevel() {
this.busy = true;
try {
const r = await fetch('/api/log-level');
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Log-Level lesen fehlgeschlagen', false);
return;
}
this.masterLogLevel = data.level;
this.flash(`Master: Log-Level ${this.formatLogLevel(data.level)}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setMasterLogLevel() {
this.busy = true;
try {
const r = await fetch('/api/log-level', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ write: true, level: this.masterLogLevel })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'Log-Level setzen fehlgeschlagen', false);
return;
}
this.masterLogLevel = data.level;
this.flash(`Master: Log-Level ${this.formatLogLevel(data.level)} gesetzt`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
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;
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 })
});
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;
}
},
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;
}
},
formatMs(v) {
return v != null && Number.isFinite(Number(v)) ? Number(v).toFixed(3) : '—';
},
formatUsAsMs(us) {
return us != null && Number.isFinite(Number(us))
? (Number(us) / 1000).toFixed(3)
: '—';
},
async echoPing(clientId) {
this.busy = true;
try {
const r = await fetch('/api/echo-ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId })
});
const data = await r.json();
if (!r.ok) {
this.flash(data.error || `Echo-Ping Slave ${clientId} fehlgeschlagen`, false);
return;
}
if (!data.success) {
this.flash(`Echo-Ping Slave ${clientId} fehlgeschlagen (${this.formatMs(data.rtt_ms)} ms)`, false);
return;
}
this.flash(
`Echo-Ping Slave ${clientId}: Host ${this.formatMs(data.rtt_ms)} ms, ` +
`ESP ${this.formatUsAsMs(data.esp_rtt_us)} ms`,
true,
5000
);
} 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>