Compare commits

..

2 Commits

18 changed files with 994 additions and 277 deletions

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. - Go to the **Frontend Update** section.
- Select the `www.bin` file and click **Flash**. - Select the `www.bin` file and click **Flash**.
- The device will automatically write to the inactive partition and reboot. - 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": "vite build",
"build:esp32": "vite build && node scripts/gzip.js", "build:esp32": "vite build && node scripts/gzip.js",
"ota:package": "node scripts/package.js", "ota:package": "node scripts/package.js",
"ota:bundle": "npm run ota:package && node scripts/bundle.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "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> <script>
import { getSystemInfo, reboot } from "./lib/api.js"; import { getSystemInfo, reboot, getOTAStatus } from "./lib/api.js";
import OTAUpdate from "./lib/OTAUpdate.svelte"; import OTAUpdate from "./lib/OTAUpdate.svelte";
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */ /** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
@@ -16,6 +16,12 @@
connection: "—", connection: "—",
}); });
let otaStatus = $state({
partitions: [],
active_partition: "—",
running_firmware_label: "—"
});
/** Format uptime seconds into human-readable string */ /** Format uptime seconds into human-readable string */
function formatUptime(seconds) { function formatUptime(seconds) {
const d = Math.floor(seconds / 86400); const d = Math.floor(seconds / 86400);
@@ -37,9 +43,11 @@
return `${bytes} B`; return `${bytes} B`;
} }
async function fetchInfo() { async function fetchAll() {
try { try {
systemInfo = await getSystemInfo(); const [sys, ota] = await Promise.all([getSystemInfo(), getOTAStatus()]);
systemInfo = sys;
otaStatus = ota;
status = "ok"; status = "ok";
errorMsg = ""; errorMsg = "";
} catch (e) { } catch (e) {
@@ -62,7 +70,10 @@
} }
$effect(() => { $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 // Resilient recovery polling: Only poll when we are waiting for a reboot
@@ -93,70 +104,61 @@
]); ]);
</script> </script>
<main class="min-h-screen bg-bg-primary flex items-center justify-center p-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-xl space-y-4"> <div class="w-full max-w-6xl space-y-8">
<!-- Header --> <!-- Header -->
<div class="text-center mb-6"> <div class="text-center">
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀</h1> <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> <p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
</div>
<!-- Status Badge --> <!-- Status Badge -->
<div class="flex justify-center"> <div class="flex justify-center mt-4">
{#if status === "loading"} {#if status === "loading"}
<div <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">
class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-2 text-sm text-accent"
>
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
<span>Connecting to ESP32...</span> <span>Connecting...</span>
</div> </div>
{:else if status === "ok"} {:else if status === "ok"}
<div <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">
class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-2 text-sm text-success"
>
<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>
<span>Connected</span> <span>Connected</span>
</div> </div>
{:else if status === "rebooting"} {:else if status === "rebooting"}
<div <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">
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"
>
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span>Rebooting...</span> <span>Rebooting...</span>
</div> </div>
{:else} {:else}
<div <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">
class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-2 text-sm text-danger"
>
<span class="w-2 h-2 rounded-full bg-danger"></span> <span class="w-2 h-2 rounded-full bg-danger"></span>
<span>Offline — {errorMsg}</span> <span>Offline — {errorMsg}</span>
</div> </div>
{/if} {/if}
</div> </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 --> <!-- 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"> <div class="px-5 py-3 border-b border-border">
<h2 <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
class="text-sm font-semibold text-text-primary uppercase tracking-wider"
>
System Info System Info
</h2> </h2>
</div> </div>
<div class="divide-y divide-border"> <div class="divide-y divide-border">
{#each infoItems as item} {#each infoItems as item}
<div <div class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors">
class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-base">{item.icon}</span> <span class="text-base">{item.icon}</span>
<span class="text-sm text-text-secondary">{item.label}</span> <span class="text-sm text-text-secondary">{item.label}</span>
</div> </div>
<span class="text-sm font-mono text-text-primary"> <span class="text-sm font-mono text-text-primary">
{#if status === "loading"} {#if status === "loading"}
<span <span class="inline-block w-16 h-4 bg-border rounded animate-pulse"></span>
class="inline-block w-16 h-4 bg-border rounded animate-pulse"
></span>
{:else} {:else}
{item.value} {item.value}
{/if} {/if}
@@ -166,8 +168,57 @@
</div> </div>
</div> </div>
<!-- Device Control Section (Reboot) --> <!-- Partition Table Card -->
<div class="bg-bg-card border border-border rounded-xl p-5"> <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 class="flex items-center justify-between">
<div> <div>
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
@@ -190,23 +241,19 @@
</div> </div>
</div> </div>
<!-- Frontend Info & OTA Section --> <!-- Updates & Maintenance Card -->
<OTAUpdate onReboot={() => (status = "rebooting")} /> <OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
</div>
</div>
<!-- Reboot Confirmation Modal --> <!-- Reboot Confirmation Modal -->
{#if showRebootConfirm} {#if showRebootConfirm}
<div <div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
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 shadow-2xl">
> <h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3>
<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>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Are you sure you want to reboot the ESP32? The device will be Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.
temporarily unavailable.
</p> </p>
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<button <button
@@ -217,7 +264,7 @@
</button> </button>
<button <button
onclick={handleReboot} 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 Reboot Now
</button> </button>

View File

@@ -1,28 +1,36 @@
<script> <script>
let { onReboot = null } = $props(); 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; const IS_DEV = import.meta.env.DEV;
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */ /** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
let status = $state("idle"); let status = $state("idle");
let errorMsg = $state(""); let errorMsg = $state("");
let uploadProgress = $state(0); // 0 to 100 let uploadProgress = $state(0);
let otaInfo = $state({ let otaInfo = $state({
active_slot: -1, active_slot: -1,
active_partition: "—", active_partition: "—",
target_partition: "—", target_partition: "—",
partitions: [],
running_firmware_label: "—"
});
let systemInfo = $state({
firmware: "—"
}); });
let selectedFile = $state(null); let selectedFile = $state(null);
let showAdvanced = $state(false); let showAdvanced = $state(false);
/** @type {'frontend' | 'firmware' | 'bundle'} */
let updateMode = $state("frontend");
let isDragging = $state(false); let isDragging = $state(false);
async function fetchStatus() { async function fetchStatus() {
status = "loading_status"; status = "loading_status";
try { try {
otaInfo = await getOTAStatus(); [otaInfo, systemInfo] = await Promise.all([getOTAStatus(), getSystemInfo()]);
status = "idle"; status = "idle";
} catch (e) { } catch (e) {
status = "error"; status = "error";
@@ -30,29 +38,32 @@
} }
} }
// Fetch status on mount
$effect(() => { $effect(() => {
fetchStatus(); fetchStatus();
}); });
function handleFileChange(event) { function handleFileChange(event) {
const files = event.target.files; const files = event.target.files;
if (files && files.length > 0) { if (files && files.length > 0) processFile(files[0]);
processFile(files[0]);
}
} }
function handleDrop(event) { function handleDrop(event) {
event.preventDefault(); event.preventDefault();
isDragging = false; isDragging = false;
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
if (files && files.length > 0) { if (files && files.length > 0) processFile(files[0]);
processFile(files[0]);
}
} }
function processFile(file) { 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; selectedFile = file;
errorMsg = ""; errorMsg = "";
} else { } else {
@@ -73,7 +84,13 @@
}, 500); }, 500);
try { try {
if (updateMode === "frontend") {
await uploadOTAFrontend(selectedFile); await uploadOTAFrontend(selectedFile);
} else if (updateMode === "firmware") {
await uploadOTAFirmware(selectedFile);
} else {
await uploadOTABundle(selectedFile);
}
clearInterval(progressInterval); clearInterval(progressInterval);
uploadProgress = 100; uploadProgress = 100;
status = "success"; status = "success";
@@ -85,38 +102,75 @@
errorMsg = e.message; 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> </script>
{#if !IS_DEV} {#if !IS_DEV}
<div class="bg-bg-card border border-border rounded-xl overflow-hidden mt-4"> <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"> <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"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Frontend Info Updates & Maintenance
</h2> </h2>
<div class="flex gap-2">
<button <button
onclick={() => showAdvanced = !showAdvanced} onclick={() => toggleMode('frontend')}
class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded bg-border text-text-secondary hover:text-text-primary transition-colors" 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>
<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>
<div class="p-5 space-y-4"> <div class="p-5 space-y-4">
<!-- Version & Slot Info -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50"> <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-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>
<div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50"> <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-[10px] uppercase text-text-secondary font-bold mb-1">FW Version</div>
<div class="text-xs font-mono text-text-primary"> <div class="text-sm font-mono text-text-primary">{systemInfo.firmware}</div>
{otaInfo.active_partition} <div class="text-[9px] text-text-secondary mt-1">
{#if otaInfo.partitions} Active: {otaInfo.active_slot === 0 ? 'ota_0' : 'ota_1'}
<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> </div>
</div> </div>
@@ -124,12 +178,14 @@
{#if showAdvanced} {#if showAdvanced}
<div class="pt-2 border-t border-border/50 space-y-3"> <div class="pt-2 border-t border-border/50 space-y-3">
<div class="flex items-center justify-between"> <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"> <div class="text-[10px] text-text-secondary">
Target: <span class="font-mono">{otaInfo.target_partition}</span> Target: <span class="font-mono text-accent">{currentTarget()}</span>
{#if otaInfo.partitions} {#if updateMode === 'frontend' && otaInfo.partitions}
<span class="ml-1"> <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> </span>
{/if} {/if}
</div> </div>
@@ -138,13 +194,12 @@
{#if status === "success"} {#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"> <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> <span class="w-1.5 h-1.5 rounded-full bg-success"></span>
Update successful! The device is rebooting... Update successful! Rebooting device...
</div> </div>
{:else} {:else}
<!-- Drag and Drop Zone -->
<div <div
role="button" role="button"
aria-label="Upload partition image" aria-label="Upload data"
tabindex="0" 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 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'} {isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-border-hover bg-bg-primary/20'}
@@ -153,19 +208,13 @@
ondragleave={() => isDragging = false} ondragleave={() => isDragging = false}
ondrop={handleDrop} ondrop={handleDrop}
> >
<input <input type="file" accept="{updateMode === 'bundle' ? '.bundle' : '.bin'}" onchange={handleFileChange} class="absolute inset-0 opacity-0 cursor-pointer" />
type="file" <div class="text-2xl">{updateMode === 'frontend' ? '🎨' : '⚙️'}</div>
accept=".bin"
onchange={handleFileChange}
class="absolute inset-0 opacity-0 cursor-pointer"
/>
<div class="text-2xl">📦</div>
{#if selectedFile} {#if selectedFile}
<div class="text-xs font-medium text-text-primary">{selectedFile.name}</div> <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> <div class="text-[10px] text-text-secondary">{(selectedFile.size / 1024).toFixed(1)} KB</div>
{:else} {: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> <div class="text-[10px] text-text-secondary">or click to browse</div>
{/if} {/if}
</div> </div>
@@ -174,25 +223,18 @@
<button <button
onclick={handleUpload} onclick={handleUpload}
disabled={status === "uploading"} disabled={status === "uploading"}
class="w-full py-2 text-xs font-bold rounded-lg transition-colors class="w-full py-2 text-xs font-bold rounded-lg transition-colors bg-accent text-white hover:brightness-110 disabled:opacity-40"
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> </button>
{/if} {/if}
{#if status === "uploading"} {#if status === "uploading"}
<div class="w-full bg-border rounded-full h-1 mt-1 overflow-hidden"> <div class="w-full bg-border rounded-full h-1 mt-1 overflow-hidden">
<div <div class="bg-accent h-1 rounded-full transition-all duration-300" style="width: {uploadProgress}%"></div>
class="bg-accent h-1 rounded-full transition-all duration-300"
style="width: {uploadProgress}%"
></div>
</div> </div>
{:else if status === "error"} {:else if status === "error"}
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10"> <p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">{errorMsg}</p>
{errorMsg}
</p>
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@@ -40,7 +40,7 @@ export async function reboot() {
/** /**
* Fetch OTA status from the ESP32. * 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() { export async function getOTAStatus() {
const res = await fetch(`${API_BASE}/api/ota/status`); const res = await fetch(`${API_BASE}/api/ota/status`);
@@ -73,3 +73,46 @@ export async function uploadOTAFrontend(file) {
return res.json(); 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, "major": 0,
"minor": 1, "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.h"
#include "nvs_flash.h" #include "nvs_flash.h"
// Project // Project
#include "appstate.hpp" #include "appstate.hpp"
#include "types.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 total_read = 0;
int remaining = req->content_len; int remaining = req->content_len;
bool first_chunk = true;
while (remaining > 0) while (remaining > 0)
{ {
@@ -77,6 +77,21 @@ internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
return ESP_FAIL; 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); err = esp_partition_write(partition, total_read, buf, recv_len);
if (err != ESP_OK) if (err != ESP_OK)
{ {

View File

@@ -3,8 +3,13 @@
// SDK // SDK
#include "cJSON.h" #include "cJSON.h"
#include "esp_http_server.h" #include "esp_http_server.h"
#include "esp_image_format.h"
#include "esp_littlefs.h" #include "esp_littlefs.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_partition.h" #include "esp_partition.h"
#include "esp_vfs.h"
#include <string.h>
// Project // Project
#include "appstate.hpp" #include "appstate.hpp"
@@ -20,36 +25,58 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition); cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition);
constexpr const char *kPartitions[] = {"www_0", "www_1", "ota_0", "ota_1",
"factory"};
constexpr size_t kPartitionCount = ArrayCount(kPartitions);
cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions"); cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions");
for (size_t i = 0; i < kPartitionCount; 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 *p_obj = cJSON_CreateObject();
cJSON_AddStringToObject(p_obj, "label", kPartitions[i]); cJSON_AddStringToObject(p_obj, "label", p->label);
cJSON_AddNumberToObject(p_obj, "type", p->type);
const esp_partition_t *p = esp_partition_find_first( cJSON_AddNumberToObject(p_obj, "subtype", p->subtype);
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, kPartitions[i]); cJSON_AddNumberToObject(p_obj, "address", p->address);
if (p)
{
cJSON_AddNumberToObject(p_obj, "size", p->size); 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; size_t total = 0, used = 0;
if (esp_littlefs_info(kPartitions[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, "used", used);
cJSON_AddNumberToObject(p_obj, "free", total - used); cJSON_AddNumberToObject(p_obj, "free", total - used);
} }
else else
{ {
// Not mounted or not LFS // For other data partitions (nvs, phy_init), just show total as used
cJSON_AddNumberToObject(p_obj, "used", 0); // for now
cJSON_AddNumberToObject(p_obj, "free", p->size); 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); cJSON_AddItemToArray(parts_arr, p_obj);
it = esp_partition_next(it);
} }
cJSON_AddStringToObject(root, "active_partition", cJSON_AddStringToObject(root, "active_partition",
@@ -57,6 +84,24 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON_AddStringToObject(root, "target_partition", cJSON_AddStringToObject(root, "target_partition",
g_Active_WWW_Partition == 0 ? "www_1" : "www_0"); 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); const char *status_info = cJSON_Print(root);
httpd_resp_sendstr(req, status_info); httpd_resp_sendstr(req, status_info);

View File

@@ -13,12 +13,13 @@
#endif #endif
// Project // Project
#include "api/ota/bundle.cpp"
#include "api/ota/firmware.cpp"
#include "api/ota/frontend.cpp" #include "api/ota/frontend.cpp"
#include "api/ota/status.cpp" #include "api/ota/status.cpp"
#include "api/system/info.cpp" #include "api/system/info.cpp"
#include "api/system/reboot.cpp" #include "api/system/reboot.cpp"
internal const char *TAG = "HTTP_SERVER"; internal const char *TAG = "HTTP_SERVER";
constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1; 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_system_reboot_uri);
httpd_register_uri_handler(server, &api_ota_status_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_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 #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Register static file handler last as a catch-all wildcard if deployed // Register static file handler last as a catch-all wildcard if deployed

View File

@@ -3,14 +3,14 @@
// SDK // SDK
#include "esp_log.h" #include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "nvs.h" #include "nvs.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "sdkconfig.h"
#include "soc/gpio_num.h" #include "soc/gpio_num.h"
// Project headers // Project headers
#include "appstate.hpp" #include "appstate.hpp"
#include "types.hpp" #include "types.hpp"
@@ -26,7 +26,7 @@ internal constexpr bool kBlockUntilEthernetEstablished = false;
extern "C" void app_main() extern "C" void app_main()
{ {
printf("Hello, Worldi!\n"); printf("Hello, Calendink OTA! [V1.1]\n");
httpd_handle_t web_server = NULL; httpd_handle_t web_server = NULL;
@@ -40,12 +40,28 @@ extern "C" void app_main()
nvs_handle_t my_handle; nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK) 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); err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
{ {
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err)); printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
} }
}
if (g_Active_WWW_Partition > 1) if (g_Active_WWW_Partition > 1)
{ {
g_Active_WWW_Partition = 0; g_Active_WWW_Partition = 0;
@@ -151,6 +167,9 @@ extern "C" void app_main()
printf("Connected!\n"); printf("Connected!\n");
// Mark the current app as valid to cancel rollback
esp_ota_mark_app_valid_cancel_rollback();
// Start the webserver // Start the webserver
web_server = start_webserver(); web_server = start_webserver();

View File

@@ -7,38 +7,44 @@
## 1. Goal ## 1. Goal
Implement a robust Over-The-Air (OTA) update mechanism specifically for the main firmware of the ESP32-S3. The update must: 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 without requiring a physical USB connection. - 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 reliable fallback if an update fails (Rollback capability via A/B slots).
- Provide a permanent "factory" fallback as an extreme safety measure. - Provide a permanent "factory" fallback as an extreme safety measure.
- Integrate seamlessly with the existing Svelte frontend UI for a push-based update experience. - Prevent accidental cross-flashing (e.g., flashing UI to firmware slots).
- Maintain a clear versioning scheme visible to the user. - Maintain a clear versioning scheme visible to the user, with accurate partition space reporting.
## 2. Chosen Approach ## 2. Chosen Approach
We implemented a **Dual-Partition Image Flash (A/B slots) with Factory Fallback** strategy using ESP-IDF's native OTA mechanisms. We implemented a **Universal Dual-Partition OTA system** using ESP-IDF's native OTA mechanisms for the firmware and LittleFS for the frontend.
The build process generates a single `.bin` firmware image. This image is uploaded via the frontend UI and streamed directly to the inactive OTA flash partition (`ota_0` or `ota_1`). Upon successful transfer and validation, the bootloader is instructed to boot from the new partition on the next restart. 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. Design Decisions & Trade-offs
### 3.1. Why Dual-Partition (A/B) with Factory? ### 3.1. Why Dual-Partition (A/B) with Factory?
- **Safety**: A failed or interrupted upload never "bricks" the device. - **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. This requires an initial USB flash to set up but provides maximum long-term reliability. - **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.
- **Storage Allocation**: With 16MB of total flash on the ESP32-S3, dedicating 6MB to application code (3x 2MB) is a worthwhile trade-off for extreme resilience, while still leaving ample room for the frontend (`www_0`, `www_1`) and NVS. - **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 ### 3.2. Automatic App Rollback
We rely on ESP-IDF's built-in "App Rollback" feature. 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, resets, or fails to explicitly mark itself as "valid" during this initial boot, the bootloader automatically reverts to the previous working partition on the subsequent boot. - **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" (and stop the rollback timer) only after it successfully establishes a network connection (Ethernet or WiFi). This ensures that a bad update won't permanently disconnect the device from future OTA attempts. - **Validation Point**: We consider the firmware "valid" only after it successfully establishes a network connection.
### 3.3. Push vs. Pull Updates ### 3.3. Universal Bundle Format & Automation
- **Decision**: We implemented a "Push" mechanism where the user manually uploads the `.bin` file via the web UI. - **Format**: A custom 12-byte header (`BNDL` magic + 4-byte FW size + 4-byte UI size) followed by the FW binary and UI binary.
- **Rationale**: This matches the existing frontend OTA workflow and is simpler to implement initially. It avoids the need for external update servers, manifest files, and polling mechanisms. A "Pull" mechanism can be added later if fleet management becomes a requirement. - **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. Versioning Strategy ### 3.4. Safety & Validation
- **Decision**: We extract the firmware version directly from the ESP-IDF natively embedded `esp_app_desc_t` structure. - **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.
- **Rationale**: This ensures the version reported by the API (`GET /api/system/info`) is exactly the version compiled by CMake (`PROJECT_VER`), eliminating the risk of manual mismatches or external version files getting out of sync. - **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. Final Architecture
@@ -56,17 +62,18 @@ www_1, data, littlefs, , 1M
``` ```
### 4.2. Backend Components ### 4.2. Backend Components
- `main/api/ota/firmware.cpp`: The endpoint (`POST /api/ota/firmware`) handling the streaming ingestion of the `.bin` file using standard ESP-IDF `esp_ota` functions. - `bundle.cpp`: Handles `POST /api/ota/bundle`. Streams the file, splitting it on the fly into the inactive `ota` and `www` partitions.
- `main/api/system/system.cpp`: The endpoint querying `esp_app_get_description()` to expose the unified version payload to the frontend. - `firmware.cpp` & `frontend.cpp`: Handles individual component updates.
- `main/main.cpp`: The orchestrator that calls `esp_ota_mark_app_valid_cancel_rollback()` post-network connection. - `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 ### 4.3. UI/UX Implementation
- The Frontend OTA update component (`OTAUpdate.svelte`) is expanded to include a parallel "Firmware Update" section. - The Svelte Dashboard features a comprehensive "Update System" component supporting individual (FW/UI) and combined (Bundle) uploads.
- This UI section handles file selection, upload progress visualization, and system reboot confirmation, providing parity with the existing frontend update UX. - 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 ## 5. Summary
We use **ESP-IDF's native OTA APIs** with a **Factory + Dual A/B Partition** layout for maximum reliability. The system leverages **Automatic App Rollback** to prevent network lockouts from bad firmware. Versioning is natively controlled via the **CMake build descriptions**, and the entire update process is driven centrally from the **Svelte Frontend UI** via a Push-based REST endpoint. 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* *Created by Antigravity - Last Updated: 2026-03-03*