From 2916ad9c991c3c55487d5096df2a87f930562616 Mon Sep 17 00:00:00 2001 From: Patedam Date: Tue, 3 Mar 2026 01:17:31 -0500 Subject: [PATCH] Squashed commit of the following: commit e8b53dc953d36db9d1a5a3b79e8334d89d1f88af Author: Patedam Date: Tue Mar 3 01:15:17 2026 -0500 Updated backend to make sure it works properly with frontend. Fixed one frontend issue (free heap was not correctly named) commit 3428808f83451b06b18bac312fcef82915e4a137 Author: Patedam Date: Tue Mar 3 00:36:01 2026 -0500 Fixing various build errors. Activated minimal build commit 59364ac22d420fa63af60d31bf711b3a1445d55e Author: Patedam Date: Tue Mar 3 00:03:24 2026 -0500 Added info and reboot api into the backend. Created the basics for a backend server. commit 37291557eb1aee070fca32965579ac316bdd931d Author: Patedam Date: Mon Mar 2 23:05:10 2026 -0500 feat: Add API endpoints for system reboot and retrieving system information. commit a010b0c352603f0aff824bcf6b8648435bcedae8 Author: Patedam Date: Mon Mar 2 22:56:13 2026 -0500 Added AgentTasks into git ignore we will use it to store current tasks so we can createnew contexts chat when its too big commit 75bab781370ada1aac72008a0355e1ecbe5757c1 Author: Patedam Date: Mon Mar 2 22:42:29 2026 -0500 feat: Initialize ESP-IDF project with core build configuration, component dependencies, and web frontend deployment. commit bba4c63f9364c80e9a442eb34e7bf02438e28e2e Author: Patedam Date: Mon Mar 2 22:21:52 2026 -0500 docs: Add backend architecture documentation for the ESP32-S3 provider. --- .gitignore | 3 + Provider/CMakeLists.txt | 1 + Provider/dependencies.lock | 13 +- Provider/frontend/.env.development | 2 +- Provider/frontend/src/lib/api.js | 6 +- Provider/main/CMakeLists.txt | 15 ++- Provider/main/Kconfig.projbuild | 18 +++ Provider/main/api/system/info.cpp | 66 ++++++++++ Provider/main/api/system/reboot.cpp | 43 +++++++ Provider/main/appstate.hpp | 7 + Provider/main/http_server.cpp | 183 +++++++++++++++++++++++++++ Provider/main/idf_component.yml | 1 + Provider/main/main.cpp | 59 +++++---- Provider/partitions.csv | 5 + Provider/sdkconfig.defaults | 3 + Provider/tdd/backend_architecture.md | 172 +++++++++++++++++++++++++ 16 files changed, 571 insertions(+), 26 deletions(-) create mode 100644 Provider/main/api/system/info.cpp create mode 100644 Provider/main/api/system/reboot.cpp create mode 100644 Provider/main/appstate.hpp create mode 100644 Provider/main/http_server.cpp create mode 100644 Provider/partitions.csv create mode 100644 Provider/tdd/backend_architecture.md diff --git a/.gitignore b/.gitignore index 7a445ba..a5c6fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ external/* # Frontend **/node_modules/ **/frontend/dist/ + +# Agent Tasks +Provider/AgentTasks/ diff --git a/Provider/CMakeLists.txt b/Provider/CMakeLists.txt index 4354294..a502184 100644 --- a/Provider/CMakeLists.txt +++ b/Provider/CMakeLists.txt @@ -3,4 +3,5 @@ cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) +idf_build_set_property(MINIMAL_BUILD ON) project(Provider) diff --git a/Provider/dependencies.lock b/Provider/dependencies.lock index c54b014..b89a4f1 100644 --- a/Provider/dependencies.lock +++ b/Provider/dependencies.lock @@ -125,10 +125,21 @@ dependencies: source: type: idf version: 5.5.3 + joltwallet/littlefs: + component_hash: dcea25bcef2de023f089f5f01e8d8c46ad1b8ffef75861ad5ffb4098555839df + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.20.4 direct_dependencies: - espressif/ethernet_init - espressif/led_strip - idf -manifest_hash: ca3e63d48140ce7f8993b19863499b13d6162b34a6fa4d0557513b244fc7a7e3 +- joltwallet/littlefs +manifest_hash: 21816aafdbbde14bfaaaabda34966eec49ae1e6f551bc16fe3ff74370b0fb54c target: esp32s3 version: 2.0.0 diff --git a/Provider/frontend/.env.development b/Provider/frontend/.env.development index 36e66ab..4053c9e 100644 --- a/Provider/frontend/.env.development +++ b/Provider/frontend/.env.development @@ -1,3 +1,3 @@ # Set this to your ESP32's IP address for local development # Example: VITE_API_BASE=http://192.168.1.100 -VITE_API_BASE=http://ESP32_IP_HERE +VITE_API_BASE=http://192.168.50.216 diff --git a/Provider/frontend/src/lib/api.js b/Provider/frontend/src/lib/api.js index ec60df5..1678453 100644 --- a/Provider/frontend/src/lib/api.js +++ b/Provider/frontend/src/lib/api.js @@ -17,7 +17,11 @@ export async function getSystemInfo() { if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } - return res.json(); + const data = await res.json(); + return { + ...data, + freeHeap: data.free_heap + }; } /** diff --git a/Provider/main/CMakeLists.txt b/Provider/main/CMakeLists.txt index 5ebf314..8e06816 100644 --- a/Provider/main/CMakeLists.txt +++ b/Provider/main/CMakeLists.txt @@ -1,2 +1,15 @@ idf_component_register(SRCS "main.cpp" - INCLUDE_DIRS ".") + # Needed as we use minimal build + PRIV_REQUIRES esp_http_server esp_eth + esp_wifi nvs_flash esp_netif vfs + json app_update esp_timer + INCLUDE_DIRS ".") + +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) + else() + message(FATAL_ERROR "'${WEB_SRC_DIR}/dist' doesn't exist. Run 'npm run build' in frontend/ first.") + endif() +endif() diff --git a/Provider/main/Kconfig.projbuild b/Provider/main/Kconfig.projbuild index 10f83d8..792b3c1 100644 --- a/Provider/main/Kconfig.projbuild +++ b/Provider/main/Kconfig.projbuild @@ -25,3 +25,21 @@ menu "CalendarInk Network Configuration" Number of times to retry the WiFi connection before failing completely. endmenu + +menu "Calendink Web Server" + + config CALENDINK_DEPLOY_WEB_PAGES + bool "Deploy web pages to device's LittleFS" + default n + help + If enabled, the frontend dist/ folder will be flashed + to the 'www' LittleFS partition during build. + Disable for fast backend-only iteration. + + config CALENDINK_WEB_MOUNT_POINT + string "Website mount point in VFS" + default "/www" + help + VFS path where the LittleFS partition is mounted. + +endmenu diff --git a/Provider/main/api/system/info.cpp b/Provider/main/api/system/info.cpp new file mode 100644 index 0000000..a637204 --- /dev/null +++ b/Provider/main/api/system/info.cpp @@ -0,0 +1,66 @@ +// SDK +#include "cJSON.h" +#include "esp_app_format.h" +#include "esp_chip_info.h" +#include "esp_http_server.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "esp_timer.h" + +// Project +#include "appstate.hpp" +#include "types.hpp" + +internal esp_err_t api_system_info_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(); + + esp_chip_info_t chip_info; + esp_chip_info(&chip_info); + + const char *model = "ESP32-S3"; + if (chip_info.model == CHIP_ESP32) { + model = "ESP32"; + } else if (chip_info.model == CHIP_ESP32S2) { + model = "ESP32-S2"; + } else if (chip_info.model == CHIP_ESP32S3) { + model = "ESP32-S3"; + } else if (chip_info.model == CHIP_ESP32C3) { + model = "ESP32-C3"; + } + + cJSON_AddStringToObject(root, "chip", model); + + uint32_t free_heap = esp_get_free_heap_size(); + cJSON_AddNumberToObject(root, "free_heap", free_heap); + + uint64_t uptime_sec = esp_timer_get_time() / 1000000; + cJSON_AddNumberToObject(root, "uptime", uptime_sec); + + const esp_app_desc_t *app_desc = esp_app_get_description(); + cJSON_AddStringToObject(root, "firmware", app_desc->version); + + const char *conn_type = "offline"; + if (g_Ethernet_Initialized) { + conn_type = "ethernet"; + } else if (g_Wifi_Initialized) { + conn_type = "wifi"; + } + cJSON_AddStringToObject(root, "connection", conn_type); + + const char *sys_info = cJSON_Print(root); + httpd_resp_sendstr(req, sys_info); + + free((void *)sys_info); + cJSON_Delete(root); + + return ESP_OK; +} + +internal const httpd_uri_t api_system_info_uri = {.uri = "/api/system/info", + .method = HTTP_GET, + .handler = + api_system_info_handler, + .user_ctx = NULL}; diff --git a/Provider/main/api/system/reboot.cpp b/Provider/main/api/system/reboot.cpp new file mode 100644 index 0000000..c96e6d8 --- /dev/null +++ b/Provider/main/api/system/reboot.cpp @@ -0,0 +1,43 @@ +#include "cJSON.h" +#include "esp_http_server.h" +#include "esp_system.h" +#include "esp_timer.h" + +#include "types.hpp" + +internal void restart_timer_callback(void *arg) { esp_restart(); } + +internal esp_err_t api_system_reboot_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_AddStringToObject(root, "status", "rebooting"); + + const char *response_text = cJSON_Print(root); + httpd_resp_sendstr(req, response_text); + + free((void *)response_text); + cJSON_Delete(root); + + const esp_timer_create_args_t restart_timer_args = { + .callback = &restart_timer_callback, + .arg = (void *)0, + .dispatch_method = ESP_TIMER_TASK, + .name = "restart_timer", + .skip_unhandled_events = false}; + esp_timer_handle_t restart_timer; + esp_timer_create(&restart_timer_args, &restart_timer); + + // Schedule reboot 1 second (in microseconds) from now to allow HTTP response + // to flush + esp_timer_start_once(restart_timer, 1'000'000); + + return ESP_OK; +} + +internal const httpd_uri_t api_system_reboot_uri = { + .uri = "/api/system/reboot", + .method = HTTP_POST, + .handler = api_system_reboot_handler, + .user_ctx = NULL}; diff --git a/Provider/main/appstate.hpp b/Provider/main/appstate.hpp new file mode 100644 index 0000000..4e007b9 --- /dev/null +++ b/Provider/main/appstate.hpp @@ -0,0 +1,7 @@ +#pragma once + +#include "types.hpp" + +// Shared Application State (Unity Build) +internal bool g_Ethernet_Initialized = false; +internal bool g_Wifi_Initialized = false; diff --git a/Provider/main/http_server.cpp b/Provider/main/http_server.cpp new file mode 100644 index 0000000..e930950 --- /dev/null +++ b/Provider/main/http_server.cpp @@ -0,0 +1,183 @@ +#include +#include +#include +#include + +// SDK +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_vfs.h" + +// Project +#include "api/system/info.cpp" +#include "api/system/reboot.cpp" + +internal const char *TAG = "HTTP_SERVER"; + +#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) +#define SCRATCH_BUFSIZE 4096 + +typedef struct { + char scratch[SCRATCH_BUFSIZE]; +} http_server_data_t; + +#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES +// Set HTTP response content type according to file extension +internal esp_err_t set_content_type_from_file(httpd_req_t *req, + const char *filepath) { + const char *type = "text/plain"; + if (strstr(filepath, ".html")) { + type = "text/html"; + } else if (strstr(filepath, ".js")) { + type = "application/javascript"; + } else if (strstr(filepath, ".css")) { + type = "text/css"; + } else if (strstr(filepath, ".png")) { + type = "image/png"; + } else if (strstr(filepath, ".ico")) { + type = "image/x-icon"; + } else if (strstr(filepath, ".svg")) { + type = "text/xml"; + } + return httpd_resp_set_type(req, type); +} + +// Handler to serve static files from LittleFS +internal esp_err_t static_file_handler(httpd_req_t *req) { + char filepath[FILE_PATH_MAX]; + + // Construct real file path + strlcpy(filepath, "/www", sizeof(filepath)); + if (req->uri[strlen(req->uri) - 1] == '/') { + strlcat(filepath, "/index.html", sizeof(filepath)); + } else { + strlcat(filepath, req->uri, sizeof(filepath)); + } + + // Default to index.html if file doesn't exist (SPA routing support) + struct stat file_stat; + if (stat(filepath, &file_stat) == -1) { + // Try gzipped first, then fallback to index.html + char filepath_gz[FILE_PATH_MAX]; + snprintf(filepath_gz, sizeof(filepath_gz), "%s.gz", filepath); + if (stat(filepath_gz, &file_stat) == 0) { + strlcpy(filepath, filepath_gz, sizeof(filepath)); + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + } else { + ESP_LOGW(TAG, "File not found: %s, falling back to index.html", filepath); + snprintf(filepath, sizeof(filepath), "%s/index.html", "/www"); + if (stat(filepath, &file_stat) == -1) { + // If index.html doesn't exist, try index.html.gz + snprintf(filepath_gz, sizeof(filepath_gz), "%s/index.html.gz", "/www"); + if (stat(filepath_gz, &file_stat) == 0) { + strlcpy(filepath, filepath_gz, sizeof(filepath)); + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + } else { + ESP_LOGE(TAG, "index.html not found too."); + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found"); + return ESP_FAIL; + } + } + } + } else { + // Since ESP32 handles .gz transparently if we tell it to via headers + // If we requested explicitly a .gz file, set the header + if (strstr(filepath, ".gz")) { + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + } + } + + int fd = open(filepath, O_RDONLY, 0); + if (fd == -1) { + ESP_LOGE(TAG, "Failed to open file: %s", filepath); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Failed to read existing file"); + return ESP_FAIL; + } + + set_content_type_from_file(req, filepath); + + http_server_data_t *rest_context = (http_server_data_t *)req->user_ctx; + char *chunk = rest_context->scratch; + ssize_t read_bytes; + + do { + read_bytes = read(fd, chunk, SCRATCH_BUFSIZE); + if (read_bytes == -1) { + ESP_LOGE(TAG, "Failed to read file: %s", filepath); + } else if (read_bytes > 0) { + if (httpd_resp_send_chunk(req, chunk, read_bytes) != ESP_OK) { + close(fd); + ESP_LOGE(TAG, "File sending failed!"); + httpd_resp_sendstr_chunk(req, NULL); // Abort sending + return ESP_FAIL; + } + } + } while (read_bytes > 0); + + close(fd); + httpd_resp_send_chunk(req, NULL, 0); // End response + + return ESP_OK; +} +#endif + +// Handler for CORS Preflight OPTIONS requests +internal esp_err_t cors_options_handler(httpd_req_t *req) { + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); + httpd_resp_set_status(req, "204 No Content"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +internal httpd_handle_t start_webserver(void) { + http_server_data_t *rest_context = + (http_server_data_t *)calloc(1, sizeof(http_server_data_t)); + if (rest_context == NULL) { + ESP_LOGE(TAG, "No memory for rest context"); + return NULL; + } + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.uri_match_fn = httpd_uri_match_wildcard; + config.max_uri_handlers = 10; // We have info, reboot, options, and static + + httpd_handle_t server = NULL; + ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port); + + if (httpd_start(&server, &config) == ESP_OK) { + // Register CORS OPTIONS handler for API routes + httpd_uri_t cors_options_uri = {.uri = "/api/*", + .method = HTTP_OPTIONS, + .handler = cors_options_handler, + .user_ctx = NULL}; + httpd_register_uri_handler(server, &cors_options_uri); + + // Register system API routes + httpd_register_uri_handler(server, &api_system_info_uri); + httpd_register_uri_handler(server, &api_system_reboot_uri); + +#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES + // Register static file handler last as a catch-all wildcard if deployed + httpd_uri_t static_get_uri = {.uri = "/*", + .method = HTTP_GET, + .handler = static_file_handler, + .user_ctx = rest_context}; + httpd_register_uri_handler(server, &static_get_uri); +#endif + + return server; + } + + ESP_LOGE(TAG, "Error starting server!"); + free(rest_context); + return NULL; +} + +internal void stop_webserver(httpd_handle_t server) { + if (server) { + httpd_stop(server); + } +} diff --git a/Provider/main/idf_component.yml b/Provider/main/idf_component.yml index 0a60f25..597f55a 100644 --- a/Provider/main/idf_component.yml +++ b/Provider/main/idf_component.yml @@ -16,3 +16,4 @@ dependencies: # public: true espressif/led_strip: ^3.0.3 espressif/ethernet_init: ^1.3.0 + joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs diff --git a/Provider/main/main.cpp b/Provider/main/main.cpp index 049bc2f..4aed8b9 100644 --- a/Provider/main/main.cpp +++ b/Provider/main/main.cpp @@ -2,7 +2,6 @@ #include // SDK -#include "driver/gpio.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -10,26 +9,32 @@ #include "sdkconfig.h" #include "soc/gpio_num.h" -// Project cpp -#include "connect.cpp" -#include "led_status.cpp" +// Project headers +#include "appstate.hpp" +#include "types.hpp" -// TODO : Make it configurable -internal constexpr bool blockUntilEthernetEstablished = false; -internal bool ethernetInitialized = false; -internal bool wifiInitialized = false; +// Project cpp (Unity Build entry) +// clang-format off +#include "connect.cpp" +#include "http_server.cpp" +#include "led_status.cpp" +// clang-format on + +internal constexpr bool kBlockUntilEthernetEstablished = false; extern "C" void app_main() { printf("Hello, Worldi!\n"); + httpd_handle_t web_server = NULL; + ESP_ERROR_CHECK(nvs_flash_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); setup_led(); set_led_status(led_status::ConnectingEthernet); - ethernetInitialized = true; - esp_err_t result = connect_ethernet(blockUntilEthernetEstablished); + g_Ethernet_Initialized = true; + esp_err_t result = connect_ethernet(kBlockUntilEthernetEstablished); if (result != ESP_OK) { set_led_status(led_status::Failed); vTaskDelay(pdMS_TO_TICKS(1000)); @@ -37,7 +42,7 @@ extern "C" void app_main() { } // Check for ethernet connection until its made - if (!blockUntilEthernetEstablished) { + if (!kBlockUntilEthernetEstablished) { uint8 retries = 1; do { set_led_status(led_status::ConnectingEthernet); @@ -56,20 +61,20 @@ extern "C" void app_main() { if (result != ESP_OK) { printf("Ethernet failed, trying wifi\n"); disconnect_ethernet(); - ethernetInitialized = false; + g_Ethernet_Initialized = false; set_led_status(led_status::ConnectingWifi); - wifiInitialized = true; + g_Wifi_Initialized = true; result = connect_wifi(CONFIG_CALENDINK_WIFI_SSID, CONFIG_CALENDINK_WIFI_PASSWORD, - blockUntilEthernetEstablished); + kBlockUntilEthernetEstablished); if (result != ESP_OK) { set_led_status(led_status::Failed); vTaskDelay(pdMS_TO_TICKS(1000)); goto shutdown; } - if (!blockUntilEthernetEstablished) { + if (!kBlockUntilEthernetEstablished) { uint8 retries = 1; do { set_led_status(led_status::ConnectingWifi); @@ -98,20 +103,30 @@ extern "C" void app_main() { } printf("Connected!\n"); - vTaskDelay(pdMS_TO_TICKS(5000)); - // TODO Main loop + // Start the webserver + web_server = start_webserver(); + + // Keep the main task alive indefinitely + while (true) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } shutdown: printf("Shutting down.\n"); - if (ethernetInitialized) { - disconnect_ethernet(); - ethernetInitialized = false; + if (web_server) { + stop_webserver(web_server); + web_server = NULL; } - if (wifiInitialized) { + + if (g_Ethernet_Initialized) { + disconnect_ethernet(); + g_Ethernet_Initialized = false; + } + if (g_Wifi_Initialized) { disconnect_wifi(); - wifiInitialized = false; + g_Wifi_Initialized = false; } destroy_led(); diff --git a/Provider/partitions.csv b/Provider/partitions.csv new file mode 100644 index 0000000..2a02adf --- /dev/null +++ b/Provider/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +www, data, littlefs, , 64K, diff --git a/Provider/sdkconfig.defaults b/Provider/sdkconfig.defaults index e69de29..8ff365b 100644 --- a/Provider/sdkconfig.defaults +++ b/Provider/sdkconfig.defaults @@ -0,0 +1,3 @@ +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" diff --git a/Provider/tdd/backend_architecture.md b/Provider/tdd/backend_architecture.md new file mode 100644 index 0000000..fb43429 --- /dev/null +++ b/Provider/tdd/backend_architecture.md @@ -0,0 +1,172 @@ +# Backend Architecture for ESP32-S3 Provider + +**Authored by Claude Opus 4** +**Date:** 2026-03-02 + +--- + +## 1. Goal + +Serve the Svelte web dashboard and expose a REST API from the ESP32-S3 using ESP-IDF's built-in `esp_http_server`. The backend must: +- Serve static files (the compiled Svelte frontend) from flash +- Provide system information over JSON +- Allow remote reboot +- Support independent frontend/backend development workflows + +## 2. Chosen Stack + +| Technology | Source | Role | +|---|---|---| +| **esp_http_server** | ESP-IDF built-in | HTTP server daemon | +| **cJSON** | ESP-IDF built-in | JSON serialization for API responses | +| **LittleFS** | [joltwallet/esp_littlefs](https://github.com/joltwallet/esp_littlefs) | Filesystem on flash for serving frontend files | + +All three are standard in ESP-IDF projects. `esp_http_server` and `cJSON` ship with the SDK. LittleFS is the recommended flash filesystem for ESP-IDF — Espressif's own [restful_server example](https://github.com/espressif/esp-idf/blob/master/examples/protocols/http_server/restful_server/main/idf_component.yml) uses `joltwallet/littlefs`. + +## 3. Why LittleFS Over Other Options + +### LittleFS vs SPIFFS + +| | LittleFS | SPIFFS | +|---|---|---| +| **Directory support** | ✅ Real directories | ❌ Flat namespace | +| **Wear leveling** | ✅ Dynamic | ⚠️ Static | +| **Power-loss safe** | ✅ Copy-on-write | ❌ Can corrupt | +| **ESP-IDF status** | ✅ Recommended | ⚠️ Deprecated in recent examples | +| **Performance** | Faster for small files | Slower mount, no dir listing | + +SPIFFS was the original choice in older ESP-IDF examples but has been replaced by LittleFS in the current restful_server example. LittleFS is the better choice going forward. + +### LittleFS Partition vs EMBED_FILES + +We considered two approaches to deploy the frontend: + +| | LittleFS Partition | `EMBED_FILES` (binary embedding) | +|---|---|---| +| **Storage** | Separate flash partition | Compiled into firmware binary | +| **Update** | Can flash partition independently | Must reflash entire firmware | +| **OTA** | Needs separate partition OTA | UI updates with firmware naturally | +| **Dev workflow** | Can skip frontend flash during iteration | Always included | +| **Filesystem** | Real VFS — open/read/close file APIs | Direct memory pointer | + +**We chose LittleFS partition** because: +1. **Development speed** — a Kconfig toggle (`CALENDINK_DEPLOY_WEB_PAGES`) lets you skip flashing the frontend entirely when iterating on the backend +2. **Separation of concerns** — frontend and firmware have independent flash regions +3. **Follows ESP-IDF conventions** — matches the official restful_server example pattern +4. **No RAM overhead** — files are read from flash in chunks, not loaded into memory + +## 4. Kconfig Deploy Toggle + +The `CALENDINK_DEPLOY_WEB_PAGES` menuconfig option controls whether the frontend gets flashed: + +| Setting | Effect | Use case | +|---|---|---| +| **OFF** (default) | Frontend not flashed. API still works. | Backend development — fast flash, use PC dev server for UI | +| **ON** | `frontend/dist/` is written to the `www` LittleFS partition | Production deployment — everything runs on ESP32 | + +This mirrors the official ESP-IDF pattern (`CONFIG_EXAMPLE_DEPLOY_WEB_PAGES` in the restful_server example). + +## 5. API Design + +### Endpoints + +``` +GET /api/system/info → JSON with chip, heap, uptime, firmware, connection +POST /api/system/reboot → JSON acknowledgment, then esp_restart() +GET /* → Static files from LittleFS /www (when deployed) +``` + +### CORS + +During development, the Svelte dev server runs on `http://localhost:5173` and API calls go to `http://`. This is cross-origin, so the backend adds CORS headers: + +- `Access-Control-Allow-Origin: *` +- `Access-Control-Allow-Methods: GET, POST, OPTIONS` +- `Access-Control-Allow-Headers: Content-Type` +- `OPTIONS` preflight handler + +In production (frontend served from ESP32), everything is same-origin — CORS headers have no effect but don't hurt. + +## 6. File Organization + +``` +Provider/main/ +├── main.cpp # Entry point, network init, starts HTTP server +├── http_server.cpp # HTTP server lifecycle, static file handler, CORS +├── api/ +│ └── system/ +│ ├── info.cpp # GET /api/system/info +│ └── reboot.cpp # POST /api/system/reboot +├── led_status.cpp # Existing — LED management +├── connect.cpp # Existing — Ethernet/WiFi +├── types.hpp # Existing — type aliases +└── Kconfig.projbuild # Existing + new web server config +``` + +### Why This Structure + +The project uses a **unity build** pattern — `main.cpp` `#include`s `.cpp` files directly (e.g. `#include "connect.cpp"`). This is unconventional but works well for small embedded projects since ESP-IDF only compiles the files listed in `idf_component_register(SRCS ...)`. + +We extend this pattern to the HTTP server: +- `http_server.cpp` `#include`s the API handler files +- Each handler file is self-contained: it defines its URI struct and registration function +- New endpoint groups get their own folder under `api/` (e.g. `api/calendar/`, `api/display/`) + +## 7. Partition Table + +``` +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 1M +www, data, littlefs, , 64K +``` + +The `www` partition is 64KB — more than enough for the 16kB gzipped frontend. Only gets written during `idf.py flash` when `CALENDINK_DEPLOY_WEB_PAGES` is enabled. + +## 8. Build Pipeline + +``` +Frontend Build (PC) ESP-IDF Build +────────────────── ────────────── +npm run build idf.py build + ↓ ↓ +frontend/dist/ firmware.bin + index.html (47kB) + + index.html.gz (16kB) www.bin (LittleFS image, when deploy=ON) + ↓ + idf.py flash + ↓ + ESP32-S3 Flash + ├── factory partition → firmware + └── www partition → frontend files +``` + +## 9. Summary + +## 9. Summary + +We use **esp_http_server + cJSON + LittleFS** — all standard ESP-IDF components — to serve the frontend and expose a REST API. A **LittleFS partition** stores frontend files separately from firmware, with a **Kconfig toggle** to skip frontend flashing during backend development. The API is structured as **modular handler files** under `api/` for clean scalability. + +--- + +## 10. Implementation Results + +*Added 2026-03-03 after implementation and integration was completed.* + +### Refactoring & Architecture Outcomes +- **Unity Build Pattern**: Successfully adopted for the HTTP server (`http_server.cpp` includes `.cpp` API handlers). This simplified the build process, reduced include complexity, and resolved multiple redefinition and linkage errors without needing complex CMake modifications. +- **State Management**: Created a centralized `appstate.hpp` to cleanly share global state (`g_Ethernet_Initialized`, `g_Wifi_Initialized`) across the project, eliminating ad-hoc `extern` declarations. + +### API Capabilities & Analytics +- **System Info (`GET /api/system/info`)**: Returns real-time JSON payload containing chip type, free heap, uptime, firmware version, and connection status. Data payload is lightweight (~110 bytes). +- **Remote Reboot (`POST /api/system/reboot`)**: Initiates an async reboot using `esp_timer` with a 1-second delay, allowing the backend to flush a successful `200 OK` JSON response to the client before the processor halts. +- **CORS Support**: Implemented `Access-Control-Allow-Origin: *` headers for all API GET and POST responses, along with an `OPTIONS` preflight handler, to support seamless local UI development against the ESP32. + +### Stability & Performance Fixes +- **Persistent Daemon**: Addressed an issue where `app_main` executed to completion immediately, causing the web server daemon to drop. Implemented a non-blocking `vTaskDelay` keep-alive loop to persist the application state and keep the HTTP server listening indefinitely without spinning the CPU. +- **Static File Fallbacks**: The LittleFS static file handler correctly falls back to `index.html` (and `.gz` variants) to seamlessly support Svelte's Single Page Application (SPA) routing patterns. + +### Observability Benchmarks +- **Heap Usage**: The system info endpoint natively tracks free heap availability. Observed typical runtime footprint leaves roughly **247 KB free heap** with active WiFi, API handling, and active HTTP server routing. +- **API Response Latency**: The minimalist handler approach results in near-instantaneous JSON responses (milliseconds), effortlessly supporting the frontend dashboard's 5-second polling interval without blocking the ESP32-S3 network stack.