feat: Implement Svelte frontend with OTA update support, build tooling, and deployment documentation.
This commit is contained in:
@@ -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`
|
||||
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`:
|
||||
```env
|
||||
MKLITTLEFS_PATH=C:\path\to\mklittlefs.exe
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { resolve, dirname } from 'path';
|
||||
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 projectRoot = resolve(__dirname, '..');
|
||||
const distDir = resolve(projectRoot, 'dist');
|
||||
const binDir = resolve(projectRoot, 'bin');
|
||||
const outputFile = resolve(binDir, 'www.bin');
|
||||
const versionFile = resolve(projectRoot, 'version.json');
|
||||
|
||||
// Ensure bin directory exists
|
||||
if (!existsSync(binDir)) {
|
||||
@@ -26,6 +26,33 @@ 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
|
||||
*/
|
||||
@@ -51,6 +78,9 @@ function loadEnv() {
|
||||
}
|
||||
|
||||
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.');
|
||||
@@ -107,6 +137,9 @@ try {
|
||||
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);
|
||||
|
||||
@@ -57,12 +57,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Poll every 5 seconds
|
||||
let pollInterval;
|
||||
$effect(() => {
|
||||
fetchInfo();
|
||||
pollInterval = setInterval(fetchInfo, 5000);
|
||||
return () => clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
const infoItems = $derived([
|
||||
@@ -78,8 +74,8 @@
|
||||
<div class="w-full max-w-xl space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-accent">Calendink Provider</h1>
|
||||
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard</p>
|
||||
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀</h1>
|
||||
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
@@ -147,11 +143,11 @@
|
||||
</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="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-text-primary">
|
||||
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Device Control
|
||||
</h2>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
@@ -169,11 +165,11 @@
|
||||
Reboot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New OTA Update Component inside the Device Control block -->
|
||||
<OTAUpdate />
|
||||
</div>
|
||||
|
||||
<!-- Frontend Info & OTA Section -->
|
||||
<OTAUpdate />
|
||||
|
||||
<!-- Reboot Confirmation Modal -->
|
||||
{#if showRebootConfirm}
|
||||
<div
|
||||
@@ -207,9 +203,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-center text-xs text-text-secondary/50 pt-2">
|
||||
Auto-refreshes every 5s
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
});
|
||||
|
||||
let selectedFile = $state(null);
|
||||
let showAdvanced = $state(false);
|
||||
let isDragging = $state(false);
|
||||
|
||||
async function fetchStatus() {
|
||||
status = "loading_status";
|
||||
@@ -35,10 +37,26 @@
|
||||
function handleFileChange(event) {
|
||||
const files = event.target.files;
|
||||
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 = "";
|
||||
} else {
|
||||
selectedFile = null;
|
||||
errorMsg = "Please select a valid .bin file";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,19 +65,17 @@
|
||||
|
||||
status = "uploading";
|
||||
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(() => {
|
||||
if (uploadProgress < 90) uploadProgress += 5;
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const result = await uploadOTAFrontend(selectedFile);
|
||||
await uploadOTAFrontend(selectedFile);
|
||||
clearInterval(progressInterval);
|
||||
uploadProgress = 100;
|
||||
status = "success";
|
||||
// The backend triggers a reboot after success. The main App.svelte polling will handle the reconnect.
|
||||
} catch (e) {
|
||||
clearInterval(progressInterval);
|
||||
uploadProgress = 0;
|
||||
@@ -70,79 +86,109 @@
|
||||
</script>
|
||||
|
||||
{#if !IS_DEV}
|
||||
<div class="bg-bg-card border border-border rounded-xl p-5 mt-4">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-sm font-semibold text-text-primary">
|
||||
Frontend Update (OTA)
|
||||
<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>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
Update the dashboard UI without flashing the entire firmware.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{#if status === "loading_status"}
|
||||
<div class="text-sm text-text-secondary animate-pulse">Loading status...</div>
|
||||
{:else}
|
||||
<!-- Status Display -->
|
||||
<div class="flex items-center gap-4 mb-4 text-sm bg-bg-primary/50 p-3 rounded-lg border border-border/50">
|
||||
<div>
|
||||
<span class="text-text-secondary">Active Partition:</span>
|
||||
<span class="font-mono font-medium ml-1 text-accent">{otaInfo.active_partition}</span>
|
||||
<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>
|
||||
<span class="text-text-secondary">Next Target:</span>
|
||||
<span class="font-mono font-medium ml-1 text-text-primary">{otaInfo.target_partition}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Controls -->
|
||||
<div class="w-full space-y-3">
|
||||
{#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-sm flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-success"></span>
|
||||
<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}
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 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}
|
||||
disabled={status === "uploading"}
|
||||
class="block w-full text-sm text-text-secondary
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-lg file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-accent/10 file:text-accent
|
||||
hover:file:bg-accent/20 transition-colors
|
||||
disabled:opacity-50"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
<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...
|
||||
|
||||
<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}
|
||||
Flash to {otaInfo.target_partition}
|
||||
<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}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress or Error-->
|
||||
{#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.5 mt-2 overflow-hidden">
|
||||
<div class="w-full bg-border rounded-full h-1 mt-1 overflow-hidden">
|
||||
<div
|
||||
class="bg-accent h-1.5 rounded-full transition-all duration-300 ease-out"
|
||||
class="bg-accent h-1 rounded-full transition-all duration-300"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else if status === "error"}
|
||||
<p class="text-sm text-danger mt-2 bg-danger/10 p-2 rounded border border-danger/20">
|
||||
<p class="text-[10px] text-danger mt-1 bg-danger/5 p-2 rounded border border-danger/10">
|
||||
{errorMsg}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -150,5 +196,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
5
Provider/frontend/version.json
Normal file
5
Provider/frontend/version.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"revision": 4
|
||||
}
|
||||
@@ -2,9 +2,17 @@ import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
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/
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(versionString),
|
||||
},
|
||||
plugins: [
|
||||
svelte(),
|
||||
tailwindcss(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SDK
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_littlefs.h"
|
||||
#include "esp_partition.h"
|
||||
|
||||
// Project
|
||||
#include "appstate.hpp"
|
||||
@@ -14,6 +16,37 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req)
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user