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