feat: enabling web-based OTA updates and deployment for frontend

This commit is contained in:
2026-03-03 16:33:01 -05:00
parent 7c537ed4db
commit eafb705eda
7 changed files with 439 additions and 2 deletions

View File

@@ -0,0 +1,64 @@
# 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!

View File

@@ -1,5 +1,6 @@
<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");
@@ -146,9 +147,9 @@
</div> </div>
</div> </div>
<!-- Reboot Section --> <!-- Device Control Section (Reboot + OTA) -->
<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 mb-2">
<div> <div>
<h2 class="text-sm font-semibold text-text-primary"> <h2 class="text-sm font-semibold text-text-primary">
Device Control Device Control
@@ -168,6 +169,9 @@
Reboot Reboot
</button> </button>
</div> </div>
<!-- New OTA Update Component inside the Device Control block -->
<OTAUpdate />
</div> </div>
<!-- Reboot Confirmation Modal --> <!-- Reboot Confirmation Modal -->

View File

@@ -0,0 +1,150 @@
<script>
import { getOTAStatus, uploadOTAFrontend } from "./api.js";
/** @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);
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) {
selectedFile = files[0];
errorMsg = "";
} else {
selectedFile = null;
}
}
async function handleUpload() {
if (!selectedFile) return;
status = "uploading";
errorMsg = "";
uploadProgress = 0; // The fetch API doesn't natively support upload progress well without XMLHttpRequest, so we emulate it visually.
// 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);
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;
status = "error";
errorMsg = e.message;
}
}
</script>
<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)
</h2>
<p class="text-xs text-text-secondary mt-1">
Update the dashboard UI without flashing the entire firmware.
</p>
</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>
<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 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>
Update successful! The device is rebooting...
</div>
{:else}
<div class="flex items-center gap-3">
<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"
/>
<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>
{:else if status === "error"}
<p class="text-sm text-danger mt-2 bg-danger/10 p-2 rounded border border-danger/20">
{errorMsg}
</p>
{/if}
{/if}
</div>
{/if}
</div>

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,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,35 @@
// SDK
#include "cJSON.h"
#include "esp_http_server.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);
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

@@ -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;
@@ -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