From 7c537ed4dbae92bc041dbc0781a5e0136505800e Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 16:17:05 -0500 Subject: [PATCH 1/5] feat: Implement HTTP server with static file serving from LittleFS, system APIs, and network connectivity management. --- Provider/main/CMakeLists.txt | 2 +- Provider/main/appstate.hpp | 1 + Provider/main/http_server.cpp | 5 +++-- Provider/main/main.cpp | 30 +++++++++++++++++++++++++++++- Provider/partitions.csv | 3 ++- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Provider/main/CMakeLists.txt b/Provider/main/CMakeLists.txt index 8e06816..f7d65df 100644 --- a/Provider/main/CMakeLists.txt +++ b/Provider/main/CMakeLists.txt @@ -8,7 +8,7 @@ idf_component_register(SRCS "main.cpp" if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES) set(WEB_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../frontend") 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() message(FATAL_ERROR "'${WEB_SRC_DIR}/dist' doesn't exist. Run 'npm run build' in frontend/ first.") endif() diff --git a/Provider/main/appstate.hpp b/Provider/main/appstate.hpp index 4e007b9..5fb8584 100644 --- a/Provider/main/appstate.hpp +++ b/Provider/main/appstate.hpp @@ -5,3 +5,4 @@ // Shared Application State (Unity Build) internal bool g_Ethernet_Initialized = false; internal bool g_Wifi_Initialized = false; +internal uint8_t g_Active_WWW_Partition = 0; diff --git a/Provider/main/http_server.cpp b/Provider/main/http_server.cpp index 73ccd3b..8589b58 100644 --- a/Provider/main/http_server.cpp +++ b/Provider/main/http_server.cpp @@ -178,7 +178,7 @@ internal httpd_handle_t start_webserver(void) #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES esp_vfs_littlefs_conf_t conf = {}; 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.dont_mount = false; esp_err_t ret = esp_vfs_littlefs_register(&conf); @@ -253,7 +253,8 @@ internal void stop_webserver(httpd_handle_t server) { httpd_stop(server); #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 } } diff --git a/Provider/main/main.cpp b/Provider/main/main.cpp index 7e24118..31d033b 100644 --- a/Provider/main/main.cpp +++ b/Provider/main/main.cpp @@ -5,10 +5,12 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "nvs.h" #include "nvs_flash.h" #include "sdkconfig.h" #include "soc/gpio_num.h" + // Project headers #include "appstate.hpp" #include "types.hpp" @@ -28,7 +30,33 @@ extern "C" void app_main() 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()); setup_led(); diff --git a/Provider/partitions.csv b/Provider/partitions.csv index 707d6df..9c44e65 100644 --- a/Provider/partitions.csv +++ b/Provider/partitions.csv @@ -2,4 +2,5 @@ nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M, -www, data, littlefs, , 128K, +www_0, data, littlefs, , 1M, +www_1, data, littlefs, , 1M, -- 2.52.0 From eafb705eda68cf7b7ca353716370ecccf91a0d87 Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 16:33:01 -0500 Subject: [PATCH 2/5] feat: enabling web-based OTA updates and deployment for frontend --- Provider/Documentation/build_frontend.md | 64 +++++++++ Provider/frontend/src/App.svelte | 8 +- Provider/frontend/src/lib/OTAUpdate.svelte | 150 +++++++++++++++++++++ Provider/frontend/src/lib/api.js | 36 +++++ Provider/main/api/ota/frontend.cpp | 143 ++++++++++++++++++++ Provider/main/api/ota/status.cpp | 35 +++++ Provider/main/http_server.cpp | 5 + 7 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 Provider/Documentation/build_frontend.md create mode 100644 Provider/frontend/src/lib/OTAUpdate.svelte create mode 100644 Provider/main/api/ota/frontend.cpp create mode 100644 Provider/main/api/ota/status.cpp diff --git a/Provider/Documentation/build_frontend.md b/Provider/Documentation/build_frontend.md new file mode 100644 index 0000000..d998a5d --- /dev/null +++ b/Provider/Documentation/build_frontend.md @@ -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! diff --git a/Provider/frontend/src/App.svelte b/Provider/frontend/src/App.svelte index 9a2ae9b..d807e55 100644 --- a/Provider/frontend/src/App.svelte +++ b/Provider/frontend/src/App.svelte @@ -1,5 +1,6 @@ + +
+
+

+ Frontend Update (OTA) +

+

+ Update the dashboard UI without flashing the entire firmware. +

+
+ + {#if status === "loading_status"} +
Loading status...
+ {:else} + +
+
+ Active Partition: + {otaInfo.active_partition} +
+
+ Next Target: + {otaInfo.target_partition} +
+
+ + +
+ {#if status === "success"} +
+ + Update successful! The device is rebooting... +
+ {:else} +
+ + +
+ + + {#if status === "uploading"} +
+
+
+ {:else if status === "error"} +

+ {errorMsg} +

+ {/if} + {/if} +
+ {/if} +
+ diff --git a/Provider/frontend/src/lib/api.js b/Provider/frontend/src/lib/api.js index 1678453..9424822 100644 --- a/Provider/frontend/src/lib/api.js +++ b/Provider/frontend/src/lib/api.js @@ -37,3 +37,39 @@ export async function reboot() { } 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(); +} diff --git a/Provider/main/api/ota/frontend.cpp b/Provider/main/api/ota/frontend.cpp new file mode 100644 index 0000000..fc5ed90 --- /dev/null +++ b/Provider/main/api/ota/frontend.cpp @@ -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 + +#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}; diff --git a/Provider/main/api/ota/status.cpp b/Provider/main/api/ota/status.cpp new file mode 100644 index 0000000..8d03118 --- /dev/null +++ b/Provider/main/api/ota/status.cpp @@ -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}; diff --git a/Provider/main/http_server.cpp b/Provider/main/http_server.cpp index 8589b58..1877a05 100644 --- a/Provider/main/http_server.cpp +++ b/Provider/main/http_server.cpp @@ -13,9 +13,12 @@ #endif // Project +#include "api/ota/frontend.cpp" +#include "api/ota/status.cpp" #include "api/system/info.cpp" #include "api/system/reboot.cpp" + internal const char *TAG = "HTTP_SERVER"; constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1; @@ -229,6 +232,8 @@ internal httpd_handle_t start_webserver(void) // Register system API routes httpd_register_uri_handler(server, &api_system_info_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 // Register static file handler last as a catch-all wildcard if deployed -- 2.52.0 From c357c76af6fa7f08ef8a88c4f87e47d35e2f776b Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 16:57:29 -0500 Subject: [PATCH 3/5] OTA for frontend works. Created documentation to know how to do it, upload and voila. --- Provider/Documentation/build_frontend.md | 18 ++++ Provider/frontend/.env | 2 + Provider/frontend/package.json | 1 + Provider/frontend/scripts/package.js | 113 +++++++++++++++++++++ Provider/frontend/src/lib/OTAUpdate.svelte | 4 + 5 files changed, 138 insertions(+) create mode 100644 Provider/frontend/.env create mode 100644 Provider/frontend/scripts/package.js diff --git a/Provider/Documentation/build_frontend.md b/Provider/Documentation/build_frontend.md index d998a5d..33ab010 100644 --- a/Provider/Documentation/build_frontend.md +++ b/Provider/Documentation/build_frontend.md @@ -62,3 +62,21 @@ 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 `frontend/bin/www.bin` file. + - **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. diff --git a/Provider/frontend/.env b/Provider/frontend/.env new file mode 100644 index 0000000..808554a --- /dev/null +++ b/Provider/frontend/.env @@ -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 diff --git a/Provider/frontend/package.json b/Provider/frontend/package.json index b729be1..c4265e3 100644 --- a/Provider/frontend/package.json +++ b/Provider/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "build:esp32": "vite build && node scripts/gzip.js", + "ota:package": "node scripts/package.js", "preview": "vite preview" }, "devDependencies": { diff --git a/Provider/frontend/scripts/package.js b/Provider/frontend/scripts/package.js new file mode 100644 index 0000000..6024c88 --- /dev/null +++ b/Provider/frontend/scripts/package.js @@ -0,0 +1,113 @@ +/** + * 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 } 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'); + +// 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 ---'); + +/** + * 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(); + +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 --fs-size= --block-size= + cmd = `"${tool}" create "${distDir}" "${outputFile}" --fs-size=${FS_SIZE} --block-size=${BLOCK_SIZE}`; + } else { + // C++ style: mklittlefs -c -s -b -p + 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.'); +} catch (e) { + console.error('Error during packaging:', e.message); + process.exit(1); +} diff --git a/Provider/frontend/src/lib/OTAUpdate.svelte b/Provider/frontend/src/lib/OTAUpdate.svelte index 3a07522..de8e932 100644 --- a/Provider/frontend/src/lib/OTAUpdate.svelte +++ b/Provider/frontend/src/lib/OTAUpdate.svelte @@ -1,6 +1,8 @@ + {#if !IS_DEV}

@@ -147,4 +150,5 @@

{/if}
+ {/if} -- 2.52.0 From 046f353d7e4afc8c7ab66801cddaba086bd991ed Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 17:29:06 -0500 Subject: [PATCH 4/5] feat: Implement Svelte frontend with OTA update support, build tooling, and deployment documentation. --- Provider/Documentation/build_frontend.md | 2 +- Provider/frontend/scripts/package.js | 37 +++- Provider/frontend/src/App.svelte | 24 +-- Provider/frontend/src/lib/OTAUpdate.svelte | 209 +++++++++++++-------- Provider/frontend/version.json | 5 + Provider/frontend/vite.config.js | 8 + Provider/main/api/ota/status.cpp | 33 ++++ 7 files changed, 218 insertions(+), 100 deletions(-) create mode 100644 Provider/frontend/version.json diff --git a/Provider/Documentation/build_frontend.md b/Provider/Documentation/build_frontend.md index 33ab010..b29b728 100644 --- a/Provider/Documentation/build_frontend.md +++ b/Provider/Documentation/build_frontend.md @@ -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 diff --git a/Provider/frontend/scripts/package.js b/Provider/frontend/scripts/package.js index 6024c88..48ed395 100644 --- a/Provider/frontend/scripts/package.js +++ b/Provider/frontend/scripts/package.js @@ -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); diff --git a/Provider/frontend/src/App.svelte b/Provider/frontend/src/App.svelte index d807e55..7ba8160 100644 --- a/Provider/frontend/src/App.svelte +++ b/Provider/frontend/src/App.svelte @@ -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 @@
-

Calendink Provider

-

ESP32-S3 System Dashboard

+

Calendink Provider 🚀

+

ESP32-S3 System Dashboard v{__APP_VERSION__}

@@ -147,11 +143,11 @@
- +
-
+
-

+

Device Control

@@ -169,11 +165,11 @@ Reboot

- - -
+ + + {#if showRebootConfirm}
{/if} - -

- Auto-refreshes every 5s -

diff --git a/Provider/frontend/src/lib/OTAUpdate.svelte b/Provider/frontend/src/lib/OTAUpdate.svelte index de8e932..2d47572 100644 --- a/Provider/frontend/src/lib/OTAUpdate.svelte +++ b/Provider/frontend/src/lib/OTAUpdate.svelte @@ -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,85 +86,116 @@ {#if !IS_DEV} -
-
-

- Frontend Update (OTA) +
+
+

+ Frontend Info

-

- Update the dashboard UI without flashing the entire firmware. -

+
- - {#if status === "loading_status"} -
Loading status...
- {:else} - -
-
- Active Partition: - {otaInfo.active_partition} -
-
- Next Target: - {otaInfo.target_partition} -
-
- - -
- {#if status === "success"} -
- - Update successful! The device is rebooting... -
- {:else} -
- - -
- - - {#if status === "uploading"} -
-
+ +
+ +
+
+
Version
+
v{__APP_VERSION__}
+
+
+
Active Slot
+
+ {otaInfo.active_partition} + {#if otaInfo.partitions} + + ({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024 / 1024).toFixed(2)} MB free) + + {/if} +
+
+
+ + {#if showAdvanced} +
+
+

OTA Upgrade

+
+ Target: {otaInfo.target_partition} + {#if otaInfo.partitions} + + ({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(2)} MB capacity) + + {/if} +
- {:else if status === "error"} -

- {errorMsg} -

- {/if} - {/if} -
- {/if} + + {#if status === "success"} +
+ + Update successful! The device is rebooting... +
+ {:else} + +
{ e.preventDefault(); isDragging = true; }} + ondragleave={() => isDragging = false} + ondrop={handleDrop} + > + + +
📦
+ {#if selectedFile} +
{selectedFile.name}
+
{(selectedFile.size / 1024).toFixed(1)} KB
+ {:else} +
Drag & Drop .bin here
+
or click to browse
+ {/if} +
+ + {#if selectedFile} + + {/if} + + {#if status === "uploading"} +
+
+
+ {:else if status === "error"} +

+ {errorMsg} +

+ {/if} + {/if} +
+ {/if} +
{/if} diff --git a/Provider/frontend/version.json b/Provider/frontend/version.json new file mode 100644 index 0000000..daebab9 --- /dev/null +++ b/Provider/frontend/version.json @@ -0,0 +1,5 @@ +{ + "major": 0, + "minor": 1, + "revision": 4 +} \ No newline at end of file diff --git a/Provider/frontend/vite.config.js b/Provider/frontend/vite.config.js index b53b297..a46b566 100644 --- a/Provider/frontend/vite.config.js +++ b/Provider/frontend/vite.config.js @@ -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(), diff --git a/Provider/main/api/ota/status.cpp b/Provider/main/api/ota/status.cpp index 8d03118..8da18b0 100644 --- a/Provider/main/api/ota/status.cpp +++ b/Provider/main/api/ota/status.cpp @@ -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", -- 2.52.0 From 3260a041c9e4924c1ac05a9facea468bf47046fa Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 18:35:17 -0500 Subject: [PATCH 5/5] feat: Polishing the frontend ota. Updated tdds and documentation --- Provider/frontend/src/App.svelte | 29 +++++- Provider/frontend/src/lib/OTAUpdate.svelte | 2 + Provider/frontend/version.json | 2 +- Provider/tdd/backend_architecture.md | 11 ++- Provider/tdd/frontend_ota.md | 99 ++++++++++++--------- Provider/tdd/frontend_technology_choices.md | 21 +++-- 6 files changed, 104 insertions(+), 60 deletions(-) diff --git a/Provider/frontend/src/App.svelte b/Provider/frontend/src/App.svelte index 7ba8160..0ebfca6 100644 --- a/Provider/frontend/src/App.svelte +++ b/Provider/frontend/src/App.svelte @@ -6,6 +6,7 @@ let status = $state("loading"); let errorMsg = $state(""); let showRebootConfirm = $state(false); + let isRecovering = $state(false); let systemInfo = $state({ chip: "—", @@ -42,14 +43,17 @@ status = "ok"; errorMsg = ""; } catch (e) { - status = "error"; - errorMsg = e.message || "Connection failed"; + if (!isRecovering) { + status = "error"; + errorMsg = e.message || "Connection failed"; + } } } async function handleReboot() { showRebootConfirm = false; status = "rebooting"; + isRecovering = true; try { await reboot(); } catch (e) { @@ -61,6 +65,25 @@ fetchInfo(); }); + // 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([ { label: "Chip", value: systemInfo.chip, icon: "🔧" }, { label: "Free Heap", value: formatBytes(systemInfo.freeHeap), icon: "💾" }, @@ -168,7 +191,7 @@
- + (status = "rebooting")} /> {#if showRebootConfirm} diff --git a/Provider/frontend/src/lib/OTAUpdate.svelte b/Provider/frontend/src/lib/OTAUpdate.svelte index 2d47572..905537a 100644 --- a/Provider/frontend/src/lib/OTAUpdate.svelte +++ b/Provider/frontend/src/lib/OTAUpdate.svelte @@ -1,4 +1,5 @@