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