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:
+111
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user