powerpods/goTool/webui/index.html
simon a0f4a81a55 Add per-slave ESP-NOW OTA progress over UART and fix dashboard updates.
Expose OTA_SLAVE_PROGRESS on the master, track per-slave state during
distribution, run ESP-NOW OTA in a background task so the host can poll
while slaves update, and show master/slave progress in the dashboard
with table layout and faster WebSocket refresh during uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 21:07:46 +02:00

735 lines
29 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>
</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>
</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">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">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;
}
}
};
}
</script>
</body>
</html>