feat: enabling web-based OTA updates and deployment for frontend

This commit is contained in:
2026-03-03 16:33:01 -05:00
parent 7c537ed4db
commit eafb705eda
7 changed files with 439 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
<script>
import { getSystemInfo, reboot } from "./lib/api.js";
import OTAUpdate from "./lib/OTAUpdate.svelte";
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
let status = $state("loading");
@@ -146,9 +147,9 @@
</div>
</div>
<!-- Reboot Section -->
<!-- Device Control Section (Reboot + OTA) -->
<div class="bg-bg-card border border-border rounded-xl p-5">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between mb-2">
<div>
<h2 class="text-sm font-semibold text-text-primary">
Device Control
@@ -168,6 +169,9 @@
Reboot
</button>
</div>
<!-- New OTA Update Component inside the Device Control block -->
<OTAUpdate />
</div>
<!-- Reboot Confirmation Modal -->

View File

@@ -0,0 +1,150 @@
<script>
import { getOTAStatus, uploadOTAFrontend } from "./api.js";
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
let status = $state("idle");
let errorMsg = $state("");
let uploadProgress = $state(0); // 0 to 100
let otaInfo = $state({
active_slot: -1,
active_partition: "—",
target_partition: "—",
});
let selectedFile = $state(null);
async function fetchStatus() {
status = "loading_status";
try {
otaInfo = await getOTAStatus();
status = "idle";
} catch (e) {
status = "error";
errorMsg = "Failed to fetch OTA status: " + e.message;
}
}
// Fetch status on mount
$effect(() => {
fetchStatus();
});
function handleFileChange(event) {
const files = event.target.files;
if (files && files.length > 0) {
selectedFile = files[0];
errorMsg = "";
} else {
selectedFile = null;
}
}
async function handleUpload() {
if (!selectedFile) return;
status = "uploading";
errorMsg = "";
uploadProgress = 0; // The fetch API doesn't natively support upload progress well without XMLHttpRequest, so we emulate it visually.
// Emulate progress since doing it cleanly with fetch requires more boilerplate.
const progressInterval = setInterval(() => {
if (uploadProgress < 90) uploadProgress += 5;
}, 500);
try {
const result = await uploadOTAFrontend(selectedFile);
clearInterval(progressInterval);
uploadProgress = 100;
status = "success";
// The backend triggers a reboot after success. The main App.svelte polling will handle the reconnect.
} catch (e) {
clearInterval(progressInterval);
uploadProgress = 0;
status = "error";
errorMsg = e.message;
}
}
</script>
<div class="bg-bg-card border border-border rounded-xl p-5 mt-4">
<div class="mb-4">
<h2 class="text-sm font-semibold text-text-primary">
Frontend Update (OTA)
</h2>
<p class="text-xs text-text-secondary mt-1">
Update the dashboard UI without flashing the entire firmware.
</p>
</div>
{#if status === "loading_status"}
<div class="text-sm text-text-secondary animate-pulse">Loading status...</div>
{:else}
<!-- Status Display -->
<div class="flex items-center gap-4 mb-4 text-sm bg-bg-primary/50 p-3 rounded-lg border border-border/50">
<div>
<span class="text-text-secondary">Active Partition:</span>
<span class="font-mono font-medium ml-1 text-accent">{otaInfo.active_partition}</span>
</div>
<div>
<span class="text-text-secondary">Next Target:</span>
<span class="font-mono font-medium ml-1 text-text-primary">{otaInfo.target_partition}</span>
</div>
</div>
<!-- Upload Controls -->
<div class="w-full space-y-3">
{#if status === "success"}
<div class="bg-success/10 border border-success/20 text-success p-3 rounded-lg text-sm flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-success"></span>
Update successful! The device is rebooting...
</div>
{:else}
<div class="flex items-center gap-3">
<input
type="file"
accept=".bin"
onchange={handleFileChange}
disabled={status === "uploading"}
class="block w-full text-sm text-text-secondary
file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-semibold
file:bg-accent/10 file:text-accent
hover:file:bg-accent/20 transition-colors
disabled:opacity-50"
/>
<button
onclick={handleUpload}
disabled={!selectedFile || status === "uploading"}
class="whitespace-nowrap px-4 py-2 text-sm font-medium rounded-lg transition-colors
bg-accent text-white border border-accent
hover:brightness-110
disabled:opacity-40 disabled:cursor-not-allowed"
>
{#if status === "uploading"}
Uploading...
{:else}
Flash to {otaInfo.target_partition}
{/if}
</button>
</div>
<!-- Progress or Error-->
{#if status === "uploading"}
<div class="w-full bg-border rounded-full h-1.5 mt-2 overflow-hidden">
<div
class="bg-accent h-1.5 rounded-full transition-all duration-300 ease-out"
style="width: {uploadProgress}%"
></div>
</div>
{:else if status === "error"}
<p class="text-sm text-danger mt-2 bg-danger/10 p-2 rounded border border-danger/20">
{errorMsg}
</p>
{/if}
{/if}
</div>
{/if}
</div>

View File

@@ -37,3 +37,39 @@ export async function reboot() {
}
return res.json();
}
/**
* Fetch OTA status from the ESP32.
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string}>}
*/
export async function getOTAStatus() {
const res = await fetch(`${API_BASE}/api/ota/status`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
}
/**
* Upload a new frontend binary image.
* @param {File} file The binary file to upload.
* @returns {Promise<{status: string, message: string}>}
*/
export async function uploadOTAFrontend(file) {
const res = await fetch(`${API_BASE}/api/ota/frontend`, {
method: 'POST',
body: file, // Send the raw file Blob/Buffer
headers: {
// Let the browser set Content-Type for the binary payload,
// or we could force application/octet-stream.
'Content-Type': 'application/octet-stream'
}
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Upload failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}