firmware-ota (#2)

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-03-03 22:47:03 -05:00
parent 9bce74e027
commit 0ec7f7f08a
21 changed files with 1065 additions and 255 deletions

View File

@@ -0,0 +1,5 @@
---
trigger: always_on
---
The way you must work with the human user is simple. When you finish a task, tell him what you did, what you think you should do next and ask for review and confirmation. Never go rogue.

View File

@@ -0,0 +1,59 @@
# Universal OTA Bundle
The Universal OTA Bundle allows you to update both the **Firmware** and the **Frontend** of the Calendink Provider in a single operation. This ensures that your UI and backend logic are always in sync.
## 1. How it Works
The bundle is a custom `.bundle` file that contains:
1. A **12-byte header** (Magic `BNDL`, FW size, UI size).
2. The **Firmware binary** (`Provider.bin`).
3. The **Frontend LittleFS binary** (`www_v*.bin`).
The ESP32 backend streams this file, writing the firmware to the next OTA slot and the frontend to the inactive `www` partition. It only commits the update if both parts are written successfully.
## 2. Prerequisites
- You have a working [Frontend Build Environment](build_frontend.md).
- You have the [ESP-IDF SDK](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/index.html) installed for firmware compilation.
## 3. Creating a Bundle
To create a new bundle, follow these steps in order:
### Step A: Build the Frontend
Inside the `frontend/` directory:
```bash
npm run build:esp32
```
### Step B: Build the Firmware
From the project **root** directory:
```bash
idf.py build
```
### Step C: Generate the Bundle
Inside the `frontend/` directory:
```bash
npm run ota:bundle
```
> [!NOTE]
> `npm run ota:bundle` now automatically runs `npm run ota:package` first to ensure the latest Svelte build is turned into a LittleFS image before bundling.
The output will be saved in `frontend/bundles/` with a name like `universal_v0.1.11.bundle`.
## 4. Flashing the Bundle
1. Open the Calendink Provider Dashboard in your browser.
2. Navigate to the **System Updates** section.
3. Click the **Universal Bundle** button.
4. Drag and drop your `.bundle` file into the upload area.
5. Click **Update**.
The device will reboot once the upload is complete. You can verify the update by checking the version numbers and the UI changes (like the number of rockets in the header!).
## 5. Troubleshooting
- **"Invalid bundle magic"**: Ensure you are uploading a `.bundle` file, not a `.bin`.
- **"Firmware part is corrupted"**: The bundle was likely created while the firmware build was incomplete or failed.
- **Old UI appearing**: Ensure you ran `npm run build:esp32` *before* `npm run ota:bundle`.

View File

@@ -80,3 +80,9 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
- Go to the **Frontend Update** section.
- Select the `www.bin` file and click **Flash**.
- The device will automatically write to the inactive partition and reboot.
## 6. Universal OTA Bundle
For a safer and more convenient update experience, you can bundle both the Firmware and Frontend into a single file.
See the [Universal OTA Bundle Guide](build_bundle.md) for details.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -8,6 +8,7 @@
"build": "vite build",
"build:esp32": "vite build && node scripts/gzip.js",
"ota:package": "node scripts/package.js",
"ota:bundle": "npm run ota:package && node scripts/bundle.js",
"preview": "vite preview"
},
"devDependencies": {

View File

@@ -0,0 +1,79 @@
/**
* Universal OTA Bundle Creator
* Packs FW (Provider.bin) and WWW (www_v*.bin) into a single .bundle file.
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '..');
const providerRoot = resolve(projectRoot, '..');
const binDir = resolve(projectRoot, 'bin');
// Paths
const fwFile = resolve(providerRoot, 'build', 'Provider.bin');
const bundleDir = resolve(projectRoot, 'bundles');
if (!existsSync(bundleDir)) {
mkdirSync(bundleDir, { recursive: true });
}
console.log('--- Universal Bundle Packaging ---');
// 1. Find the latest www.bin with proper semantic version sorting
const binFiles = readdirSync(binDir)
.filter(f => f.startsWith('www_v') && f.endsWith('.bin'))
.sort((a, b) => {
const getParts = (s) => {
const m = s.match(/v(\d+)\.(\d+)\.(\d+)/);
return m ? m.slice(1).map(Number) : [0, 0, 0];
};
const [aMajor, aMinor, aRev] = getParts(a);
const [bMajor, bMinor, bRev] = getParts(b);
return (bMajor - aMajor) || (bMinor - aMinor) || (bRev - aRev);
});
if (binFiles.length === 0) {
console.error('Error: No www_v*.bin found in frontend/bin/. Run "npm run ota:package" first.');
process.exit(1);
}
const wwwFile = resolve(binDir, binFiles[0]);
if (!existsSync(fwFile)) {
console.error(`Error: Firmware binary not found at ${fwFile}. Run "idf.py build" first.`);
process.exit(1);
}
try {
console.log(`Packing Firmware: ${fwFile}`);
console.log(`Packing Frontend: ${wwwFile}`);
const fwBuf = readFileSync(fwFile);
const wwwBuf = readFileSync(wwwFile);
// Create 12-byte header
// Magic: BNDL (4 bytes)
// FW Size: uint32 (4 bytes)
// WWW Size: uint32 (4 bytes)
const header = Buffer.alloc(12);
header.write('BNDL', 0);
header.writeUInt32LE(fwBuf.length, 4);
header.writeUInt32LE(wwwBuf.length, 8);
const bundleBuf = Buffer.concat([header, fwBuf, wwwBuf]);
const outputFile = resolve(bundleDir, `universal_v${binFiles[0].replace('www_v', '').replace('.bin', '')}.bundle`);
writeFileSync(outputFile, bundleBuf);
console.log('-------------------------------');
console.log(`Success: Bundle created at ${outputFile}`);
console.log(`Total size: ${(bundleBuf.length / 1024 / 1024).toFixed(2)} MB`);
console.log('-------------------------------');
} catch (e) {
console.error('Error creating bundle:', e.message);
process.exit(1);
}

View File

@@ -1,5 +1,5 @@
<script>
import { getSystemInfo, reboot } from "./lib/api.js";
import { getSystemInfo, reboot, getOTAStatus } from "./lib/api.js";
import OTAUpdate from "./lib/OTAUpdate.svelte";
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
@@ -16,6 +16,12 @@
connection: "—",
});
let otaStatus = $state({
partitions: [],
active_partition: "—",
running_firmware_label: "—"
});
/** Format uptime seconds into human-readable string */
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -37,9 +43,11 @@
return `${bytes} B`;
}
async function fetchInfo() {
async function fetchAll() {
try {
systemInfo = await getSystemInfo();
const [sys, ota] = await Promise.all([getSystemInfo(), getOTAStatus()]);
systemInfo = sys;
otaStatus = ota;
status = "ok";
errorMsg = "";
} catch (e) {
@@ -62,7 +70,10 @@
}
$effect(() => {
fetchInfo();
fetchAll();
// Poll for status updates every 5 seconds
const interval = setInterval(fetchAll, 5000);
return () => clearInterval(interval);
});
// Resilient recovery polling: Only poll when we are waiting for a reboot
@@ -93,70 +104,61 @@
]);
</script>
<main class="min-h-screen bg-bg-primary flex items-center justify-center p-4">
<div class="w-full max-w-xl space-y-4">
<main class="min-h-screen bg-bg-primary p-4 md:p-8 flex flex-col items-center">
<div class="w-full max-w-6xl space-y-8">
<!-- Header -->
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀</h1>
<div class="text-center">
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀🚀👑</h1>
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
</div>
<!-- Status Badge -->
<div class="flex justify-center">
<div class="flex justify-center mt-4">
{#if status === "loading"}
<div
class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-2 text-sm text-accent"
>
<div class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-1.5 text-xs text-accent">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
<span>Connecting to ESP32...</span>
<span>Connecting...</span>
</div>
{:else if status === "ok"}
<div
class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-2 text-sm text-success"
>
<div class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-1.5 text-xs text-success">
<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>
<span>Connected</span>
</div>
{:else if status === "rebooting"}
<div
class="inline-flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-2 text-sm text-amber-400"
>
<div class="inline-flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-1.5 text-xs text-amber-400">
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span>Rebooting...</span>
</div>
{:else}
<div
class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-2 text-sm text-danger"
>
<div class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-1.5 text-xs text-danger">
<span class="w-2 h-2 rounded-full bg-danger"></span>
<span>Offline — {errorMsg}</span>
</div>
{/if}
</div>
</div>
<!-- 2-Column Grid Layout -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<!-- Left Column: System Info & Partition Table -->
<div class="space-y-8">
<!-- System Info Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden">
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border">
<h2
class="text-sm font-semibold text-text-primary uppercase tracking-wider"
>
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
System Info
</h2>
</div>
<div class="divide-y divide-border">
{#each infoItems as item}
<div
class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors"
>
<div class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors">
<div class="flex items-center gap-3">
<span class="text-base">{item.icon}</span>
<span class="text-sm text-text-secondary">{item.label}</span>
</div>
<span class="text-sm font-mono text-text-primary">
{#if status === "loading"}
<span
class="inline-block w-16 h-4 bg-border rounded animate-pulse"
></span>
<span class="inline-block w-16 h-4 bg-border rounded animate-pulse"></span>
{:else}
{item.value}
{/if}
@@ -166,8 +168,57 @@
</div>
</div>
<!-- Device Control Section (Reboot) -->
<div class="bg-bg-card border border-border rounded-xl p-5">
<!-- Partition Table Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<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">
Partition Table
</h2>
<span class="text-[10px] text-text-secondary font-mono">Flash: 16MB</span>
</div>
<div class="divide-y divide-border">
{#if status === "loading"}
<div class="p-5 text-center text-xs text-text-secondary animate-pulse">Loading memory layout...</div>
{:else}
{#each otaStatus.partitions as part}
<div class="px-5 py-2.5 flex items-center justify-between hover:bg-bg-card-hover transition-colors">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono font-bold {part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label ? 'text-accent' : 'text-text-primary'}">
{part.label}
</span>
{#if part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label}
<span class="text-[8px] bg-accent/20 text-accent px-1 rounded uppercase tracking-tighter">Active</span>
{/if}
</div>
<span class="text-[9px] text-text-secondary uppercase">
Type {part.type} / Sub {part.subtype}
</span>
</div>
<div class="text-right flex flex-col items-end">
<div class="text-[11px] font-mono text-text-primary">{formatBytes(part.size)}</div>
<div class="flex items-center gap-1.5 mt-0.5">
{#if part.app_version}
<span class="text-[9px] text-accent font-bold">v{part.app_version}</span>
{/if}
{#if part.free !== undefined}
<span class="text-[9px] {part.free > 1024 ? 'text-success' : 'text-text-secondary'} font-bold">
{formatBytes(part.free)} free
</span>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>
<!-- Right Column: Device Control & OTA Updates -->
<div class="space-y-8">
<!-- Device Control Card -->
<div class="bg-bg-card border border-border rounded-xl p-5 shadow-xl">
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
@@ -190,23 +241,19 @@
</div>
</div>
<!-- Frontend Info & OTA Section -->
<OTAUpdate onReboot={() => (status = "rebooting")} />
<!-- Updates & Maintenance Card -->
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
</div>
</div>
<!-- Reboot Confirmation Modal -->
{#if showRebootConfirm}
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
>
<div
class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4"
>
<h3 class="text-lg font-semibold text-text-primary">
Confirm Reboot
</h3>
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
<div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl">
<h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3>
<p class="text-sm text-text-secondary">
Are you sure you want to reboot the ESP32? The device will be
temporarily unavailable.
Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.
</p>
<div class="flex gap-3 justify-end">
<button
@@ -217,7 +264,7 @@
</button>
<button
onclick={handleReboot}
class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors"
class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors shadow-lg shadow-danger/20"
>
Reboot Now
</button>

View File

@@ -1,28 +1,36 @@
<script>
let { onReboot = null } = $props();
import { getOTAStatus, uploadOTAFrontend } from "./api.js";
import { getOTAStatus, uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle, getSystemInfo } 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 uploadProgress = $state(0);
let otaInfo = $state({
active_slot: -1,
active_partition: "—",
target_partition: "—",
partitions: [],
running_firmware_label: "—"
});
let systemInfo = $state({
firmware: "—"
});
let selectedFile = $state(null);
let showAdvanced = $state(false);
/** @type {'frontend' | 'firmware' | 'bundle'} */
let updateMode = $state("frontend");
let isDragging = $state(false);
async function fetchStatus() {
status = "loading_status";
try {
otaInfo = await getOTAStatus();
[otaInfo, systemInfo] = await Promise.all([getOTAStatus(), getSystemInfo()]);
status = "idle";
} catch (e) {
status = "error";
@@ -30,29 +38,32 @@
}
}
// Fetch status on mount
$effect(() => {
fetchStatus();
});
function handleFileChange(event) {
const files = event.target.files;
if (files && files.length > 0) {
processFile(files[0]);
}
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]);
}
if (files && files.length > 0) processFile(files[0]);
}
function processFile(file) {
if (file.name.endsWith('.bin')) {
if (updateMode === 'bundle') {
if (file.name.endsWith('.bundle')) {
selectedFile = file;
errorMsg = "";
} else {
selectedFile = null;
errorMsg = "Please select a valid .bundle file";
}
} else if (file.name.endsWith('.bin')) {
selectedFile = file;
errorMsg = "";
} else {
@@ -73,7 +84,13 @@
}, 500);
try {
if (updateMode === "frontend") {
await uploadOTAFrontend(selectedFile);
} else if (updateMode === "firmware") {
await uploadOTAFirmware(selectedFile);
} else {
await uploadOTABundle(selectedFile);
}
clearInterval(progressInterval);
uploadProgress = 100;
status = "success";
@@ -85,38 +102,75 @@
errorMsg = e.message;
}
}
function toggleMode(mode) {
if (showAdvanced && updateMode === mode) {
showAdvanced = false;
} else {
showAdvanced = true;
updateMode = mode;
selectedFile = null;
errorMsg = "";
}
}
const currentTarget = $derived(() => {
if (updateMode === 'bundle') return 'FW + UI';
if (updateMode === 'frontend') return otaInfo.target_partition;
// For firmware, target is the slot that is NOT the running one
const runningLabel = otaInfo.running_firmware_label || 'ota_0';
return runningLabel === 'ota_0' ? 'ota_1' : 'ota_0';
});
</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
Updates & Maintenance
</h2>
<div class="flex gap-2">
<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"
onclick={() => toggleMode('frontend')}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
{showAdvanced && updateMode === 'frontend' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
>
{showAdvanced ? 'Hide Tools' : 'OTA Update'}
Frontend OTA
</button>
<button
onclick={() => toggleMode('firmware')}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
{showAdvanced && updateMode === 'firmware' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
>
Firmware
</button>
<button
onclick={() => toggleMode('bundle')}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
{showAdvanced && updateMode === 'bundle' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
>
Universal Bundle
</button>
</div>
</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-[10px] uppercase text-text-secondary font-bold mb-1">UI Version</div>
<div class="text-sm font-mono text-accent">v{__APP_VERSION__}</div>
<div class="text-[9px] text-text-secondary mt-1">
Slot: {otaInfo.active_partition}
{#if otaInfo.partitions}
({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024).toFixed(0)} KB free)
{/if}
</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 class="text-[10px] uppercase text-text-secondary font-bold mb-1">FW Version</div>
<div class="text-sm font-mono text-text-primary">{systemInfo.firmware}</div>
<div class="text-[9px] text-text-secondary mt-1">
Active: {otaInfo.active_slot === 0 ? 'ota_0' : 'ota_1'}
</div>
</div>
</div>
@@ -124,12 +178,14 @@
{#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>
<h3 class="text-xs font-bold text-text-primary">
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
</h3>
<div class="text-[10px] text-text-secondary">
Target: <span class="font-mono">{otaInfo.target_partition}</span>
{#if otaInfo.partitions}
Target: <span class="font-mono text-accent">{currentTarget()}</span>
{#if updateMode === 'frontend' && otaInfo.partitions}
<span class="ml-1">
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(2)} MB capacity)
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)
</span>
{/if}
</div>
@@ -138,13 +194,12 @@
{#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...
Update successful! Rebooting device...
</div>
{:else}
<!-- Drag and Drop Zone -->
<div
role="button"
aria-label="Upload partition image"
aria-label="Upload data"
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'}
@@ -153,19 +208,13 @@
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>
<input type="file" accept="{updateMode === 'bundle' ? '.bundle' : '.bin'}" onchange={handleFileChange} class="absolute inset-0 opacity-0 cursor-pointer" />
<div class="text-2xl">{updateMode === 'frontend' ? '🎨' : '⚙️'}</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-xs text-text-primary">Drop {updateMode === 'bundle' ? 'Universal .bundle' : updateMode === 'frontend' ? 'UI .bin' : 'Firmware .bin'} here</div>
<div class="text-[10px] text-text-secondary">or click to browse</div>
{/if}
</div>
@@ -174,25 +223,18 @@
<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"
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}`}
{status === "uploading" ? 'Flashing...' : `Update ${updateMode === 'bundle' ? 'Everything' : updateMode === 'frontend' ? 'UI' : 'Firmware'}`}
</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 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>
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">{errorMsg}</p>
{/if}
{/if}
</div>

View File

@@ -40,7 +40,7 @@ export async function reboot() {
/**
* Fetch OTA status from the ESP32.
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string}>}
* @returns {Promise<{active_slot: number, active_partition: string, target_partition: string, partitions: any[], running_firmware_label: string, running_firmware_slot: number}>}
*/
export async function getOTAStatus() {
const res = await fetch(`${API_BASE}/api/ota/status`);
@@ -73,3 +73,46 @@ export async function uploadOTAFrontend(file) {
return res.json();
}
/**
* Upload a new firmware binary image.
* @param {File} file The firmware binary file to upload.
* @returns {Promise<{status: string, message: string}>}
*/
export async function uploadOTAFirmware(file) {
const res = await fetch(`${API_BASE}/api/ota/firmware`, {
method: 'POST',
body: file,
headers: {
'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();
}
/**
* Upload a universal .bundle image (FW + WWW).
* @param {File} file The bundle binary file to upload.
* @returns {Promise<{status: string, message: string}>}
*/
export async function uploadOTABundle(file) {
const res = await fetch(`${API_BASE}/api/ota/bundle`, {
method: 'POST',
body: file,
headers: {
'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();
}

View File

@@ -1,5 +1,5 @@
{
"major": 0,
"minor": 1,
"revision": 7
"revision": 14
}

View File

@@ -0,0 +1,187 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <sys/param.h>
// Project
#include "appstate.hpp"
#include "types.hpp"
#define BUNDLE_SCRATCH_BUFSIZE 4096
typedef struct
{
char magic[4];
uint32_t fw_size;
uint32_t www_size;
} bundle_header_t;
internal void bundle_ota_restart_timer_callback(void *arg) { esp_restart(); }
internal esp_err_t api_ota_bundle_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
if (req->content_len < sizeof(bundle_header_t))
{
ESP_LOGE("OTA_BUNDLE", "Request content too short");
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Content too short");
return ESP_FAIL;
}
char *buf = (char *)malloc(BUNDLE_SCRATCH_BUFSIZE);
if (!buf)
{
ESP_LOGE("OTA_BUNDLE", "Failed to allocate buffer");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
// 1. Read Header
bundle_header_t header;
int overhead = httpd_req_recv(req, (char *)&header, sizeof(bundle_header_t));
if (overhead <= 0)
{
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Header receive failed");
return ESP_FAIL;
}
if (memcmp(header.magic, "BNDL", 4) != 0)
{
free(buf);
ESP_LOGE("OTA_BUNDLE", "Invalid magic: %.4s", header.magic);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid bundle magic");
return ESP_FAIL;
}
ESP_LOGI("OTA_BUNDLE",
"Starting Universal Update: FW %lu bytes, WWW %lu bytes",
header.fw_size, header.www_size);
// 2. Prepare Firmware Update
const esp_partition_t *fw_part = esp_ota_get_next_update_partition(NULL);
esp_ota_handle_t fw_handle = 0;
esp_err_t err = esp_ota_begin(fw_part, header.fw_size, &fw_handle);
if (err != ESP_OK)
{
free(buf);
ESP_LOGE("OTA_BUNDLE", "esp_ota_begin failed (%s)", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA Begin failed");
return ESP_FAIL;
}
// 3. Stream Firmware
uint32_t fw_remaining = header.fw_size;
bool fw_first_chunk = true;
while (fw_remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(fw_remaining, BUNDLE_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
continue;
esp_ota_abort(fw_handle);
free(buf);
return ESP_FAIL;
}
if (fw_first_chunk && recv_len > 0)
{
if ((uint8_t)buf[0] != 0xE9)
{
ESP_LOGE("OTA_BUNDLE", "Invalid FW magic in bundle: %02X",
(uint8_t)buf[0]);
esp_ota_abort(fw_handle);
free(buf);
httpd_resp_send_err(
req, HTTPD_400_BAD_REQUEST,
"Invalid Bundle: Firmware part is corrupted or invalid.");
return ESP_FAIL;
}
fw_first_chunk = false;
}
esp_ota_write(fw_handle, buf, recv_len);
fw_remaining -= recv_len;
}
esp_ota_end(fw_handle);
// 4. Prepare WWW Update
uint8_t target_www_slot = g_Active_WWW_Partition == 0 ? 1 : 0;
const char *www_label = target_www_slot == 0 ? "www_0" : "www_1";
const esp_partition_t *www_part = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS, www_label);
esp_partition_erase_range(www_part, 0, www_part->size);
// 5. Stream WWW
uint32_t www_remaining = header.www_size;
uint32_t www_written = 0;
while (www_remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(www_remaining, BUNDLE_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
continue;
free(buf);
return ESP_FAIL;
}
esp_partition_write(www_part, www_written, buf, recv_len);
www_written += recv_len;
www_remaining -= recv_len;
}
free(buf);
// 6. Commit Updates
esp_ota_set_boot_partition(fw_part);
nvs_handle_t nvs_h;
if (nvs_open("storage", NVS_READWRITE, &nvs_h) == ESP_OK)
{
nvs_set_u8(nvs_h, "www_part", target_www_slot);
nvs_commit(nvs_h);
nvs_close(nvs_h);
}
ESP_LOGI("OTA_BUNDLE", "Universal Update Complete! Rebooting...");
httpd_resp_set_type(req, "application/json");
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "status", "success");
cJSON_AddStringToObject(root, "message",
"Universal update successful, rebooting...");
const char *resp = cJSON_Print(root);
httpd_resp_sendstr(req, resp);
free((void *)resp);
cJSON_Delete(root);
// Reboot
esp_timer_create_args_t tmr_args = {};
tmr_args.callback = &bundle_ota_restart_timer_callback;
tmr_args.name = "bundle_reboot";
esp_timer_handle_t tmr;
esp_timer_create(&tmr_args, &tmr);
esp_timer_start_once(tmr, 1'000'000);
return ESP_OK;
}
internal const httpd_uri_t api_ota_bundle_uri = {.uri = "/api/ota/bundle",
.method = HTTP_POST,
.handler =
api_ota_bundle_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,164 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include <sys/param.h>
// Project
#include "appstate.hpp"
#include "types.hpp"
#define OTA_FIRMWARE_SCRATCH_BUFSIZE 4096
internal void firmware_ota_restart_timer_callback(void *arg) { esp_restart(); }
internal esp_err_t api_ota_firmware_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
const esp_partition_t *update_partition =
esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL)
{
ESP_LOGE("OTA_FW", "Passive OTA partition not found");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA partition not found");
return ESP_FAIL;
}
ESP_LOGI("OTA_FW", "Writing to partition subtype %d at offset 0x%lx",
update_partition->subtype, update_partition->address);
esp_ota_handle_t update_handle = 0;
esp_err_t err =
esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle);
if (err != ESP_OK)
{
ESP_LOGE("OTA_FW", "esp_ota_begin failed (%s)", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA begin failed");
return ESP_FAIL;
}
char *buf = (char *)malloc(OTA_FIRMWARE_SCRATCH_BUFSIZE);
if (!buf)
{
ESP_LOGE("OTA_FW", "Failed to allocate buffer");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int binary_file_len = 0;
int remaining = req->content_len;
while (remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(remaining, OTA_FIRMWARE_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
{
continue;
}
ESP_LOGE("OTA_FW", "Receive failed");
esp_ota_abort(update_handle);
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Receive failed");
return ESP_FAIL;
}
if (binary_file_len == 0 && recv_len > 0)
{
if ((uint8_t)buf[0] != 0xE9)
{
ESP_LOGE("OTA_FW", "Invalid magic: %02X. Expected 0xE9 for Firmware.",
(uint8_t)buf[0]);
esp_ota_abort(update_handle);
free(buf);
httpd_resp_send_err(
req, HTTPD_400_BAD_REQUEST,
"Invalid file: This does not look like an ESP32 firmware binary.");
return ESP_FAIL;
}
}
err = esp_ota_write(update_handle, (const void *)buf, recv_len);
if (err != ESP_OK)
{
ESP_LOGE("OTA_FW", "esp_ota_write failed (%s)", esp_err_to_name(err));
esp_ota_abort(update_handle);
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Flash write failed");
return ESP_FAIL;
}
binary_file_len += recv_len;
remaining -= recv_len;
}
free(buf);
ESP_LOGI("OTA_FW", "Total binary data written: %d", binary_file_len);
err = esp_ota_end(update_handle);
if (err != ESP_OK)
{
if (err == ESP_ERR_OTA_VALIDATE_FAILED)
{
ESP_LOGE("OTA_FW", "Image validation failed, image is corrupted");
}
else
{
ESP_LOGE("OTA_FW", "esp_ota_end failed (%s)!", esp_err_to_name(err));
}
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA validation/end failed");
return ESP_FAIL;
}
err = esp_ota_set_boot_partition(update_partition);
if (err != ESP_OK)
{
ESP_LOGE("OTA_FW", "esp_ota_set_boot_partition failed (%s)!",
esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set boot partition");
return ESP_FAIL;
}
ESP_LOGI("OTA_FW", "OTA successful, rebooting...");
httpd_resp_set_type(req, "application/json");
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "status", "success");
cJSON_AddStringToObject(root, "message",
"Firmware update successful, rebooting...");
const char *response_text = cJSON_Print(root);
httpd_resp_sendstr(req, response_text);
free((void *)response_text);
cJSON_Delete(root);
// Trigger reboot with 1s delay
const esp_timer_create_args_t restart_timer_args = {
.callback = &firmware_ota_restart_timer_callback,
.arg = (void *)0,
.dispatch_method = ESP_TIMER_TASK,
.name = "fw_ota_restart_timer",
.skip_unhandled_events = false};
esp_timer_handle_t restart_timer;
esp_timer_create(&restart_timer_args, &restart_timer);
esp_timer_start_once(restart_timer, 1'000'000);
return ESP_OK;
}
internal const httpd_uri_t api_ota_firmware_uri = {.uri = "/api/ota/firmware",
.method = HTTP_POST,
.handler =
api_ota_firmware_handler,
.user_ctx = NULL};

View File

@@ -8,7 +8,6 @@
#include "nvs.h"
#include "nvs_flash.h"
// Project
#include "appstate.hpp"
#include "types.hpp"
@@ -59,6 +58,7 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
int total_read = 0;
int remaining = req->content_len;
bool first_chunk = true;
while (remaining > 0)
{
@@ -77,6 +77,21 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
return ESP_FAIL;
}
if (first_chunk)
{
if ((uint8_t)buf[0] == 0xE9)
{
ESP_LOGE("OTA", "Magic 0xE9 detected. This looks like a FIRMWARE bin, "
"but you are uploading to FRONTEND slot!");
free(buf);
httpd_resp_send_err(
req, HTTPD_400_BAD_REQUEST,
"Invalid file: This is a Firmware binary, not a UI binary.");
return ESP_FAIL;
}
first_chunk = false;
}
err = esp_partition_write(partition, total_read, buf, recv_len);
if (err != ESP_OK)
{

View File

@@ -1,12 +1,20 @@
#include <cstddef>
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_image_format.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
#include "esp_vfs.h"
#include <string.h>
// Project
#include "appstate.hpp"
#include "types.hpp"
#include "utils.hpp"
internal esp_err_t api_ota_status_handler(httpd_req_t *req)
{
@@ -17,34 +25,58 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition);
const char *partitions[] = {"www_0", "www_1"};
cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions");
for (int i = 0; i < 2; i++)
esp_partition_iterator_t it = esp_partition_find(
ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
while (it != NULL)
{
const esp_partition_t *p = esp_partition_get(it);
cJSON *p_obj = cJSON_CreateObject();
cJSON_AddStringToObject(p_obj, "label", partitions[i]);
const esp_partition_t *p = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, partitions[i]);
if (p)
{
cJSON_AddStringToObject(p_obj, "label", p->label);
cJSON_AddNumberToObject(p_obj, "type", p->type);
cJSON_AddNumberToObject(p_obj, "subtype", p->subtype);
cJSON_AddNumberToObject(p_obj, "address", p->address);
cJSON_AddNumberToObject(p_obj, "size", p->size);
// Try to get LittleFS info if it's a data partition
if (p->type == ESP_PARTITION_TYPE_DATA)
{
size_t total = 0, used = 0;
if (esp_littlefs_info(partitions[i], &total, &used) == ESP_OK)
if (esp_littlefs_info(p->label, &total, &used) == ESP_OK)
{
cJSON_AddNumberToObject(p_obj, "used", used);
cJSON_AddNumberToObject(p_obj, "free", total - used);
}
else
{
// Not mounted or not LFS
cJSON_AddNumberToObject(p_obj, "used", 0);
cJSON_AddNumberToObject(p_obj, "free", p->size);
// For other data partitions (nvs, phy_init), just show total as used
// for now
cJSON_AddNumberToObject(p_obj, "used", p->size);
cJSON_AddNumberToObject(p_obj, "free", 0);
}
}
// For app partitions, try to find the binary size
else if (p->type == ESP_PARTITION_TYPE_APP)
{
esp_app_desc_t app_desc;
if (esp_ota_get_partition_description(p, &app_desc) == ESP_OK)
{
cJSON_AddStringToObject(p_obj, "app_version", app_desc.version);
// Get the true binary size from image metadata
esp_image_metadata_t data;
const esp_partition_pos_t pos = {.offset = p->address, .size = p->size};
if (esp_image_get_metadata(&pos, &data) == ESP_OK)
{
cJSON_AddNumberToObject(p_obj, "used", data.image_len);
cJSON_AddNumberToObject(p_obj, "free", p->size - data.image_len);
}
}
}
cJSON_AddItemToArray(parts_arr, p_obj);
it = esp_partition_next(it);
}
cJSON_AddStringToObject(root, "active_partition",
@@ -52,6 +84,24 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON_AddStringToObject(root, "target_partition",
g_Active_WWW_Partition == 0 ? "www_1" : "www_0");
const esp_partition_t *running = esp_ota_get_running_partition();
if (running)
{
cJSON_AddStringToObject(root, "running_firmware_label", running->label);
if (running->subtype >= ESP_PARTITION_SUBTYPE_APP_OTA_MIN &&
running->subtype <= ESP_PARTITION_SUBTYPE_APP_OTA_MAX)
{
cJSON_AddNumberToObject(root, "running_firmware_slot",
running->subtype -
ESP_PARTITION_SUBTYPE_APP_OTA_MIN);
}
else
{
cJSON_AddNumberToObject(root, "running_firmware_slot",
-1); // Factory or other
}
}
const char *status_info = cJSON_Print(root);
httpd_resp_sendstr(req, status_info);

View File

@@ -13,12 +13,13 @@
#endif
// Project
#include "api/ota/bundle.cpp"
#include "api/ota/firmware.cpp"
#include "api/ota/frontend.cpp"
#include "api/ota/status.cpp"
#include "api/system/info.cpp"
#include "api/system/reboot.cpp"
internal const char *TAG = "HTTP_SERVER";
constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1;
@@ -234,6 +235,8 @@ internal httpd_handle_t start_webserver(void)
httpd_register_uri_handler(server, &api_system_reboot_uri);
httpd_register_uri_handler(server, &api_ota_status_uri);
httpd_register_uri_handler(server, &api_ota_frontend_uri);
httpd_register_uri_handler(server, &api_ota_firmware_uri);
httpd_register_uri_handler(server, &api_ota_bundle_uri);
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Register static file handler last as a catch-all wildcard if deployed

View File

@@ -3,14 +3,14 @@
// SDK
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "sdkconfig.h"
#include "soc/gpio_num.h"
// Project headers
#include "appstate.hpp"
#include "types.hpp"
@@ -26,7 +26,7 @@ internal constexpr bool kBlockUntilEthernetEstablished = false;
extern "C" void app_main()
{
printf("Hello, Worldi!\n");
printf("Hello, Calendink OTA! [V1.1]\n");
httpd_handle_t web_server = NULL;
@@ -40,12 +40,28 @@ extern "C" void app_main()
nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
{
// If we are running from the factory partition, force the www partition to
// 0 This ensures that after a USB flash (which only writes to www_0), we
// aren't stuck looking at an old www_1.
const esp_partition_t *running = esp_ota_get_running_partition();
if (running && running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY)
{
printf(
"Running from factory: resetting www_part to 0 for consistency.\n");
g_Active_WWW_Partition = 0;
nvs_set_u8(my_handle, "www_part", 0);
nvs_commit(my_handle);
}
else
{
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
{
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
}
}
if (g_Active_WWW_Partition > 1)
{
g_Active_WWW_Partition = 0;
@@ -151,6 +167,9 @@ extern "C" void app_main()
printf("Connected!\n");
// Mark the current app as valid to cancel rollback
esp_ota_mark_app_valid_cancel_rollback();
// Start the webserver
web_server = start_webserver();

8
Provider/main/utils.hpp Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
#include <types.hpp>
template <typename T, size_t N> constexpr size_t ArrayCount(T (&)[N])
{
return N;
}

View File

@@ -1,6 +1,9 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,
otadata, data, ota, , 0x2000,
phy_init, data, phy, , 0x1000,
factory, app, factory, , 2M,
ota_0, app, ota_0, , 2M,
ota_1, app, ota_1, , 2M,
www_0, data, littlefs, , 1M,
www_1, data, littlefs, , 1M,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 phy_init otadata data phy ota 0xf000 0x1000 0x2000
4 factory phy_init app data factory phy 0x10000 1M 0x1000
5 factory app factory 2M
6 ota_0 app ota_0 2M
7 ota_1 app ota_1 2M
8 www_0 data littlefs 1M
9 www_1 data littlefs 1M

View File

@@ -0,0 +1,79 @@
# Firmware OTA Strategy for ESP32-S3 Provider
**Authored by Antigravity**
**Date:** 2026-03-03
---
## 1. Goal
Implement a robust Over-The-Air (OTA) update mechanism for both the main firmware of the ESP32-S3 and the Svelte frontend. The update must:
- Update the core application logic and the user interface without requiring a physical USB connection.
- Keep the Firmware and Frontend in sync by allowing them to be updated together atomically.
- Provide a reliable fallback if an update fails (Rollback capability via A/B slots).
- Provide a permanent "factory" fallback as an extreme safety measure.
- Prevent accidental cross-flashing (e.g., flashing UI to firmware slots).
- Maintain a clear versioning scheme visible to the user, with accurate partition space reporting.
## 2. Chosen Approach
We implemented a **Universal Dual-Partition OTA system** using ESP-IDF's native OTA mechanisms for the firmware and LittleFS for the frontend.
Updates can be performed individually (Firmware only via `.bin`, Frontend only via `.bin`), but the primary and recommended approach is the **Universal OTA Bundle**.
The build process generates a single `.bundle` file containing both the firmware image and the compiled frontend filesystem. This bundle is uploaded via the frontend UI, streamed directly to the inactive OTA flash partition (`ota_0` or `ota_1`) and inactive UI partition (`www_0` or `www_1`). Upon successful transfer and validation of both components, the bootloader and NVS are instructed to switch active partitions on the next restart.
## 3. Design Decisions & Trade-offs
### 3.1. Why Dual-Partition (A/B) with Factory?
- **Safety**: A failed or interrupted upload never "bricks" the device.
- **Factory Fallback**: By maintaining a dedicated 2MB `factory` partition alongside the two 2MB OTA partitions (`ota_0`, `ota_1`), we ensure that even if both OTA slots are irrecoverably corrupted, the device can always boot into a known-good state.
- **Frontend Sync**: The frontend also uses a dual-partition layout (`www_0`, `www_1`). The Universal Bundle ensures both FW and UI switch together.
### 3.2. Automatic App Rollback
We rely on ESP-IDF's built-in "App Rollback" feature.
- **The Mechanism**: When the ESP32 boots a newly OTA-flashed firmware, it is marked as "Pending Verify". If the application crashes or fails to mark itself as "valid", the bootloader reverts to the previous working partition.
- **Validation Point**: We consider the firmware "valid" only after it successfully establishes a network connection.
### 3.3. Universal Bundle Format & Automation
- **Format**: A custom 12-byte header (`BNDL` magic + 4-byte FW size + 4-byte UI size) followed by the FW binary and UI binary.
- **Automation**: The Svelte build chain automates packaging. Running `npm run ota:bundle` automatically triggers Vite production build, LittleFS frontend packaging, applies proper semantic version sorting (to always pick the latest compiled UI), and generates the `.bundle` payload.
### 3.4. Safety & Validation
- **Magic Number Checks**: The backend enforces strict validation before writing to flash. Firmware endpoints and bundle streams check for the ESP32 image magic byte (`0xE9`), and Bundle endpoints check for the `BNDL` magic header. This prevents a user from accidentally uploading a LittleFS image to the Firmware slot, avoiding immediate boot loops.
- **Atomic Commits**: The Universal Bundle handler only sets the new boot partition and updates the NVS UI partition index if *both* firmware and frontend streams complete successfully.
### 3.5. Versioning & Partition Metadata
- **Firmware Versioning**: Extracted natively from `esp_app_desc_t`, syncing API version with CMake `PROJECT_VER`.
- **Space Reporting**: The system dynamically scans App partitions using `esp_image_get_metadata()` to determine the exact binary size flashed in each slot. This allows the UI to display accurate "used" and "free" space per partition, regardless of the fixed partition size.
## 4. Final Architecture
### 4.1. The Partition Table
```csv
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x6000
otadata, data, ota, , 0x2000
phy_init, data, phy, , 0x1000
factory, app, factory, , 2M
ota_0, app, ota_0, , 2M
ota_1, app, ota_1, , 2M
www_0, data, littlefs, , 1M
www_1, data, littlefs, , 1M
```
### 4.2. Backend Components
- `bundle.cpp`: Handles `POST /api/ota/bundle`. Streams the file, splitting it on the fly into the inactive `ota` and `www` partitions.
- `firmware.cpp` & `frontend.cpp`: Handles individual component updates.
- `status.cpp`: Uses `esp_partition_find` and `esp_image_get_metadata` to report partition sizes and active slots.
- `main.cpp`: Calls `esp_ota_mark_app_valid_cancel_rollback()` post-network connection and manages NVS synchronization for the UI slot when booting from Factory.
### 4.3. UI/UX Implementation
- The Svelte Dashboard features a comprehensive "Update System" component supporting individual (FW/UI) and combined (Bundle) uploads.
- A "Partition Table" view provides real-time visibility into the exact binary size, available free space, and version hash of every system and app partition.
## 5. Summary
We use **ESP-IDF's native OTA APIs** with a **Factory + Dual A/B Partition** layout, synchronized with a **Dual LittleFS Partition** layout for the frontend. The system relies on custom **Universal Bundles** to guarantee atomic FW+UI upgrades, protected by **Magic Number validations** and **Automatic App Rollbacks**. The entire process is driven from a highly integrated Svelte UI that leverages backend metadata extraction to provide accurate system insights.
---
*Created by Antigravity - Last Updated: 2026-03-03*