Add web dashboard OTA upload with live progress.

Share UART OTA logic between CLI and serve via POST /api/ota, WebSocket progress events, and a dashboard upload UI showing the running partition.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-19 00:45:54 +02:00
co-authored by Cursor
parent 59ca269407
commit 4bf43d8a5e
6 changed files with 463 additions and 198 deletions
+111 -5
View File
@@ -124,6 +124,20 @@
margin-top: 1rem;
padding-top: 1rem;
}
.progress {
background: var(--pp-surface-raised);
border: 1px solid var(--pp-border);
}
.progress-bar {
background: #2d6cdf;
}
.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()">
@@ -168,6 +182,8 @@
<dd class="col-7" x-text="state.master.version"></dd>
<dt class="col-5 text-muted">Git</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>
@@ -281,6 +297,43 @@
</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 die inaktive OTA-Partition (wie <code>gotool ota</code>).
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>
<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')"
x-text="ota.message"></p>
</template>
</div>
</div>
</section>
</div>
</main>
@@ -293,6 +346,8 @@
masterDz: 100,
allDz: 100,
slaveDz: {},
otaFile: null,
ota: { active: false, phase: '', percent: 0, message: '' },
busy: false,
configMsg: '',
configMsgOk: false,
@@ -308,12 +363,16 @@
};
this.ws.onmessage = (e) => {
try {
const st = JSON.parse(e.data);
this.state = st;
if (st.master?.deadzone != null) {
this.masterDz = st.master.deadzone;
const msg = JSON.parse(e.data);
if (msg.type === 'ota_progress') {
this.applyOTAProgress(msg);
return;
}
for (const c of (st.clients || [])) {
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;
}
@@ -327,6 +386,53 @@
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';
},
applyOTAProgress(p) {
this.ota.phase = p.phase || '';
this.ota.percent = p.percent ?? 0;
this.ota.message = p.message || '';
if (p.phase === 'preparing' || p.phase === 'ready' || p.phase === 'uploading') {
this.ota.active = true;
}
if (p.phase === 'done' || p.phase === 'error') {
this.ota.active = false;
}
},
async uploadOTA() {
if (!this.otaFile) return;
this.ota = { active: true, phase: 'preparing', percent: 0, message: 'Upload startet…' };
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',
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)`
});
} catch (e) {
this.applyOTAProgress({ phase: 'error', percent: 0, message: String(e) });
} finally {
this.busy = false;
this.ota.active = false;
}
},
flash(msg, ok) {
this.configMsg = msg;
this.configMsgOk = ok;