UART RESTART (client_id 0 = local reboot, >0 = unicast); dashboard and CLI hooks; delayed esp_restart after response. Co-authored-by: Cursor <cursoragent@cursor.com>
809 lines
33 KiB
HTML
809 lines
33 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 {
|
||
font-family: ui-monospace, monospace;
|
||
font-size: 0.85rem;
|
||
color: var(--pp-accent);
|
||
}
|
||
|
||
.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">Slaves per ESP-NOW — Master-Deadzone bleibt separat.</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>Aktion</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template x-if="!(state.clients || []).length">
|
||
<tr><td colspan="6" 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>
|
||
<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-warning btn-sm"
|
||
@click="findMe(c.id)"
|
||
:disabled="busy || !state.uart_connected || !c.available"
|
||
title="LED-Ring Find me (ESP-NOW)">
|
||
Find me
|
||
</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">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,
|
||
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(':');
|
||
},
|
||
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);
|
||
},
|
||
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 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>
|