From fdb13d62d40f9cdb1e9a653f9c93b7d652287853 Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 22:17:58 -0500 Subject: [PATCH] feat: Implement web dashboard with system information, reboot, and OTA update functionality. --- Provider/frontend/src/App.svelte | 258 ++++++++++++-------- Provider/frontend/src/lib/OTAUpdate.svelte | 270 +++++++++++---------- Provider/frontend/src/lib/api.js | 23 +- Provider/frontend/version.json | 2 +- Provider/main/api/ota/firmware.cpp | 149 ++++++++++++ Provider/main/api/ota/status.cpp | 64 +++-- Provider/main/http_server.cpp | 3 +- Provider/main/main.cpp | 31 ++- 8 files changed, 547 insertions(+), 253 deletions(-) create mode 100644 Provider/main/api/ota/firmware.cpp diff --git a/Provider/frontend/src/App.svelte b/Provider/frontend/src/App.svelte index 0ebfca6..2579f88 100644 --- a/Provider/frontend/src/App.svelte +++ b/Provider/frontend/src/App.svelte @@ -1,5 +1,5 @@ -
-
+
+
-
-

Calendink Provider 🚀

+
+

Calendink Provider 🚀🚀🚀

ESP32-S3 System Dashboard v{__APP_VERSION__}

-
- - -
- {#if status === "loading"} -
- - Connecting to ESP32... -
- {:else if status === "ok"} -
- - Connected -
- {:else if status === "rebooting"} -
- - Rebooting... -
- {:else} -
- - Offline — {errorMsg} -
- {/if} -
- - -
-
-

- System Info -

-
-
- {#each infoItems as item} -
-
- {item.icon} - {item.label} -
- - {#if status === "loading"} - - {:else} - {item.value} - {/if} - + + +
+ {#if status === "loading"} +
+ + Connecting...
- {/each} + {:else if status === "ok"} +
+ + Connected +
+ {:else if status === "rebooting"} +
+ + Rebooting... +
+ {:else} +
+ + Offline — {errorMsg} +
+ {/if}
- -
-
-
-

- Device Control -

-

- Restart the ESP32 microcontroller -

+ +
+ + +
+ +
+
+

+ System Info +

+
+
+ {#each infoItems as item} +
+
+ {item.icon} + {item.label} +
+ + {#if status === "loading"} + + {:else} + {item.value} + {/if} + +
+ {/each} +
- -
-
- - (status = "rebooting")} /> + +
+
+

+ Partition Table +

+ Flash: 16MB +
+
+ {#if status === "loading"} +
Loading memory layout...
+ {:else} + {#each otaStatus.partitions as part} +
+
+
+ + {part.label} + + {#if part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label} + Active + {/if} +
+ + Type {part.type} / Sub {part.subtype} + +
+
+
{formatBytes(part.size)}
+ {#if part.app_version} +
v{part.app_version}
+ {:else if part.free !== undefined} +
+ {formatBytes(part.free)} free +
+ {/if} +
+
+ {/each} + {/if} +
+
+
+ + +
+ +
+
+
+

+ Device Control +

+

+ Restart the ESP32 microcontroller +

+
+ +
+
+ + + { status = "rebooting"; isRecovering = true; }} /> +
+ +
{#if showRebootConfirm} -
-
-

- Confirm Reboot -

+
+
+

Confirm Reboot

- Are you sure you want to reboot the ESP32? The device will be - temporarily unavailable. + Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.

diff --git a/Provider/frontend/src/lib/OTAUpdate.svelte b/Provider/frontend/src/lib/OTAUpdate.svelte index 905537a..f3495b1 100644 --- a/Provider/frontend/src/lib/OTAUpdate.svelte +++ b/Provider/frontend/src/lib/OTAUpdate.svelte @@ -1,28 +1,36 @@ + + function toggleMode(mode) { + if (showAdvanced && updateMode === mode) { + showAdvanced = false; + } else { + showAdvanced = true; + updateMode = mode; + selectedFile = null; + errorMsg = ""; + } + } + + const currentTarget = $derived(() => { + if (updateMode === 'frontend') return otaInfo.target_partition; + // For firmware, target is the slot that is NOT the running one + const runningLabel = otaInfo.running_firmware_label || 'ota_0'; + return runningLabel === 'ota_0' ? 'ota_1' : 'ota_0'; + }); + - {#if !IS_DEV} -
-
-

- Frontend Info -

- -
- -
- -
-
-
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} -
-
- - {#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 !IS_DEV} +
+
+

+ Updates & Maintenance +

+
+ +
- {/if} + +
+
+
+
UI Version
+
v{__APP_VERSION__}
+
+ Slot: {otaInfo.active_partition} + {#if otaInfo.partitions} + ({(otaInfo.partitions.find(p => p.label === otaInfo.active_partition)?.free / 1024).toFixed(0)} KB free) + {/if} +
+
+
+
FW Version
+
{systemInfo.firmware}
+
+ Active: {otaInfo.active_slot === 0 ? 'ota_0' : 'ota_1'} +
+
+
+ + {#if showAdvanced} +
+
+

+ OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'}) +

+
+ Target: {currentTarget()} + {#if updateMode === 'frontend' && otaInfo.partitions} + + ({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB) + + {/if} +
+
+ + {#if status === "success"} +
+ + Update successful! Rebooting device... +
+ {:else} +
{ e.preventDefault(); isDragging = true; }} + ondragleave={() => isDragging = false} + ondrop={handleDrop} + > + +
{updateMode === 'frontend' ? '🎨' : '⚙️'}
+ {#if selectedFile} +
{selectedFile.name}
+
{(selectedFile.size / 1024).toFixed(1)} KB
+ {:else} +
Drop {updateMode} .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/src/lib/api.js b/Provider/frontend/src/lib/api.js index 9424822..6a7a45d 100644 --- a/Provider/frontend/src/lib/api.js +++ b/Provider/frontend/src/lib/api.js @@ -40,7 +40,7 @@ export async function reboot() { /** * Fetch OTA status from the ESP32. - * @returns {Promise<{active_slot: number, active_partition: string, target_partition: string}>} + * @returns {Promise<{active_slot: number, active_partition: string, target_partition: string, partitions: any[], running_firmware_label: string, running_firmware_slot: number}>} */ export async function getOTAStatus() { const res = await fetch(`${API_BASE}/api/ota/status`); @@ -73,3 +73,24 @@ export async function uploadOTAFrontend(file) { return res.json(); } +/** + * Upload a new firmware binary image. + * @param {File} file The firmware binary file to upload. + * @returns {Promise<{status: string, message: string}>} + */ +export async function uploadOTAFirmware(file) { + const res = await fetch(`${API_BASE}/api/ota/firmware`, { + method: 'POST', + body: file, + headers: { + '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/frontend/version.json b/Provider/frontend/version.json index c44a581..df5bc4c 100644 --- a/Provider/frontend/version.json +++ b/Provider/frontend/version.json @@ -1,5 +1,5 @@ { "major": 0, "minor": 1, - "revision": 7 + "revision": 12 } \ No newline at end of file diff --git a/Provider/main/api/ota/firmware.cpp b/Provider/main/api/ota/firmware.cpp new file mode 100644 index 0000000..8897f54 --- /dev/null +++ b/Provider/main/api/ota/firmware.cpp @@ -0,0 +1,149 @@ +// SDK +#include "cJSON.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "esp_timer.h" +#include + +// Project +#include "appstate.hpp" +#include "types.hpp" + +#define OTA_FIRMWARE_SCRATCH_BUFSIZE 4096 + +internal void firmware_ota_restart_timer_callback(void *arg) { esp_restart(); } + +internal esp_err_t api_ota_firmware_handler(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + + const esp_partition_t *update_partition = + esp_ota_get_next_update_partition(NULL); + if (update_partition == NULL) + { + ESP_LOGE("OTA_FW", "Passive OTA partition not found"); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "OTA partition not found"); + return ESP_FAIL; + } + + ESP_LOGI("OTA_FW", "Writing to partition subtype %d at offset 0x%lx", + update_partition->subtype, update_partition->address); + + esp_ota_handle_t update_handle = 0; + esp_err_t err = + esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); + if (err != ESP_OK) + { + ESP_LOGE("OTA_FW", "esp_ota_begin failed (%s)", esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "OTA begin failed"); + return ESP_FAIL; + } + + char *buf = (char *)malloc(OTA_FIRMWARE_SCRATCH_BUFSIZE); + if (!buf) + { + ESP_LOGE("OTA_FW", "Failed to allocate buffer"); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory"); + return ESP_FAIL; + } + + int binary_file_len = 0; + int remaining = req->content_len; + + while (remaining > 0) + { + int recv_len = + httpd_req_recv(req, buf, MIN(remaining, OTA_FIRMWARE_SCRATCH_BUFSIZE)); + if (recv_len <= 0) + { + if (recv_len == HTTPD_SOCK_ERR_TIMEOUT) + { + continue; + } + ESP_LOGE("OTA_FW", "Receive failed"); + esp_ota_abort(update_handle); + free(buf); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Receive failed"); + return ESP_FAIL; + } + + err = esp_ota_write(update_handle, (const void *)buf, recv_len); + if (err != ESP_OK) + { + ESP_LOGE("OTA_FW", "esp_ota_write failed (%s)", esp_err_to_name(err)); + esp_ota_abort(update_handle); + free(buf); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Flash write failed"); + return ESP_FAIL; + } + + binary_file_len += recv_len; + remaining -= recv_len; + } + + free(buf); + ESP_LOGI("OTA_FW", "Total binary data written: %d", binary_file_len); + + err = esp_ota_end(update_handle); + if (err != ESP_OK) + { + if (err == ESP_ERR_OTA_VALIDATE_FAILED) + { + ESP_LOGE("OTA_FW", "Image validation failed, image is corrupted"); + } + else + { + ESP_LOGE("OTA_FW", "esp_ota_end failed (%s)!", esp_err_to_name(err)); + } + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "OTA validation/end failed"); + return ESP_FAIL; + } + + err = esp_ota_set_boot_partition(update_partition); + if (err != ESP_OK) + { + ESP_LOGE("OTA_FW", "esp_ota_set_boot_partition failed (%s)!", + esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Failed to set boot partition"); + return ESP_FAIL; + } + + ESP_LOGI("OTA_FW", "OTA successful, rebooting..."); + + httpd_resp_set_type(req, "application/json"); + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "status", "success"); + cJSON_AddStringToObject(root, "message", + "Firmware 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 with 1s delay + const esp_timer_create_args_t restart_timer_args = { + .callback = &firmware_ota_restart_timer_callback, + .arg = (void *)0, + .dispatch_method = ESP_TIMER_TASK, + .name = "fw_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_firmware_uri = {.uri = "/api/ota/firmware", + .method = HTTP_POST, + .handler = + api_ota_firmware_handler, + .user_ctx = NULL}; diff --git a/Provider/main/api/ota/status.cpp b/Provider/main/api/ota/status.cpp index dda8fe7..baa9b48 100644 --- a/Provider/main/api/ota/status.cpp +++ b/Provider/main/api/ota/status.cpp @@ -4,6 +4,7 @@ #include "cJSON.h" #include "esp_http_server.h" #include "esp_littlefs.h" +#include "esp_ota_ops.h" #include "esp_partition.h" // Project @@ -20,36 +21,53 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req) cJSON_AddNumberToObject(root, "active_slot", g_Active_WWW_Partition); - constexpr const char *kPartitions[] = {"www_0", "www_1", "ota_0", "ota_1", - "factory"}; - constexpr size_t kPartitionCount = ArrayCount(kPartitions); cJSON *parts_arr = cJSON_AddArrayToObject(root, "partitions"); - for (size_t i = 0; i < kPartitionCount; i++) + esp_partition_iterator_t it = esp_partition_find( + ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it != NULL) { + const esp_partition_t *p = esp_partition_get(it); cJSON *p_obj = cJSON_CreateObject(); - cJSON_AddStringToObject(p_obj, "label", kPartitions[i]); + cJSON_AddStringToObject(p_obj, "label", p->label); + cJSON_AddNumberToObject(p_obj, "type", p->type); + cJSON_AddNumberToObject(p_obj, "subtype", p->subtype); + cJSON_AddNumberToObject(p_obj, "address", p->address); + cJSON_AddNumberToObject(p_obj, "size", p->size); - const esp_partition_t *p = esp_partition_find_first( - ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, kPartitions[i]); - if (p) + // Try to get LittleFS info if it's a data partition + if (p->type == ESP_PARTITION_TYPE_DATA) { - cJSON_AddNumberToObject(p_obj, "size", p->size); - size_t total = 0, used = 0; - if (esp_littlefs_info(kPartitions[i], &total, &used) == ESP_OK) + if (esp_littlefs_info(p->label, &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); + // For other data partitions (nvs, phy_init), just show total as used + // for now + cJSON_AddNumberToObject(p_obj, "used", p->size); + cJSON_AddNumberToObject(p_obj, "free", 0); } } + // For app partitions, try to find the binary size + else if (p->type == ESP_PARTITION_TYPE_APP) + { + esp_app_desc_t app_desc; + if (esp_ota_get_partition_description(p, &app_desc) == ESP_OK) + { + // This is a bit of a hack as we don't have a direct "binary size" in + // the header but we can at least show it's occupied. For simplicity, if + // it's a valid app, we'll mark some space as used. Actually, without a + // better way to get the exact bin size, we'll just show it's an App. + cJSON_AddStringToObject(p_obj, "app_version", app_desc.version); + } + } + cJSON_AddItemToArray(parts_arr, p_obj); + it = esp_partition_next(it); } cJSON_AddStringToObject(root, "active_partition", @@ -57,6 +75,24 @@ internal esp_err_t api_ota_status_handler(httpd_req_t *req) cJSON_AddStringToObject(root, "target_partition", g_Active_WWW_Partition == 0 ? "www_1" : "www_0"); + const esp_partition_t *running = esp_ota_get_running_partition(); + if (running) + { + cJSON_AddStringToObject(root, "running_firmware_label", running->label); + if (running->subtype >= ESP_PARTITION_SUBTYPE_APP_OTA_MIN && + running->subtype <= ESP_PARTITION_SUBTYPE_APP_OTA_MAX) + { + cJSON_AddNumberToObject(root, "running_firmware_slot", + running->subtype - + ESP_PARTITION_SUBTYPE_APP_OTA_MIN); + } + else + { + cJSON_AddNumberToObject(root, "running_firmware_slot", + -1); // Factory or other + } + } + const char *status_info = cJSON_Print(root); httpd_resp_sendstr(req, status_info); diff --git a/Provider/main/http_server.cpp b/Provider/main/http_server.cpp index 1877a05..e0f75f1 100644 --- a/Provider/main/http_server.cpp +++ b/Provider/main/http_server.cpp @@ -13,12 +13,12 @@ #endif // Project +#include "api/ota/firmware.cpp" #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; @@ -234,6 +234,7 @@ internal httpd_handle_t start_webserver(void) 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); + httpd_register_uri_handler(server, &api_ota_firmware_uri); #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES // Register static file handler last as a catch-all wildcard if deployed diff --git a/Provider/main/main.cpp b/Provider/main/main.cpp index 31d033b..a20e95a 100644 --- a/Provider/main/main.cpp +++ b/Provider/main/main.cpp @@ -3,14 +3,14 @@ // SDK #include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.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" @@ -26,7 +26,7 @@ internal constexpr bool kBlockUntilEthernetEstablished = false; extern "C" void app_main() { - printf("Hello, Worldi!\n"); + printf("Hello, Calendink OTA! [V1.1]\n"); httpd_handle_t web_server = NULL; @@ -41,11 +41,27 @@ extern "C" void app_main() 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) + // If we are running from the factory partition, force the www partition to + // 0 This ensures that after a USB flash (which only writes to www_0), we + // aren't stuck looking at an old www_1. + const esp_partition_t *running = esp_ota_get_running_partition(); + if (running && running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY) { - printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err)); + printf( + "Running from factory: resetting www_part to 0 for consistency.\n"); + g_Active_WWW_Partition = 0; + nvs_set_u8(my_handle, "www_part", 0); + nvs_commit(my_handle); } + else + { + 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; @@ -151,6 +167,9 @@ extern "C" void app_main() printf("Connected!\n"); + // Mark the current app as valid to cancel rollback + esp_ota_mark_app_valid_cancel_rollback(); + // Start the webserver web_server = start_webserver();