diff --git a/components/settings_manager/settings_manager.c b/components/settings_manager/settings_manager.c index e5384d5d..8c768768 100644 --- a/components/settings_manager/settings_manager.c +++ b/components/settings_manager/settings_manager.c @@ -525,6 +525,13 @@ esp_err_t settings_get_json(char *json_out, size_t max_len) { cJSON_AddBoolToObject(root, "eq_available", false); #endif + // Add web OTA availability flag +#if CONFIG_SNAPCLIENT_WEB_OTA + cJSON_AddBoolToObject(root, "web_ota_enabled", true); +#else + cJSON_AddBoolToObject(root, "web_ota_enabled", false); +#endif + // Render to string char *json_str = cJSON_PrintUnformatted(root); cJSON_Delete(root); diff --git a/components/ui_http_server/CMakeLists.txt b/components/ui_http_server/CMakeLists.txt index ca2b173a..e7039e79 100644 --- a/components/ui_http_server/CMakeLists.txt +++ b/components/ui_http_server/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register(SRCS "ui_http_server.c" + "ota_handlers.c" INCLUDE_DIRS "include" - REQUIRES esp_http_server settings_manager dsp_processor_settings tas5805m_settings + REQUIRES esp_http_server settings_manager dsp_processor_settings tas5805m_settings app_update PRIV_REQUIRES custom_board EMBED_TXTFILES html/index.html html/index.js @@ -10,4 +11,5 @@ idf_component_register(SRCS "ui_http_server.c" html/dsp-settings.html html/dac-settings.html html/eq-settings.html + html/ota-update.html EMBED_FILES html/favicon.ico) diff --git a/components/ui_http_server/html/index.html b/components/ui_http_server/html/index.html index eb9e7634..217419eb 100644 --- a/components/ui_http_server/html/index.html +++ b/components/ui_http_server/html/index.html @@ -92,6 +92,7 @@

ESP32 Snapclient

+ @@ -135,6 +136,14 @@

ESP32 Snapclient

eqTab.style.display = 'block'; } } + + // Show OTA tab if web_ota_enabled is true + if (capabilities.web_ota_enabled === true) { + const otaTab = document.getElementById('ota-tab'); + if (otaTab) { + otaTab.style.display = 'block'; + } + } } catch (error) { console.error('Error checking DSP availability:', error); // DSP and DAC tabs remain hidden on error diff --git a/components/ui_http_server/html/ota-update.html b/components/ui_http_server/html/ota-update.html new file mode 100644 index 00000000..3492e1e1 --- /dev/null +++ b/components/ui_http_server/html/ota-update.html @@ -0,0 +1,488 @@ + + + + + + OTA Update + + + + + +

OTA Firmware Update

+ +
+ +
+
+
Current Firmware
+
Loading…
+
+
+
Selected Firmware
+
No file selected
+
+
+ +
+ +
+ Select a compiled .bin firmware file. The device will stop playback, + flash the new firmware, and restart automatically. +
+ +
+ +
+ +
+
+
+ +
+
+
+
+
+
+ +
+ +
+
+ +
+ Warning: Do not power off or disconnect the device during the + update. The device will restart automatically once the new firmware is written. +
+ +
+ + + + diff --git a/components/ui_http_server/include/ota_handlers.h b/components/ui_http_server/include/ota_handlers.h new file mode 100644 index 00000000..ef7269d9 --- /dev/null +++ b/components/ui_http_server/include/ota_handlers.h @@ -0,0 +1,7 @@ +#pragma once + +#include "esp_http_server.h" + +#if CONFIG_SNAPCLIENT_WEB_OTA +void ota_register_handlers(httpd_handle_t server); +#endif diff --git a/components/ui_http_server/ota_handlers.c b/components/ui_http_server/ota_handlers.c new file mode 100644 index 00000000..f63ffb32 --- /dev/null +++ b/components/ui_http_server/ota_handlers.c @@ -0,0 +1,211 @@ +#include "ota_handlers.h" + +#if CONFIG_SNAPCLIENT_WEB_OTA + +#include +#include "esp_app_desc.h" +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "OTA_HTTP"; + +extern void sc_stop_snapclient(void); + +#define OTA_HTTP_CHUNK_SIZE 4096 + +static void set_cors_headers(httpd_req_t *req) { + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", + "GET, POST, DELETE, OPTIONS"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); + httpd_resp_set_hdr(req, "Access-Control-Max-Age", "86400"); +} + +static esp_err_t options_handler(httpd_req_t *req) { + set_cors_headers(req); + httpd_resp_set_status(req, "204 No Content"); + httpd_resp_send(req, NULL, 0); + return ESP_OK; +} + +static void restart_task(void *pv) { + vTaskDelay(pdMS_TO_TICKS(200)); + esp_restart(); + vTaskDelete(NULL); +} + +/* + * POST /api/ota/upload + * Receives a raw firmware binary, writes it to the inactive OTA partition, + * sets it as the next boot partition, and restarts. + */ +static esp_err_t ota_upload_handler(httpd_req_t *req) { + ESP_LOGD(TAG, "%s: uri=%s content_len=%d", __func__, req->uri, req->content_len); + + set_cors_headers(req); + httpd_resp_set_type(req, "application/json"); + + if (req->content_len == 0) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "{\"error\": \"No firmware data\"}"); + return ESP_OK; + } + + ESP_LOGI(TAG, "%s: OTA upload started, %d bytes", __func__, req->content_len); + + sc_stop_snapclient(); + vTaskDelay(pdMS_TO_TICKS(500)); + + const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); + if (!update_partition) { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"No OTA partition available\"}"); + return ESP_OK; + } + + esp_ota_handle_t ota_handle; + esp_err_t err = esp_ota_begin(update_partition, req->content_len, &ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: esp_ota_begin failed: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"OTA begin failed\"}"); + return ESP_OK; + } + + char *buf = (char *)malloc(OTA_HTTP_CHUNK_SIZE); + if (!buf) { + esp_ota_abort(ota_handle); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Memory allocation failed\"}"); + return ESP_OK; + } + + int remaining = req->content_len; + int received = 0; + int last_pct10 = -1; + + while (remaining > 0) { + int to_read = (remaining < OTA_HTTP_CHUNK_SIZE) ? remaining : OTA_HTTP_CHUNK_SIZE; + int ret = httpd_req_recv(req, buf, to_read); + + if (ret <= 0) { + free(buf); + esp_ota_abort(ota_handle); + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + httpd_resp_set_status(req, "408 Request Timeout"); + httpd_resp_sendstr(req, "{\"error\": \"Upload timed out\"}"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Receive failed\"}"); + } + return ESP_OK; + } + + err = esp_ota_write(ota_handle, buf, ret); + if (err != ESP_OK) { + free(buf); + esp_ota_abort(ota_handle); + ESP_LOGE(TAG, "%s: esp_ota_write failed: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"OTA write failed\"}"); + return ESP_OK; + } + + received += ret; + remaining -= ret; + + int pct10 = received * 10 / req->content_len; + if (pct10 != last_pct10) { + last_pct10 = pct10; + ESP_LOGI(TAG, "%s: OTA progress: %d%%", __func__, pct10 * 10); + } + } + + free(buf); + + err = esp_ota_end(ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: esp_ota_end failed: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"OTA validation failed — invalid firmware?\"}"); + return ESP_OK; + } + + err = esp_ota_set_boot_partition(update_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "%s: esp_ota_set_boot_partition failed: %s", __func__, esp_err_to_name(err)); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "{\"error\": \"Failed to set boot partition\"}"); + return ESP_OK; + } + + ESP_LOGI(TAG, "%s: OTA complete (%d bytes), restarting...", __func__, received); + + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_hdr(req, "Connection", "close"); + httpd_resp_sendstr(req, "{\"success\": true}"); + + xTaskCreate(restart_task, "ota_restart", 2048, NULL, 5, NULL); + + return ESP_OK; +} + +/* + * GET /api/ota/status + * Returns running firmware description so the frontend can confirm a + * successful OTA by comparing SHA256 before and after the upload. + */ +static esp_err_t get_ota_status_handler(httpd_req_t *req) { + ESP_LOGD(TAG, "%s: uri=%s", __func__, req->uri); + + set_cors_headers(req); + httpd_resp_set_type(req, "application/json"); + + const esp_app_desc_t *desc = esp_app_get_description(); + + char sha256[65] = {0}; + for (int i = 0; i < 32; i++) { + snprintf(sha256 + i * 2, 3, "%02x", desc->app_elf_sha256[i]); + } + + char json[256]; + snprintf(json, sizeof(json), + "{\"version\":\"%s\",\"project_name\":\"%s\"," + "\"compile_date\":\"%s\",\"compile_time\":\"%s\"," + "\"idf_version\":\"%s\",\"sha256\":\"%s\"}", + desc->version, desc->project_name, + desc->date, desc->time, + desc->idf_ver, sha256); + + httpd_resp_sendstr(req, json); + return ESP_OK; +} + +void ota_register_handlers(httpd_handle_t server) { + httpd_uri_t upload = { + .uri = "/api/ota/upload", + .method = HTTP_POST, + .handler = ota_upload_handler, + }; + httpd_register_uri_handler(server, &upload); + + httpd_uri_t upload_options = { + .uri = "/api/ota/upload", + .method = HTTP_OPTIONS, + .handler = options_handler, + }; + httpd_register_uri_handler(server, &upload_options); + + httpd_uri_t status = { + .uri = "/api/ota/status", + .method = HTTP_GET, + .handler = get_ota_status_handler, + }; + httpd_register_uri_handler(server, &status); +} + +#endif /* CONFIG_SNAPCLIENT_WEB_OTA */ diff --git a/components/ui_http_server/ui_http_server.c b/components/ui_http_server/ui_http_server.c index b616a700..e06edd76 100644 --- a/components/ui_http_server/ui_http_server.c +++ b/components/ui_http_server/ui_http_server.c @@ -23,6 +23,7 @@ #include "freertos/semphr.h" #include "freertos/task.h" #include "settings_manager.h" +#include "ota_handlers.h" #if CONFIG_DAC_TAS5805M #include "tas5805m_settings.h" @@ -51,6 +52,10 @@ extern const uint8_t dac_settings_html_start[] asm("_binary_dac_settings_html_st extern const uint8_t dac_settings_html_end[] asm("_binary_dac_settings_html_end"); extern const uint8_t eq_settings_html_start[] asm("_binary_eq_settings_html_start"); extern const uint8_t eq_settings_html_end[] asm("_binary_eq_settings_html_end"); +#if CONFIG_SNAPCLIENT_WEB_OTA +extern const uint8_t ota_update_html_start[] asm("_binary_ota_update_html_start"); +extern const uint8_t ota_update_html_end[] asm("_binary_ota_update_html_end"); +#endif extern const uint8_t favicon_ico_start[] asm("_binary_favicon_ico_start"); extern const uint8_t favicon_ico_end[] asm("_binary_favicon_ico_end"); @@ -72,6 +77,9 @@ static const embedded_file_t embedded_files[] = { {"/dsp-settings.html", dsp_settings_html_start, dsp_settings_html_end, "text/html; charset=utf-8"}, {"/dac-settings.html", dac_settings_html_start, dac_settings_html_end, "text/html; charset=utf-8"}, {"/eq-settings.html", eq_settings_html_start, eq_settings_html_end, "text/html; charset=utf-8"}, +#if CONFIG_SNAPCLIENT_WEB_OTA + {"/ota-update.html", ota_update_html_start, ota_update_html_end, "text/html; charset=utf-8"}, +#endif {"/favicon.ico", favicon_ico_start, favicon_ico_end, "image/x-icon"}, }; @@ -1177,6 +1185,10 @@ esp_err_t start_server(const char *base_path, int port) { httpd_register_uri_handler(server, &_options_eq_schema_handler); #endif /* CONFIG_DAC_TAS5805M */ +#if CONFIG_SNAPCLIENT_WEB_OTA + ota_register_handlers(server); +#endif + /* URI handler for static files (catch-all, must be last) */ httpd_uri_t _static_file_handler = { .uri = "/*", diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index d815e0dd..90189d65 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -43,6 +43,13 @@ menu "Snapclient Configuration" default 8000 help HTTP server port to use. + + config SNAPCLIENT_WEB_OTA + bool "Enable OTA firmware upload via web interface" + default y + help + Expose a /api/ota/upload endpoint and an OTA Update tab in the web UI. + Disable to reduce firmware size or restrict update access. endmenu config USE_SAMPLE_INSERTION