powerpods/goTool/webui/index.html
simon eb67a46158 Add LED ring control per client and broadcast over REST and WebSocket.
Solid color mode fills all ring LEDs; master routes UART commands to slaves
via ESPNOW_LED_RING. goTool exposes POST /api/led-ring, WebSocket set_led_ring,
and a dashboard LED panel with master/slave/all targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 19:24:55 +02:00

1018 lines
42 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); }
.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>
</dl>
</template>
<template x-if="state.master && !state.master.ok">
<div class="alert alert-warning py-2 mb-0" x-text="state.master.error"></div>
</template>
<div class="config-block">
<h6 class="text-secondary mb-2">Konfiguration Master</h6>
<label class="form-label text-muted small mb-1" for="master-dz-input">
Accel Deadzone (LSB)
</label>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input id="master-dz-input" type="number"
class="form-control form-control-sm config-input"
min="0" max="4095" step="1"
placeholder="z. B. 100"
x-model.number="masterDz"
:disabled="busy || !state.uart_connected">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()"
:disabled="busy || !state.uart_connected">
Setzen
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="findMe()"
:disabled="busy || !state.uart_connected"
title="LED-Ring: Rot/Grün/Blau je 3×">
Find me
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)"
:disabled="busy || !state.uart_connected"
title="Master neu starten">
Restart
</button>
</div>
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected">
UART nicht verbunden — Eingabe gesperrt.
</p>
</div>
</div>
</div>
</section>
<section class="col-lg-8">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>ESP-NOW Slaves</span>
<div class="d-flex align-items-center gap-2">
<input type="number" class="form-control form-control-sm dz-input"
min="0" max="4095" placeholder="LSB"
x-model.number="allDz"
:disabled="busy">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setDeadzoneAll(allDz)"
:disabled="busy || !state.uart_connected">
Alle Slaves
</button>
<span class="badge bg-secondary" x-text="(state.clients || []).length + ' registered'"></span>
</div>
</div>
<p class="text-muted small px-3 pt-2 mb-0">
Accel-Stream pro Slave per „Stream an“ aktivieren (~16&nbsp;ms ESP-NOW). Ohne Aktivierung keine Werte.
</p>
<div class="card-body p-0 pt-2">
<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>Stream</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<template x-if="!(state.clients || []).length">
<tr><td colspan="8" 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>
<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">
<input type="number" class="form-control form-control-sm dz-input"
min="0" max="4095"
:placeholder="String(c.deadzone || 100)"
x-model.number="slaveDz[c.id]"
:disabled="busy">
<button type="button" class="btn btn-primary btn-sm"
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
:disabled="busy || !state.uart_connected || !c.available">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="unicastTest(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
title="ESP-NOW Unicast-Test">
Test
</button>
<button type="button" class="btn btn-outline-info btn-sm"
@click="ledRing({ clientId: c.id })"
:disabled="busy || !state.uart_connected || !c.available"
title="LED-Ring (aktueller Modus)">
LED
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="ledRing({ clientId: c.id, mode: 'find-me' })"
:disabled="busy || !state.uart_connected || !c.available"
title="Find me">
Find
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
title="Neustart per ESP-NOW">
Restart
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">LED-Ring</div>
<div class="card-body">
<p class="text-muted small mb-3">
Modi: <code>clear</code>, <code>color</code> (ganzer Ring), <code>progress</code> (0100&nbsp;%),
<code>digit</code> (010), <code>blink</code>, <code>find-me</code>.
Ziel: Master (<code>client_id=0</code>), ein Slave oder alle Slaves (Broadcast).
</p>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label class="form-label small text-muted">Modus</label>
<select class="form-select form-select-sm" x-model="led.mode" :disabled="busy">
<option value="color">Farbe (alle LEDs)</option>
<option value="clear">Aus (clear)</option>
<option value="progress">Progress</option>
<option value="digit">Ziffer/Symbol</option>
<option value="blink">Blink</option>
<option value="find-me">Find me</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">RGB / Intensität</label>
<div class="d-flex flex-wrap gap-2">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="R" x-model.number="led.r" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="G" x-model.number="led.g" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:4rem" min="0" max="255"
placeholder="B" x-model.number="led.b" :disabled="busy">
<input type="number" class="form-control form-control-sm" style="width:5rem" min="0" max="255"
title="0 = Geräte-Default (~5 %)"
placeholder="Int." x-model.number="led.intensity" :disabled="busy">
</div>
</div>
<div class="col-md-2" x-show="led.mode === 'progress'">
<label class="form-label small text-muted">Progress %</label>
<input type="number" class="form-control form-control-sm" min="0" max="100"
x-model.number="led.progress" :disabled="busy">
</div>
<div class="col-md-2" x-show="led.mode === 'digit'">
<label class="form-label small text-muted">Ziffer 010</label>
<input type="number" class="form-control form-control-sm" min="0" max="10"
x-model.number="led.digit" :disabled="busy">
</div>
<div class="col-md-2" x-show="led.mode === 'blink'">
<label class="form-label small text-muted">Blink ms × Anzahl</label>
<div class="d-flex gap-1">
<input type="number" class="form-control form-control-sm" min="1"
x-model.number="led.blinkMs" :disabled="busy">
<input type="number" class="form-control form-control-sm" min="1"
x-model.number="led.blinkCount" :disabled="busy">
</div>
</div>
<div class="col-md-4 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm"
@click="ledRing({ clientId: 0 })"
:disabled="busy || !state.uart_connected">
Master
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
@click="ledRing({ allClients: true, slavesOnly: true })"
:disabled="busy || !state.uart_connected">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="ledRing({ allClients: true })"
:disabled="busy || !state.uart_connected">
Alle + Master
</button>
</div>
</div>
</div>
</div>
</section>
<section class="col-12">
<div class="card">
<div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body">
<p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf den Master (UART), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling.
</p>
<div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !state.uart_connected">
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()"
:disabled="ota.active || busy || !state.uart_connected || !otaFile">
Firmware hochladen
</button>
<span class="text-muted small" x-show="otaFile"
x-text="otaFile ? otaFile.name + ' (' + formatSize(otaFile.size) + ')' : ''"></span>
</div>
<div class="ota-progress-panel mt-3"
x-show="ota.active || ota.phase === 'distributing' || ota.phase === 'done' || ota.phase === 'error'">
<p class="small text-muted mb-2">Master (UART)</p>
<table class="table table-sm pp-table ota-progress-table mb-3">
<tbody>
<tr>
<td class="ota-progress-col">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Master</span>
<span x-text="otaMasterPct() + '%'"></span>
</div>
<div class="progress mb-1" style="height: 1.1rem;">
<div class="progress-bar" role="progressbar"
:style="'width: ' + otaMasterPct() + '%'"
:class="otaMasterBarClass()"
x-text="otaMasterPct() + '%'"></div>
</div>
<p class="small text-muted mb-0"
x-text="ota.masterMessage || (ota.step === 'master' ? ota.message : '')"></p>
</td>
<td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)"
:disabled="busy || !state.uart_connected">
Restart
</button>
</td>
</tr>
</tbody>
</table>
<p class="small text-muted mb-2">
Slaves (ESP-NOW)
<span x-text="'(' + otaSlaveRows().length + ')'"></span>
</p>
<p class="small text-muted mb-2" x-show="ota.message && ota.step === 'slaves'"
x-text="ota.message"></p>
<table class="table table-sm pp-table ota-progress-table mb-2">
<tbody>
<template x-for="row in otaSlaveRows()" :key="'ota-slave-' + row.id">
<tr>
<td class="ota-progress-col">
<div class="d-flex justify-content-between small mb-1">
<span>
Slave <span x-text="row.id"></span>
<span class="text-muted mac ms-1" x-text="row.mac"></span>
<span class="badge bg-secondary ms-1" x-text="row.statusLabel"></span>
</span>
<span x-text="row.percent + '%'"></span>
</div>
<div class="progress mb-1" style="height: 0.85rem;">
<div class="progress-bar" role="progressbar"
:class="row.barClass"
:style="'width: ' + row.percent + '%'"></div>
</div>
<p class="small text-muted mb-0" x-text="row.bytesLabel"></p>
</td>
<td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(row.id)"
:disabled="busy || !state.uart_connected">
Restart
</button>
</td>
</tr>
</template>
<tr x-show="otaSlaveRows().length === 0">
<td colspan="2" class="text-muted small py-3">
Warte auf Slave-Fortschritt…
</td>
</tr>
</tbody>
</table>
<p class="small mb-0" x-show="ota.phase === 'done' || ota.phase === 'error'"
:class="ota.phase === 'error' ? 'text-danger' : 'text-success'"
x-text="ota.message"></p>
</div>
</div>
</div>
</section>
</div>
</main>
<script>
function dashboard() {
return {
state: { master: {}, clients: [] },
ws: null,
wsConnected: false,
masterDz: 100,
allDz: 100,
slaveDz: {},
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';
const connect = () => {
this.ws = new WebSocket(url);
this.ws.onopen = () => { this.wsConnected = true; };
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;
}
this.state = msg;
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();
},
formatMac(hex) {
if (!hex || hex.length !== 12) return hex || '';
return hex.match(/.{2}/g).join(':');
},
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 '';
},
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;
}
},
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>