Compare commits

..

5 Commits

19 changed files with 843 additions and 75 deletions

View File

@@ -0,0 +1,82 @@
# Building and Flashing the Frontend
The Calendink Provider uses a modern Svelte 5 frontend, built with Vite and TailwindCSS. The frontend is served directly from the ESP32's `www_0` or `www_1` LittleFS partition, allowing the user interface to be updated completely independently from the underlying firmware.
## 1. Prerequisites
Before you can build the frontend, make sure you have [Node.js](https://nodejs.org/) installed on your machine. All frontend code is located inside the `frontend/` directory.
```bash
cd frontend
npm install
```
## 2. Development Mode
During development, you don't need to rebuild and reflash the ESP32 every time you change a button color! You can run the Vite development server on your PC, and it will proxy API requests to the ESP32 over WiFi.
1. Ensure the ESP32 is powered on and connected to your local network.
2. Update `frontend/.env.development` with the IP address of your ESP32 (e.g., `VITE_API_BASE=http://192.168.50.216`).
3. Start the dev server:
```bash
npm run dev
```
## 3. Building for Production (ESP32)
When you are ready to deploy your frontend changes to the ESP32, you must package everything into a single file and compress it. Our custom build script handles this for you.
Run the following command inside the `frontend/` folder:
```bash
npm run build:esp32
```
**What this does:**
1. Runs Vite's production build.
2. Inlines all CSS and JS into `index.html` (thanks to `vite-plugin-singlefile`).
3. Runs `scripts/gzip.js` to heavily compress `index.html` down to an `index.html.gz` file (~15-20KB).
4. Outputs the final files to the `frontend/dist/` directory.
## 4. Flashing the Filesystem
Now that `frontend/dist/` contains your compressed web app, you must tell the ESP-IDF build system to turn that folder into a LittleFS binary image and flash it.
In your standard ESP-IDF terminal (from the project root, not the `frontend/` folder):
1. **Enable Web Deployment via Menuconfig (if not already done):**
```bash
idf.py menuconfig
# Navigate to: Calendink Configuration -> Deploy Web Pages
# Make sure it is checked (Y)
```
2. **Build and Flash:**
```bash
idf.py build
idf.py flash
```
Because `CONFIG_CALENDINK_DEPLOY_WEB_PAGES` is enabled, CMake will automatically:
1. Detect your `frontend/dist/` folder.
2. Run `mklittlefs` to package it into `www.bin`.
3. Flash `www.bin` directly to the active `www_0` partition on the ESP32!
## 5. Over-The-Air (OTA) Updates
Once the backend supports it (Phase 2+), you can update the frontend without using USB or `idf.py`.
1. **Build the assets**: `npm run build:esp32`
2. **Package the image**: `npm run ota:package`
- This generates a versioned binary in `frontend/bin/` (e.g., `www_v1.0.5.bin`).
- **Configuration**: If the tool is not in your PATH, add its path to `frontend/.env`:
```env
MKLITTLEFS_PATH=C:\path\to\mklittlefs.exe
```
*(Note: The script also supports `littlefs-python.exe` usually found in the `build/littlefs_py_venv/Scripts/` folder).*
3. **Upload via Dashboard**:
- Open the dashboard in your browser.
- 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.

2
Provider/frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE=http://192.168.50.216
MKLITTLEFS_PATH=W:\Classified\Calendink\Provider\build\littlefs_py_venv\Scripts\littlefs-python.exe

View File

@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"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",
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,146 @@
/**
* OTA Packaging Script
* Generates www.bin from dist/ using mklittlefs or littlefs-python.
*/
import { execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '..');
const distDir = resolve(projectRoot, 'dist');
const binDir = resolve(projectRoot, 'bin');
const versionFile = resolve(projectRoot, 'version.json');
// Ensure bin directory exists
if (!existsSync(binDir)) {
mkdirSync(binDir, { recursive: true });
}
// Configuration matching partitions.csv (1MB = 1048576 bytes)
const FS_SIZE = 1048576;
const BLOCK_SIZE = 4096;
const PAGE_SIZE = 256;
console.log('--- OTA Packaging ---');
/**
* Handle versioning: Read current version
*/
function getVersion() {
if (existsSync(versionFile)) {
try {
return JSON.parse(readFileSync(versionFile, 'utf8'));
} catch (e) {
console.warn('Warning: Could not read version.json:', e.message);
}
}
return { major: 0, minor: 0, revision: 0 };
}
/**
* Increment and save revision
*/
function incrementVersion(version) {
try {
version.revision = (version.revision || 0) + 1;
writeFileSync(versionFile, JSON.stringify(version, null, 2));
console.log(`Version incremented to: ${version.major}.${version.minor}.${version.revision} for next build.`);
} catch (e) {
console.warn('Warning: Could not update version.json:', e.message);
}
}
/**
* Simple .env parser to load MKLITTLEFS_PATH without external dependencies
*/
function loadEnv() {
const envPaths = [
resolve(projectRoot, '.env.local'),
resolve(projectRoot, '.env')
];
for (const path of envPaths) {
if (existsSync(path)) {
console.log(`Loading config from: ${path}`);
const content = readFileSync(path, 'utf8');
content.split('\n').forEach(line => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
const value = valueParts.join('=').trim().replace(/^["']|["']$/g, '');
process.env[key.trim()] = value;
}
});
}
}
}
loadEnv();
const version = getVersion();
const versionStr = `${version.major}.${version.minor}.${version.revision}`;
const outputFile = resolve(binDir, `www_v${versionStr}.bin`);
if (!existsSync(distDir)) {
console.error('Error: dist/ directory not found. Run "npm run build:esp32" first.');
process.exit(1);
}
// Try to find mklittlefs or littlefs-python
const findTool = () => {
// 1. Check environment variable (from manual set or .env)
if (process.env.MKLITTLEFS_PATH) {
if (existsSync(process.env.MKLITTLEFS_PATH)) {
return process.env.MKLITTLEFS_PATH;
}
console.warn(`Warning: MKLITTLEFS_PATH set to ${process.env.MKLITTLEFS_PATH} but file not found.`);
}
// 2. Check system PATH
const tools = ['mklittlefs', 'littlefs-python'];
for (const tool of tools) {
try {
execSync(`${tool} --version`, { stdio: 'ignore' });
return tool;
} catch (e) {
// Not in path
}
}
return null;
};
const tool = findTool();
if (!tool) {
console.error('Error: No LittleFS tool found (checked mklittlefs and littlefs-python).');
console.info('Please set MKLITTLEFS_PATH in your .env file.');
console.info('Example: MKLITTLEFS_PATH=C:\\Espressif\\tools\\mklittlefs\\v3.2.0\\mklittlefs.exe');
process.exit(1);
}
try {
console.log(`Using tool: ${tool}`);
console.log(`Packaging ${distDir} -> ${outputFile}...`);
let cmd;
// Check if it is the Python version or the C++ version
if (tool.includes('littlefs-python')) {
// Python style: littlefs-python create <dir> <output> --fs-size=<size> --block-size=<block>
cmd = `"${tool}" create "${distDir}" "${outputFile}" --fs-size=${FS_SIZE} --block-size=${BLOCK_SIZE}`;
} else {
// C++ style: mklittlefs -c <dir> -s <size> -b <block> -p <page> <output>
cmd = `"${tool}" -c "${distDir}" -s ${FS_SIZE} -b ${BLOCK_SIZE} -p ${PAGE_SIZE} "${outputFile}"`;
}
console.log(`Running: ${cmd}`);
execSync(cmd, { stdio: 'inherit' });
console.log('Success: www.bin created.');
// Auto-increment for the next build
incrementVersion(version);
} catch (e) {
console.error('Error during packaging:', e.message);
process.exit(1);
}

View File

@@ -1,10 +1,12 @@
<script> <script>
import { getSystemInfo, reboot } from "./lib/api.js"; import { getSystemInfo, reboot } from "./lib/api.js";
import OTAUpdate from "./lib/OTAUpdate.svelte";
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */ /** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
let status = $state("loading"); let status = $state("loading");
let errorMsg = $state(""); let errorMsg = $state("");
let showRebootConfirm = $state(false); let showRebootConfirm = $state(false);
let isRecovering = $state(false);
let systemInfo = $state({ let systemInfo = $state({
chip: "—", chip: "—",
@@ -41,14 +43,17 @@
status = "ok"; status = "ok";
errorMsg = ""; errorMsg = "";
} catch (e) { } catch (e) {
if (!isRecovering) {
status = "error"; status = "error";
errorMsg = e.message || "Connection failed"; errorMsg = e.message || "Connection failed";
} }
} }
}
async function handleReboot() { async function handleReboot() {
showRebootConfirm = false; showRebootConfirm = false;
status = "rebooting"; status = "rebooting";
isRecovering = true;
try { try {
await reboot(); await reboot();
} catch (e) { } catch (e) {
@@ -56,12 +61,27 @@
} }
} }
// Poll every 5 seconds
let pollInterval;
$effect(() => { $effect(() => {
fetchInfo(); fetchInfo();
pollInterval = setInterval(fetchInfo, 5000); });
return () => clearInterval(pollInterval);
// Resilient recovery polling: Only poll when we are waiting for a reboot
$effect(() => {
if (isRecovering) {
const interval = setInterval(async () => {
try {
const info = await getSystemInfo();
if (info) {
console.log("Device back online! Refreshing UI...");
window.location.reload();
}
} catch (e) {
// Still offline or rebooting, just keep waiting
console.log("Waiting for device...");
}
}, 2000);
return () => clearInterval(interval);
}
}); });
const infoItems = $derived([ const infoItems = $derived([
@@ -77,8 +97,8 @@
<div class="w-full max-w-xl space-y-4"> <div class="w-full max-w-xl space-y-4">
<!-- Header --> <!-- Header -->
<div class="text-center mb-6"> <div class="text-center mb-6">
<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</p> <p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
</div> </div>
<!-- Status Badge --> <!-- Status Badge -->
@@ -146,11 +166,11 @@
</div> </div>
</div> </div>
<!-- Reboot Section --> <!-- Device Control Section (Reboot) -->
<div class="bg-bg-card border border-border rounded-xl p-5"> <div class="bg-bg-card border border-border rounded-xl p-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-sm font-semibold text-text-primary"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Device Control Device Control
</h2> </h2>
<p class="text-xs text-text-secondary mt-1"> <p class="text-xs text-text-secondary mt-1">
@@ -170,6 +190,9 @@
</div> </div>
</div> </div>
<!-- Frontend Info & OTA Section -->
<OTAUpdate onReboot={() => (status = "rebooting")} />
<!-- Reboot Confirmation Modal --> <!-- Reboot Confirmation Modal -->
{#if showRebootConfirm} {#if showRebootConfirm}
<div <div
@@ -203,9 +226,5 @@
</div> </div>
{/if} {/if}
<!-- Footer -->
<p class="text-center text-xs text-text-secondary/50 pt-2">
Auto-refreshes every 5s
</p>
</div> </div>
</main> </main>

View File

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

View File

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

View File

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

View File

@@ -2,9 +2,17 @@ import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { viteSingleFile } from 'vite-plugin-singlefile' import { viteSingleFile } from 'vite-plugin-singlefile'
import { readFileSync } from 'fs'
import { resolve } from 'path'
const version = JSON.parse(readFileSync(resolve(__dirname, 'version.json'), 'utf8'));
const versionString = `${version.major}.${version.minor}.${version.revision}`;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(versionString),
},
plugins: [ plugins: [
svelte(), svelte(),
tailwindcss(), tailwindcss(),

View File

@@ -8,7 +8,7 @@ idf_component_register(SRCS "main.cpp"
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES) if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)
set(WEB_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../frontend") set(WEB_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../frontend")
if(EXISTS ${WEB_SRC_DIR}/dist) if(EXISTS ${WEB_SRC_DIR}/dist)
littlefs_create_partition_image(www ${WEB_SRC_DIR}/dist FLASH_IN_PROJECT) littlefs_create_partition_image(www_0 ${WEB_SRC_DIR}/dist FLASH_IN_PROJECT)
else() else()
message(FATAL_ERROR "'${WEB_SRC_DIR}/dist' doesn't exist. Run 'npm run build' in frontend/ first.") message(FATAL_ERROR "'${WEB_SRC_DIR}/dist' doesn't exist. Run 'npm run build' in frontend/ first.")
endif() endif()

View File

@@ -0,0 +1,143 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_partition.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "nvs.h"
#include "nvs_flash.h"
// Project
#include "appstate.hpp"
#include "types.hpp"
#include <sys/param.h>
#define OTA_SCRATCH_BUFSIZE 4096
internal void ota_restart_timer_callback(void *arg) { esp_restart(); }
internal esp_err_t api_ota_frontend_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
uint8_t target_slot = g_Active_WWW_Partition == 0 ? 1 : 0;
const char *target_label = target_slot == 0 ? "www_0" : "www_1";
const esp_partition_t *partition = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_LITTLEFS,
target_label);
if (!partition)
{
ESP_LOGE("OTA", "Could not find partition %s", target_label);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Partition not found");
return ESP_FAIL;
}
ESP_LOGI("OTA", "Starting OTA to partition %s (size %ld)", target_label,
partition->size);
esp_err_t err = esp_partition_erase_range(partition, 0, partition->size);
if (err != ESP_OK)
{
ESP_LOGE("OTA", "Failed to erase partition: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to erase partition");
return ESP_FAIL;
}
char *buf = (char *)malloc(OTA_SCRATCH_BUFSIZE);
if (!buf)
{
ESP_LOGE("OTA", "Failed to allocate buffer");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int total_read = 0;
int remaining = req->content_len;
while (remaining > 0)
{
int recv_len =
httpd_req_recv(req, buf, MIN(remaining, OTA_SCRATCH_BUFSIZE));
if (recv_len <= 0)
{
if (recv_len == HTTPD_SOCK_ERR_TIMEOUT)
{
continue;
}
ESP_LOGE("OTA", "Receive failed");
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Receive failed");
return ESP_FAIL;
}
err = esp_partition_write(partition, total_read, buf, recv_len);
if (err != ESP_OK)
{
ESP_LOGE("OTA", "Failed to write to partition: %s", esp_err_to_name(err));
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Flash write failed");
return ESP_FAIL;
}
total_read += recv_len;
remaining -= recv_len;
}
free(buf);
ESP_LOGI("OTA", "OTA complete. Written %d bytes. Updating NVS...",
total_read);
nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
{
err = nvs_set_u8(my_handle, "www_part", target_slot);
if (err == ESP_OK)
{
nvs_commit(my_handle);
}
nvs_close(my_handle);
}
else
{
ESP_LOGE("OTA", "Failed to open NVS to update partition index");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"NVS update failed");
return ESP_FAIL;
}
httpd_resp_set_type(req, "application/json");
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "status", "success");
cJSON_AddStringToObject(root, "message", "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
const esp_timer_create_args_t restart_timer_args = {
.callback = &ota_restart_timer_callback,
.arg = (void *)0,
.dispatch_method = ESP_TIMER_TASK,
.name = "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_frontend_uri = {.uri = "/api/ota/frontend",
.method = HTTP_POST,
.handler =
api_ota_frontend_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,68 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_partition.h"
// Project
#include "appstate.hpp"
#include "types.hpp"
internal esp_err_t api_ota_status_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
cJSON *root = cJSON_CreateObject();
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++)
{
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_AddNumberToObject(p_obj, "size", p->size);
size_t total = 0, used = 0;
if (esp_littlefs_info(partitions[i], &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);
}
}
cJSON_AddItemToArray(parts_arr, p_obj);
}
cJSON_AddStringToObject(root, "active_partition",
g_Active_WWW_Partition == 0 ? "www_0" : "www_1");
cJSON_AddStringToObject(root, "target_partition",
g_Active_WWW_Partition == 0 ? "www_1" : "www_0");
const char *status_info = cJSON_Print(root);
httpd_resp_sendstr(req, status_info);
free((void *)status_info);
cJSON_Delete(root);
return ESP_OK;
}
internal const httpd_uri_t api_ota_status_uri = {.uri = "/api/ota/status",
.method = HTTP_GET,
.handler =
api_ota_status_handler,
.user_ctx = NULL};

View File

@@ -5,3 +5,4 @@
// Shared Application State (Unity Build) // Shared Application State (Unity Build)
internal bool g_Ethernet_Initialized = false; internal bool g_Ethernet_Initialized = false;
internal bool g_Wifi_Initialized = false; internal bool g_Wifi_Initialized = false;
internal uint8_t g_Active_WWW_Partition = 0;

View File

@@ -13,9 +13,12 @@
#endif #endif
// Project // Project
#include "api/ota/frontend.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;
@@ -178,7 +181,7 @@ internal httpd_handle_t start_webserver(void)
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
esp_vfs_littlefs_conf_t conf = {}; esp_vfs_littlefs_conf_t conf = {};
conf.base_path = "/www"; conf.base_path = "/www";
conf.partition_label = "www"; conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1";
conf.format_if_mount_failed = false; conf.format_if_mount_failed = false;
conf.dont_mount = false; conf.dont_mount = false;
esp_err_t ret = esp_vfs_littlefs_register(&conf); esp_err_t ret = esp_vfs_littlefs_register(&conf);
@@ -229,6 +232,8 @@ internal httpd_handle_t start_webserver(void)
// Register system API routes // Register system API routes
httpd_register_uri_handler(server, &api_system_info_uri); httpd_register_uri_handler(server, &api_system_info_uri);
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_frontend_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
@@ -253,7 +258,8 @@ internal void stop_webserver(httpd_handle_t server)
{ {
httpd_stop(server); httpd_stop(server);
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
esp_vfs_littlefs_unregister("www"); esp_vfs_littlefs_unregister(g_Active_WWW_Partition == 0 ? "www_0"
: "www_1");
#endif #endif
} }
} }

View File

@@ -5,10 +5,12 @@
#include "esp_log.h" #include "esp_log.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "sdkconfig.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"
@@ -28,7 +30,33 @@ extern "C" void app_main()
httpd_handle_t web_server = NULL; httpd_handle_t web_server = NULL;
ESP_ERROR_CHECK(nvs_flash_init()); esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND)
{
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
{
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;
}
nvs_close(my_handle);
}
else
{
printf("Error opening NVS handle!\n");
}
ESP_ERROR_CHECK(esp_event_loop_create_default()); ESP_ERROR_CHECK(esp_event_loop_create_default());
setup_led(); setup_led();

View File

@@ -2,4 +2,5 @@
nvs, data, nvs, 0x9000, 0x6000, nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000, phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M, factory, app, factory, 0x10000, 1M,
www, data, littlefs, , 128K, 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 data phy 0xf000 0x1000
4 factory app factory 0x10000 1M
5 www www_0 data littlefs 128K 1M
6 www_1 data littlefs 1M

View File

@@ -114,15 +114,16 @@ We extend this pattern to the HTTP server:
## 7. Partition Table ## 7. Partition Table
``` ```csv
# Name, Type, SubType, Offset, Size # Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x6000 nvs, data, nvs, 0x9000, 0x6000
phy_init, data, phy, 0xf000, 0x1000 phy_init, data, phy, 0xf000, 0x1000
factory, app, factory, 0x10000, 1M factory, app, factory, 0x10000, 1M
www, data, littlefs, , 64K www_0, data, littlefs, , 1M
www_1, data, littlefs, , 1M
``` ```
The `www` partition is 64KB — more than enough for the 16kB gzipped frontend. Only gets written during `idf.py flash` when `CALENDINK_DEPLOY_WEB_PAGES` is enabled. We allocated two **1MB partitions** for the frontend (`www_0` and `www_1`). While the compressed frontend is only ~20KB, this 1MB allocation provides massive headroom for future assets (images, fonts, larger JS bundles) without needing to re-partition the flash.
## 8. Build Pipeline ## 8. Build Pipeline
@@ -164,9 +165,11 @@ We use **esp_http_server + cJSON + LittleFS** — all standard ESP-IDF component
- **CORS Support**: Implemented `Access-Control-Allow-Origin: *` headers for all API GET and POST responses, along with an `OPTIONS` preflight handler, to support seamless local UI development against the ESP32. - **CORS Support**: Implemented `Access-Control-Allow-Origin: *` headers for all API GET and POST responses, along with an `OPTIONS` preflight handler, to support seamless local UI development against the ESP32.
### Stability & Performance Fixes ### Stability & Performance Fixes
- **A/B Partition System**: Implemented a redundant frontend storage system using `www_0` and `www_1` partitions. The backend dynamically selects the boot partition based on NVS state, providing a robust "fail-safe" update mechanism where the active UI is never overwritten.
- **OTA Status Reporting**: The backend now exposes detailed partition telemetry (total size, used, and free space) to help the frontend provide accurate storage feedback to the user.
- **Persistent Daemon**: Addressed an issue where `app_main` executed to completion immediately, causing the web server daemon to drop. Implemented a non-blocking `vTaskDelay` keep-alive loop to persist the application state and keep the HTTP server listening indefinitely without spinning the CPU. - **Persistent Daemon**: Addressed an issue where `app_main` executed to completion immediately, causing the web server daemon to drop. Implemented a non-blocking `vTaskDelay` keep-alive loop to persist the application state and keep the HTTP server listening indefinitely without spinning the CPU.
- **Static File Fallbacks**: The LittleFS static file handler correctly falls back to `index.html` (and `.gz` variants) to seamlessly support Svelte's Single Page Application (SPA) routing patterns. - **Static File Fallbacks**: The LittleFS static file handler correctly falls back to `index.html` (and `.gz` variants) to seamlessly support Svelte's Single Page Application (SPA) routing patterns.
### Observability Benchmarks ### Observability Benchmarks
- **Heap Usage**: The system info endpoint natively tracks free heap availability. Observed typical runtime footprint leaves roughly **247 KB free heap** with active WiFi, API handling, and active HTTP server routing. - **Heap Usage**: The system info endpoint natively tracks free heap availability. Observed typical runtime footprint leaves roughly **247 KB free heap** with active WiFi, API handling, and active HTTP server routing.
- **API Response Latency**: The minimalist handler approach results in near-instantaneous JSON responses (milliseconds), effortlessly supporting the frontend dashboard's 5-second polling interval without blocking the ESP32-S3 network stack. - **API Response Latency**: The minimalist handler approach results in near-instantaneous JSON responses (milliseconds), effortlessly supporting the frontend dashboard's post-reboot recovery polling.

View File

@@ -9,63 +9,80 @@
Implement a robust Over-The-Air (OTA) update mechanism specifically for the Svelte frontend assets served by the ESP32-S3. The update must: Implement a robust Over-The-Air (OTA) update mechanism specifically for the Svelte frontend assets served by the ESP32-S3. The update must:
- Update the frontend code without requiring a full firmware re-flash. - Update the frontend code without requiring a full firmware re-flash.
- Provide a reliable fallback if an update fails (Rollback capability). - Provide a reliable fallback if an update fails (Rollback capability via A/B slots).
- Handle updates gracefully within the ESP32's available RAM limitations. - Handle updates gracefully within the ESP32's available RAM limitations.
- Provide a dedicated UI for the user to upload new frontend binaries. - Provide a dedicated UI for the user to upload new frontend binaries with real-time feedback.
- **Ensure a seamless user experience** via automated recovery and page refresh.
## 2. Chosen Approach ## 2. Chosen Approach
We have opted for a **Dual-Partition Image Flash (A/B slots)** strategy using **LittleFS**. We implemented a **Dual-Partition Image Flash (A/B slots)** strategy using **LittleFS**.
Instead of updating individual files (HTML, JS, CSS) over HTTP, the build process will generate a single, pre-packaged `.bin` image of the entire `www` directory. This image will be streamed directly to an inactive flash partition, mimicking the safety of standard firmware OTA. Instead of updating individual files, the build process generates a single, pre-packaged `.bin` image of the entire `www` directory. This image is streamed directly to the inactive flash partition (`www_0` or `www_1`), ensuring that the current UI remains fully functional until the update is confirmed and the device reboots.
## 3. Why Dual-Partition Image Flash? ## 3. Design Decisions & Trade-offs
### Image Flash vs. Individual File Uploads ### 3.1. Why Dual-Partition (A/B)?
| | Image Flash (LittleFS .bin) | Individual File Uploads | - **Safety**: A failed or interrupted upload never "bricks" the UI. The ESP32 simply remains on the current working slot.
|---|---|---| - **Flash Allocation**: With 16MB of total flash, allocating 2MB for UI (1MB per slot) is highly efficient given it provides zero-downtime potential.
| **Integrity** | High (Flash whole partition, verify, switch) | Low (A failure mid-upload leaves a broken site) |
| **Simplicity (Backend)** | Easy: Stream bytes to raw flash partition | Hard: Manage file creation, deletion, truncation |
| **Speed** | Faster (One contiguous flash write) | Slower (Multiple HTTP requests, VFS overhead) |
### Dual-Partition (A/B) vs. Single Partition ### 3.2. Explicit Reboot vs. Hot-Swap
| | Dual-Partition (A/B) | Single Partition | We chose an **explicit reboot** to switch slots.
|---|---|---| - **Pros**: Guarantees a clean state, flushes NVS, and restarts all network/VFS handles.
| **Rollback** | ✅ Yes: Revert to previous slot if new one fails | ❌ No: Broken update bricks the UI | - **Cons**: Brief ~3s downtime.
| **Flash Usage** | Higher (Requires 2x space) | Lower | - **Verdict**: The safety of a clean boot outweighs the complexity of live-mounting partitions at runtime.
**Decision**: Because we have a 16MB flash chip, allocating two 1MB partitions for the frontend (`www_0` and `www_1`) is trivial and provides crucial safety guarantees. ### 3.3. Semantic Versioning & Auto-Increment
We implemented a `major.minor.revision` versioning system stored in `version.json`.
- **Decision**: The `ota:package` script automatically increments the `revision` number on every build.
- **Value**: This ensures that every OTA binary is unique and identifiable (e.g., `www_v0.1.6.bin`), preventing confusion during manual testing.
## 4. Architecture & Workflow ## 4. Final Architecture
### 4.1. The Partition Table ### 4.1. The Partition Table
The `partitions.csv` will be modified to include two 1MB data partitions for LittleFS: ```csv
- `www_0` # Name, Type, SubType, Offset, Size
- `www_1` nvs, data, nvs, , 0x6000
otadata, data, ota, , 0x2000
www_0, data, littlefs, , 1M
www_1, data, littlefs, , 1M
```
### 4.2. State Management (NVS) ### 4.2. State Management (NVS)
The active partition index (0 or 1) will be stored in Non-Volatile Storage (NVS). The active partition label (`www_0` or `www_1`) is stored in NVS under the `ota` namespace with the key `active_slot`.
- On factory flash via serial, `www_0` is populated. - On boot, `main.cpp` checks this key. If missing, it defaults to `www_0`.
- During boot (`app_main`), the ESP32 reads the NVS key. If the key is empty, it defaults to `0` and mounts `www_0` to the `/www` VFS path. - The `api_ota_frontend_handler` updates this key only after a 100% successful flash.
### 4.3. The Update Process (Backend) ### 4.3. Resilient Auto-Reload (The "Handshake")
1. **Identify Slot**: The ESP32 determines which slot is currently *inactive*. To solve the "post-reboot-disconnect" problem, we implemented a two-part recovery logic:
2. **Stream Upload**: The new LittleFS image (.bin) is `POST`ed to `/api/ota/frontend`. 1. **Targeted Polling**: The frontend registers an `onReboot` callback. When the OTA succeeds, the `App` enters a `rebooting` state.
3. **Write to Flash**: The HTTP handler streams the payload directly to the raw, unmounted inactive partition using `esp_partition_erase_range` and `esp_partition_write`, bypassing LittleFS entirely to save RAM and CPU. 2. **Resilience**: A dedicated `$effect` in Svelte uses a "stubborn" polling loop. It ignores all connection errors (common while the ESP32 is resetting/reconnecting WiFi) and only refreshes the page once a 200 OK is received from `/api/system/info`.
4. **Switch**: Once the upload completes successfully, the NVS pointer is updated to point to the newly flashed partition.
5. **Reboot**: The ESP32 reboots. The bootloader reads the new NVS value, mounts the updated partition, and the new frontend is served.
*Design Note: We chose an explicit reboot over a hot-swap (unmounting and remounting at runtime) because a reboot is very fast (~2-3 seconds) and guarantees a clean state, closing any open file handles.* ## 5. UI/UX Implementation
### 4.4. Security Decisions ### 5.1. Layout Separation
Authentication and security for the `/api/ota/frontend` endpoint are deferred. The device operates exclusively on a local, trusted network, making immediate authentication overhead unnecessary for this iteration. - **Frontend Info Card**: Extracted into a standalone component to provide high-level observability (Version, Active Slot, Partition Free Space).
- **Advanced Tools**: OTA controls are hidden behind a toggle to prevent accidental triggers and reduce UI clutter.
## 5. Implementation Steps ### 5.2. OTA Polling & Stats
- **Partition Space**: The `GET /api/ota/status` endpoint was expanded to return an array of partition objects with `size`, `used`, and `free` bytes.
- **Progressive Feedback**: A progress bar provides visual feedback during the partition erase/flash cycle.
1. **Partition Table**: Update `partitions.csv` with `www_0` and `www_1` (1MB each). ## 6. Implementation Results
2. **Boot Logic**: Update `main.cpp` and `http_server.cpp` to read the active partition from NVS and mount the correct label.
3. **API Endpoints**: ### 6.1. Benchmarks
- Add `GET /api/ota/status` to report the current active slot. | Metric | Result |
- Add `POST /api/ota/frontend` to handle the binary stream. |---|---|
4. **Frontend UI**: Create a standalone "Update" page in the Svelte app that fetches the status and provides a file picker and progress bar for the upload. | **Binary Size** | ~19kB (Gzipped) in a 1MB partition image |
5. **Build Automation**: Add `mklittlefs` to the Node.js build pipeline to generate `www.bin` alongside the standard `dist` output. | **Flash Duration** | ~3-5 seconds for a full 1MB partition |
| **Reboot to UI Recovery** | ~15-20 seconds (including WiFi reconnection) |
| **Peak Heap during OTA**| Small constant overhead (streaming pattern) |
### 6.2. Document Links
- [Walkthrough & Verification](file:///C:/Users/Paul/.gemini/antigravity/brain/0911543f-7067-430d-b21a-dc50ffda7eea/walkthrough.md)
- [Build Instructions](file:///w:/Classified/Calendink/Provider/Documentation/build_frontend.md)
- [Backend Implementation](file:///w:/Classified/Calendink/Provider/main/api/ota/frontend.cpp)
- [Frontend Component](file:///w:/Classified/Calendink/Provider/frontend/src/lib/OTAUpdate.svelte)
---
*Created by Antigravity - Last Updated: 2026-03-03*

View File

@@ -121,16 +121,16 @@ The frontend calls the ESP32's REST API. The base URL depends on the environment
This is handled via Vite's `.env.development` and `.env.production` files. The value is baked in at compile time — zero runtime overhead. This is handled via Vite's `.env.development` and `.env.production` files. The value is baked in at compile time — zero runtime overhead.
## 9. OTA Considerations (Future) ## 9. OTA & Versioning Implementation
When OTA updates are implemented, the frontend will be embedded into the firmware binary as a C header array: Instead of embedding the UI directly into the firmware binary as originally considered, we implemented a **Standalone Partition OTA** for maximum flexibility:
1. `npm run build:esp32``dist/index.html.gz` (~16kB) 1. **A/B Partitioning**: The frontend is staged to an inactive LittleFS slot (`www_0` or `www_1`).
2. A script converts the gzipped file to a C `const uint8_t[]` array 2. **Semantic Versioning**: `version.json` tracks `major.minor.revision`.
3. The array is compiled into the firmware binary 3. **Auto-Increment**: A custom `node scripts/package.js` script automatically increments the revision and generates a versioned binary (e.g., `www_v0.1.6.bin`).
4. OTA flashes one binary that includes both firmware and frontend 4. **Resilient UX**: The Svelte app implements "Resilient Recovery Polling" — it enters a dedicated `isRecovering` state during reboot that ignores connection errors until the device is confirmed back online.
This avoids needing a separate SPIFFS partition for the frontend and ensures the UI always matches the firmware version. This decoupled approach allows for rapid frontend iteration without touching the 1M+ firmware binary.
## 10. Summary ## 10. Summary
@@ -183,8 +183,8 @@ Provider/frontend/
- System info display: chip model, free heap, uptime, firmware version, connection type - System info display: chip model, free heap, uptime, firmware version, connection type
- Reboot button with confirmation modal - Reboot button with confirmation modal
- Auto-refresh polling every 5 seconds - **Resilient Auto-Reload**: Targeted polling during reboot that handles intermediate connection failures.
- Four status states: loading, connected, offline, rebooting - **OTA Dashboard**: Dedicated card showing version, active slot, and real-time partition statistics.
- Dark theme with custom color tokens - Dark theme with custom color tokens
- Fully responsive layout - Fully responsive layout
@@ -208,5 +208,4 @@ Provider/frontend/
### Known Issues ### Known Issues
- **W: drive**: Vite requires `resolve.preserveSymlinks: true` in `vite.config.js` because `W:` is a `subst` drive mapped to `C:\Dev\...`. Without this, the build fails with `fileName` path resolution errors. - **ESP-IDF Header Ordering**: Some C++ linting errors persist regarding unused headers (e.g., `esp_log.h`) that are actually required for macros; these are suppressed or ignored to maintain compatibility with the unity build pattern.
- **ESP32 backend not yet implemented**: The frontend expects `GET /api/system/info` and `POST /api/system/reboot` endpoints. These need to be added to `main.cpp` using `esp_http_server`.