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
DSP Settings
DAC Settings
EQ
+ OTA Update
@@ -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
+
+
+
+
+
+
+
+
+ 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