-
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}
+
-
(showRebootConfirm = true)}
- disabled={status === "rebooting" || status === "loading"}
- class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
- bg-danger/10 text-danger border border-danger/20
- hover:bg-danger/20 hover:border-danger/30
- disabled:opacity-40 disabled:cursor-not-allowed"
- >
- Reboot
-
-
-
-
-
(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
+
+
+
(showRebootConfirm = true)}
+ disabled={status === "rebooting" || status === "loading"}
+ class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
+ bg-danger/10 text-danger border border-danger/20
+ hover:bg-danger/20 hover:border-danger/30
+ disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ Reboot
+
+
+
+
+
+
{ 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.
Reboot Now
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
-
- 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'}
-
-
-
-
-
-
-
-
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}
-
- {status === "uploading" ? 'Processing Update...' : `Flash to ${otaInfo.target_partition}`}
-
- {/if}
-
- {#if status === "uploading"}
-
- {:else if status === "error"}
-
- {errorMsg}
-
- {/if}
- {/if}
-
- {/if}
+{#if !IS_DEV}
+
+
+
+ Updates & Maintenance
+
+
+ toggleMode('frontend')}
+ class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
+ {showAdvanced && updateMode === 'frontend' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
+ >
+ Frontend OTA
+
+ toggleMode('firmware')}
+ class="text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
+ {showAdvanced && updateMode === 'firmware' ? 'bg-accent text-white' : 'bg-border text-text-secondary hover:text-text-primary'}"
+ >
+ Firmware OTA
+
- {/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}
+
+ {status === "uploading" ? 'Flashing...' : `Update ${updateMode === 'frontend' ? 'UI' : 'Firmware'}`}
+
+ {/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();