feat: Implement Svelte frontend with OTA update support, build tooling, and deployment documentation.

This commit is contained in:
2026-03-03 17:29:06 -05:00
parent c357c76af6
commit 046f353d7e
7 changed files with 218 additions and 100 deletions

View File

@@ -69,7 +69,7 @@ Once the backend supports it (Phase 2+), you can update the frontend without usi
1. **Build the assets**: `npm run build:esp32` 1. **Build the assets**: `npm run build:esp32`
2. **Package the image**: `npm run ota:package` 2. **Package the image**: `npm run ota:package`
- This generates a `frontend/bin/www.bin` file. - 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`: - **Configuration**: If the tool is not in your PATH, add its path to `frontend/.env`:
```env ```env
MKLITTLEFS_PATH=C:\path\to\mklittlefs.exe MKLITTLEFS_PATH=C:\path\to\mklittlefs.exe

View File

@@ -6,13 +6,13 @@
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { resolve, dirname } from 'path'; import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { existsSync, readFileSync, mkdirSync } from 'fs'; import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '..'); const projectRoot = resolve(__dirname, '..');
const distDir = resolve(projectRoot, 'dist'); const distDir = resolve(projectRoot, 'dist');
const binDir = resolve(projectRoot, 'bin'); const binDir = resolve(projectRoot, 'bin');
const outputFile = resolve(binDir, 'www.bin'); const versionFile = resolve(projectRoot, 'version.json');
// Ensure bin directory exists // Ensure bin directory exists
if (!existsSync(binDir)) { if (!existsSync(binDir)) {
@@ -26,6 +26,33 @@ const PAGE_SIZE = 256;
console.log('--- OTA Packaging ---'); 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 * Simple .env parser to load MKLITTLEFS_PATH without external dependencies
*/ */
@@ -51,6 +78,9 @@ function loadEnv() {
} }
loadEnv(); loadEnv();
const version = getVersion();
const versionStr = `${version.major}.${version.minor}.${version.revision}`;
const outputFile = resolve(binDir, `www_v${versionStr}.bin`);
if (!existsSync(distDir)) { if (!existsSync(distDir)) {
console.error('Error: dist/ directory not found. Run "npm run build:esp32" first.'); console.error('Error: dist/ directory not found. Run "npm run build:esp32" first.');
@@ -107,6 +137,9 @@ try {
console.log(`Running: ${cmd}`); console.log(`Running: ${cmd}`);
execSync(cmd, { stdio: 'inherit' }); execSync(cmd, { stdio: 'inherit' });
console.log('Success: www.bin created.'); console.log('Success: www.bin created.');
// Auto-increment for the next build
incrementVersion(version);
} catch (e) { } catch (e) {
console.error('Error during packaging:', e.message); console.error('Error during packaging:', e.message);
process.exit(1); process.exit(1);

View File

@@ -57,12 +57,8 @@
} }
} }
// Poll every 5 seconds
let pollInterval;
$effect(() => { $effect(() => {
fetchInfo(); fetchInfo();
pollInterval = setInterval(fetchInfo, 5000);
return () => clearInterval(pollInterval);
}); });
const infoItems = $derived([ const infoItems = $derived([
@@ -78,8 +74,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 -->
@@ -147,11 +143,11 @@
</div> </div>
</div> </div>
<!-- Device Control Section (Reboot + OTA) --> <!-- 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 mb-2"> <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">
@@ -169,11 +165,11 @@
Reboot Reboot
</button> </button>
</div> </div>
<!-- New OTA Update Component inside the Device Control block -->
<OTAUpdate />
</div> </div>
<!-- Frontend Info & OTA Section -->
<OTAUpdate />
<!-- Reboot Confirmation Modal --> <!-- Reboot Confirmation Modal -->
{#if showRebootConfirm} {#if showRebootConfirm}
<div <div
@@ -207,9 +203,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

@@ -15,6 +15,8 @@
}); });
let selectedFile = $state(null); let selectedFile = $state(null);
let showAdvanced = $state(false);
let isDragging = $state(false);
async function fetchStatus() { async function fetchStatus() {
status = "loading_status"; status = "loading_status";
@@ -35,10 +37,26 @@
function handleFileChange(event) { function handleFileChange(event) {
const files = event.target.files; const files = event.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
selectedFile = files[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 = ""; errorMsg = "";
} else { } else {
selectedFile = null; selectedFile = null;
errorMsg = "Please select a valid .bin file";
} }
} }
@@ -47,19 +65,17 @@
status = "uploading"; status = "uploading";
errorMsg = ""; errorMsg = "";
uploadProgress = 0; // The fetch API doesn't natively support upload progress well without XMLHttpRequest, so we emulate it visually. uploadProgress = 0;
// Emulate progress since doing it cleanly with fetch requires more boilerplate.
const progressInterval = setInterval(() => { const progressInterval = setInterval(() => {
if (uploadProgress < 90) uploadProgress += 5; if (uploadProgress < 90) uploadProgress += 5;
}, 500); }, 500);
try { try {
const result = await uploadOTAFrontend(selectedFile); await uploadOTAFrontend(selectedFile);
clearInterval(progressInterval); clearInterval(progressInterval);
uploadProgress = 100; uploadProgress = 100;
status = "success"; status = "success";
// The backend triggers a reboot after success. The main App.svelte polling will handle the reconnect.
} catch (e) { } catch (e) {
clearInterval(progressInterval); clearInterval(progressInterval);
uploadProgress = 0; uploadProgress = 0;
@@ -70,85 +86,116 @@
</script> </script>
{#if !IS_DEV} {#if !IS_DEV}
<div class="bg-bg-card border border-border rounded-xl p-5 mt-4"> <div class="bg-bg-card border border-border rounded-xl overflow-hidden mt-4">
<div class="mb-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"> <h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Frontend Update (OTA) Frontend Info
</h2> </h2>
<p class="text-xs text-text-secondary mt-1"> <button
Update the dashboard UI without flashing the entire firmware. onclick={() => showAdvanced = !showAdvanced}
</p> 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>
{#if status === "loading_status"} <div class="p-5 space-y-4">
<div class="text-sm text-text-secondary animate-pulse">Loading status...</div> <!-- Version & Slot Info -->
{:else} <div class="grid grid-cols-2 gap-4">
<!-- Status Display --> <div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50">
<div class="flex items-center gap-4 mb-4 text-sm bg-bg-primary/50 p-3 rounded-lg border border-border/50"> <div class="text-[10px] uppercase text-text-secondary font-bold mb-1">Version</div>
<div> <div class="text-sm font-mono text-accent">v{__APP_VERSION__}</div>
<span class="text-text-secondary">Active Partition:</span> </div>
<span class="font-mono font-medium ml-1 text-accent">{otaInfo.active_partition}</span> <div class="bg-bg-primary/30 p-3 rounded-lg border border-border/50">
</div> <div class="text-[10px] uppercase text-text-secondary font-bold mb-1">Active Slot</div>
<div> <div class="text-xs font-mono text-text-primary">
<span class="text-text-secondary">Next Target:</span> {otaInfo.active_partition}
<span class="font-mono font-medium ml-1 text-text-primary">{otaInfo.target_partition}</span> {#if otaInfo.partitions}
</div> <span class="text-text-secondary ml-1">
</div> ({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024 / 1024).toFixed(2)} MB free)
</span>
<!-- Upload Controls --> {/if}
<div class="w-full space-y-3"> </div>
{#if status === "success"} </div>
<div class="bg-success/10 border border-success/20 text-success p-3 rounded-lg text-sm flex items-center gap-2"> </div>
<span class="w-2 h-2 rounded-full bg-success"></span>
Update successful! The device is rebooting... {#if showAdvanced}
</div> <div class="pt-2 border-t border-border/50 space-y-3">
{:else} <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <h3 class="text-xs font-bold text-text-primary">OTA Upgrade</h3>
<input <div class="text-[10px] text-text-secondary">
type="file" Target: <span class="font-mono">{otaInfo.target_partition}</span>
accept=".bin" {#if otaInfo.partitions}
onchange={handleFileChange} <span class="ml-1">
disabled={status === "uploading"} ({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(2)} MB capacity)
class="block w-full text-sm text-text-secondary </span>
file:mr-4 file:py-2 file:px-4 {/if}
file:rounded-lg file:border-0 </div>
file:text-sm file:font-semibold
file:bg-accent/10 file:text-accent
hover:file:bg-accent/20 transition-colors
disabled:opacity-50"
/>
<button
onclick={handleUpload}
disabled={!selectedFile || status === "uploading"}
class="whitespace-nowrap px-4 py-2 text-sm font-medium rounded-lg transition-colors
bg-accent text-white border border-accent
hover:brightness-110
disabled:opacity-40 disabled:cursor-not-allowed"
>
{#if status === "uploading"}
Uploading...
{:else}
Flash to {otaInfo.target_partition}
{/if}
</button>
</div>
<!-- Progress or Error-->
{#if status === "uploading"}
<div class="w-full bg-border rounded-full h-1.5 mt-2 overflow-hidden">
<div
class="bg-accent h-1.5 rounded-full transition-all duration-300 ease-out"
style="width: {uploadProgress}%"
></div>
</div> </div>
{:else if status === "error"}
<p class="text-sm text-danger mt-2 bg-danger/10 p-2 rounded border border-danger/20"> {#if status === "success"}
{errorMsg} <div class="bg-success/10 border border-success/20 text-success p-3 rounded-lg text-xs flex items-center gap-2">
</p> <span class="w-1.5 h-1.5 rounded-full bg-success"></span>
{/if} Update successful! The device is rebooting...
{/if} </div>
</div> {:else}
{/if} <!-- 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> </div>
{/if} {/if}

View File

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

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

@@ -1,6 +1,8 @@
// SDK // SDK
#include "cJSON.h" #include "cJSON.h"
#include "esp_http_server.h" #include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_partition.h"
// Project // Project
#include "appstate.hpp" #include "appstate.hpp"
@@ -14,6 +16,37 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
cJSON *root = cJSON_CreateObject(); cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition); 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", cJSON_AddStringToObject(root, "active_partition",
g_Active_WWW_Partition == 0 ? "www_0" : "www_1"); g_Active_WWW_Partition == 0 ? "www_0" : "www_1");
cJSON_AddStringToObject(root, "target_partition", cJSON_AddStringToObject(root, "target_partition",