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>
1336 lines
56 KiB
HTML
1336 lines
56 KiB
HTML
<!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 ms ESP-NOW). Tap-Notify (S/D/T)
|
||
konfiguriert den Slave; „Empfang an“ startet das Abfragen von Tap-Events (~16 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> (0–100 %),
|
||
<code>digit</code> (0–10), <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 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 || !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>
|