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>
This commit is contained in:
+231
-26
@@ -131,6 +131,19 @@
|
||||
.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;
|
||||
@@ -180,7 +193,7 @@
|
||||
<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">Git</dt>
|
||||
<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>
|
||||
@@ -303,7 +316,7 @@
|
||||
<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 die inaktive OTA-Partition (wie <code>gotool ota</code>).
|
||||
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">
|
||||
@@ -320,17 +333,76 @@
|
||||
<span class="text-muted small" x-show="otaFile"
|
||||
x-text="otaFile ? otaFile.name + ' (' + formatSize(otaFile.size) + ')' : ''"></span>
|
||||
</div>
|
||||
<template x-if="ota.active || ota.phase === 'done' || ota.phase === 'error'">
|
||||
<div class="progress mb-2" style="height: 1.25rem;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
:style="'width: ' + ota.percent + '%'"
|
||||
:class="ota.phase === 'error' ? 'bg-danger' : (ota.phase === 'done' ? 'bg-success' : '')"
|
||||
x-text="ota.percent + '%'"></div>
|
||||
</div>
|
||||
<p class="small mb-0"
|
||||
:class="ota.phase === 'error' ? 'text-danger' : (ota.phase === 'done' ? 'text-success' : 'text-muted')"
|
||||
<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>
|
||||
</template>
|
||||
<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>
|
||||
@@ -347,7 +419,13 @@
|
||||
allDz: 100,
|
||||
slaveDz: {},
|
||||
otaFile: null,
|
||||
ota: { active: false, phase: '', percent: 0, message: '' },
|
||||
ota: {
|
||||
active: false, phase: '', step: '', percent: 0,
|
||||
masterPercent: 0, masterDone: false, masterMessage: '',
|
||||
message: '', slaves: 0, imageSize: 0,
|
||||
slaveProgress: {},
|
||||
slaveDetails: {}
|
||||
},
|
||||
busy: false,
|
||||
configMsg: '',
|
||||
configMsgOk: false,
|
||||
@@ -392,20 +470,148 @@
|
||||
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.percent = p.percent ?? 0;
|
||||
this.ota.step = p.step || this.ota.step || '';
|
||||
this.ota.percent = p.percent ?? this.ota.percent;
|
||||
this.ota.message = p.message || '';
|
||||
if (p.phase === 'preparing' || p.phase === 'ready' || p.phase === 'uploading') {
|
||||
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', percent: 0, message: 'Upload startet…' };
|
||||
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);
|
||||
@@ -414,23 +620,22 @@
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.success) {
|
||||
this.applyOTAProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
phase: 'error', step: '', percent: 0,
|
||||
message: data.error || 'OTA fehlgeschlagen'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const slot = data.target_slot != null ? 'ota_' + data.target_slot : '?';
|
||||
this.applyOTAProgress({
|
||||
phase: 'done',
|
||||
percent: 100,
|
||||
message: `OK — ${data.bytes_written} Bytes nach ${slot} (Neustart zum Booten)`
|
||||
});
|
||||
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', percent: 0, message: String(e) });
|
||||
this.applyOTAProgress({ phase: 'error', step: '', percent: 0, message: String(e) });
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.ota.active = false;
|
||||
}
|
||||
},
|
||||
flash(msg, ok) {
|
||||
|
||||
Reference in New Issue
Block a user