frontend-ota (#1)
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
203
Provider/frontend/src/lib/OTAUpdate.svelte
Normal file
203
Provider/frontend/src/lib/OTAUpdate.svelte
Normal file
@@ -0,0 +1,203 @@
|
||||
<script>
|
||||
let { onReboot = null } = $props();
|
||||
import { getOTAStatus, uploadOTAFrontend } from "./api.js";
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
/** @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);
|
||||
let showAdvanced = $state(false);
|
||||
let isDragging = $state(false);
|
||||
|
||||
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) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function processFile(file) {
|
||||
if (file.name.endsWith('.bin')) {
|
||||
selectedFile = file;
|
||||
errorMsg = "";
|
||||
} else {
|
||||
selectedFile = null;
|
||||
errorMsg = "Please select a valid .bin file";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile) return;
|
||||
|
||||
status = "uploading";
|
||||
errorMsg = "";
|
||||
uploadProgress = 0;
|
||||
|
||||
const progressInterval = setInterval(() => {
|
||||
if (uploadProgress < 90) uploadProgress += 5;
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
await uploadOTAFrontend(selectedFile);
|
||||
clearInterval(progressInterval);
|
||||
uploadProgress = 100;
|
||||
status = "success";
|
||||
if (onReboot) onReboot();
|
||||
} catch (e) {
|
||||
clearInterval(progressInterval);
|
||||
uploadProgress = 0;
|
||||
status = "error";
|
||||
errorMsg = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !IS_DEV}
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden mt-4">
|
||||
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Frontend Info
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => showAdvanced = !showAdvanced}
|
||||
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded bg-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{showAdvanced ? 'Hide Tools' : 'OTA Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Version & Slot Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50">
|
||||
<div class="text-[10px] uppercase text-text-secondary font-bold mb-1">Version</div>
|
||||
<div class="text-sm font-mono text-accent">v{__APP_VERSION__}</div>
|
||||
</div>
|
||||
<div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50">
|
||||
<div class="text-[10px] uppercase text-text-secondary font-bold mb-1">Active Slot</div>
|
||||
<div class="text-xs font-mono text-text-primary">
|
||||
{otaInfo.active_partition}
|
||||
{#if otaInfo.partitions}
|
||||
<span class="text-text-secondary ml-1">
|
||||
({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024 / 1024).toFixed(2)} MB free)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="pt-2 border-t border-border/50 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-bold text-text-primary">OTA Upgrade</h3>
|
||||
<div class="text-[10px] text-text-secondary">
|
||||
Target: <span class="font-mono">{otaInfo.target_partition}</span>
|
||||
{#if otaInfo.partitions}
|
||||
<span class="ml-1">
|
||||
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(2)} MB capacity)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status === "success"}
|
||||
<div class="bg-success/10 border border-success/20 text-success p-3 rounded-lg text-xs flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>
|
||||
Update successful! The device is rebooting...
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Drag and Drop Zone -->
|
||||
<div
|
||||
role="button"
|
||||
aria-label="Upload partition image"
|
||||
tabindex="0"
|
||||
class="relative border-2 border-dashed rounded-xl p-6 transition-all duration-200 flex flex-col items-center justify-center gap-2
|
||||
{isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-border-hover bg-bg-primary/20'}
|
||||
{status === 'uploading' ? 'opacity-50 pointer-events-none' : ''}"
|
||||
ondragover={(e) => { e.preventDefault(); isDragging = true; }}
|
||||
ondragleave={() => isDragging = false}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".bin"
|
||||
onchange={handleFileChange}
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
|
||||
<div class="text-2xl">📦</div>
|
||||
{#if selectedFile}
|
||||
<div class="text-xs font-medium text-text-primary">{selectedFile.name}</div>
|
||||
<div class="text-[10px] text-text-secondary">{(selectedFile.size / 1024).toFixed(1)} KB</div>
|
||||
{:else}
|
||||
<div class="text-xs text-text-primary">Drag & Drop .bin here</div>
|
||||
<div class="text-[10px] text-text-secondary">or click to browse</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedFile}
|
||||
<button
|
||||
onclick={handleUpload}
|
||||
disabled={status === "uploading"}
|
||||
class="w-full py-2 text-xs font-bold rounded-lg transition-colors
|
||||
bg-accent text-white hover:brightness-110
|
||||
disabled:opacity-40"
|
||||
>
|
||||
{status === "uploading" ? 'Processing Update...' : `Flash to ${otaInfo.target_partition}`}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if status === "uploading"}
|
||||
<div class="w-full bg-border rounded-full h-1 mt-1 overflow-hidden">
|
||||
<div
|
||||
class="bg-accent h-1 rounded-full transition-all duration-300"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else if status === "error"}
|
||||
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">
|
||||
{errorMsg}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user