From b0cff8369ea9557bf829f5fea2c6065166badeeb Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 7 Jul 2026 09:41:29 +0200 Subject: [PATCH] Add ESP32 OTA firmware update with rollback confirmation. Stream application binaries to the inactive OTA slot via POST /api/system/ota, validate the esp_app_desc project name in core, and cancel rollback after a healthy network boot through OtaService::confirmBoot(). Co-authored-by: Cursor --- Software/components/core/CMakeLists.txt | 1 + .../core/include/core/OtaAppDescriptor.hpp | 60 ++++++ .../core/include/core/OtaImageError.hpp | 33 +++ .../components/core/src/OtaAppDescriptor.cpp | 77 +++++++ Software/components/core/test/CMakeLists.txt | 5 + .../core/test/ota_app_descriptor_test.cpp | 87 ++++++++ Software/components/net/CMakeLists.txt | 2 +- .../net/include/net/NetBootstrap.hpp | 6 + .../net/include/net/SetupWebServer.hpp | 7 + Software/components/net/src/NetBootstrap.cpp | 15 +- .../components/net/src/SetupWebServer.cpp | 109 +++++++++- .../components/services/ota/CMakeLists.txt | 9 + .../services/ota/include/ota/OtaError.hpp | 51 +++++ .../services/ota/include/ota/OtaService.hpp | 127 +++++++++++ .../components/services/ota/src/OtaError.cpp | 41 ++++ .../services/ota/src/OtaService.cpp | 204 ++++++++++++++++++ Software/docs/manual/ch-api.tex | 23 ++ Software/docs/manual/ch-classes.tex | 8 + Software/main/CMakeLists.txt | 2 +- Software/main/main.cpp | 8 + 20 files changed, 862 insertions(+), 13 deletions(-) create mode 100644 Software/components/core/include/core/OtaAppDescriptor.hpp create mode 100644 Software/components/core/include/core/OtaImageError.hpp create mode 100644 Software/components/core/src/OtaAppDescriptor.cpp create mode 100644 Software/components/core/test/ota_app_descriptor_test.cpp create mode 100644 Software/components/services/ota/CMakeLists.txt create mode 100644 Software/components/services/ota/include/ota/OtaError.hpp create mode 100644 Software/components/services/ota/include/ota/OtaService.hpp create mode 100644 Software/components/services/ota/src/OtaError.cpp create mode 100644 Software/components/services/ota/src/OtaService.cpp diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index fcc386f..c3287c5 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -37,6 +37,7 @@ idf_component_register( "src/DspProgram.cpp" "src/RegisterWrite.cpp" "src/DspProgramBlob.cpp" + "src/OtaAppDescriptor.cpp" INCLUDE_DIRS "include" ) diff --git a/Software/components/core/include/core/OtaAppDescriptor.hpp b/Software/components/core/include/core/OtaAppDescriptor.hpp new file mode 100644 index 0000000..f50b022 --- /dev/null +++ b/Software/components/core/include/core/OtaAppDescriptor.hpp @@ -0,0 +1,60 @@ +/** + * @file OtaAppDescriptor.hpp + * @brief Host-testable validation of ESP-IDF app descriptors in OTA images. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include "core/OtaImageError.hpp" + +#include +#include +#include +#include + +namespace core { + +/** Byte offset of esp_app_desc_t in a raw application .bin image. */ +inline constexpr std::size_t kOtaAppDescriptorOffset = 0x20U; + +/** Expected esp_app_desc_t::magic_word (ESP_APP_DESC_MAGIC_WORD). */ +inline constexpr std::uint32_t kOtaAppDescriptorMagic = 0xABCD5432U; + +/** Expected esp_app_desc_t::project_name for this tree (CMake project()). */ +inline constexpr char kOtaProjectName[] = "digiradio"; + +/** + * @brief otaImageErrorToken — stable API/JSON error string. + * + * @dname otaImageErrorToken + * @param error Validation failure. + * @return Short token without secrets. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] const char* otaImageErrorToken(OtaImageError error) noexcept; + +/** + * @brief validateOtaAppDescriptor — reject foreign or corrupt images. + * + * @dname validateOtaAppDescriptor + * @param imagePrefix First bytes of the incoming OTA stream (>= 0x20 + 72). + * @return Ok when magic and project_name match DigiRadio, else OtaImageError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::expected +validateOtaAppDescriptor(std::span imagePrefix); + +} // namespace core diff --git a/Software/components/core/include/core/OtaImageError.hpp b/Software/components/core/include/core/OtaImageError.hpp new file mode 100644 index 0000000..07a8625 --- /dev/null +++ b/Software/components/core/include/core/OtaImageError.hpp @@ -0,0 +1,33 @@ +/** + * @file OtaImageError.hpp + * @brief Failure causes for ESP32 firmware image header validation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +namespace core { + +/** + * @brief OtaImageError — firmware image descriptor validation failures. + * + * @dname OtaImageError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-07 + */ +enum class OtaImageError { + InsufficientHeader, ///< Fewer bytes than the app descriptor offset. + InvalidMagic, ///< magic_word != ESP_APP_DESC_MAGIC_WORD. + InvalidProject, ///< project_name does not match this firmware tree. +}; + +} // namespace core diff --git a/Software/components/core/src/OtaAppDescriptor.cpp b/Software/components/core/src/OtaAppDescriptor.cpp new file mode 100644 index 0000000..462783c --- /dev/null +++ b/Software/components/core/src/OtaAppDescriptor.cpp @@ -0,0 +1,77 @@ +/** + * @file OtaAppDescriptor.cpp + * @brief OTA app descriptor validation implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "core/OtaAppDescriptor.hpp" + +#include +#include + +namespace core { + +namespace { + +constexpr std::size_t kMinProjectNameCheck = + kOtaAppDescriptorOffset + 4U + 4U + 32U + 32U; + +[[nodiscard]] std::uint32_t readLe32(const std::uint8_t* p) +{ + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +[[nodiscard]] bool projectNameMatches(const char* field) +{ + return std::strncmp(field, kOtaProjectName, sizeof(kOtaProjectName) - 1U) + == 0; +} + +} // namespace + +const char* otaImageErrorToken(OtaImageError error) noexcept +{ + switch (error) { + case OtaImageError::InsufficientHeader: + return "insufficient_header"; + case OtaImageError::InvalidMagic: + return "invalid_magic"; + case OtaImageError::InvalidProject: + return "invalid_project"; + } + return "unknown"; +} + +std::expected +validateOtaAppDescriptor(std::span imagePrefix) +{ + if (imagePrefix.size() < kMinProjectNameCheck) { + return std::unexpected(OtaImageError::InsufficientHeader); + } + + const std::uint8_t* desc = imagePrefix.data() + kOtaAppDescriptorOffset; + const std::uint32_t magic = readLe32(desc); + if (magic != kOtaAppDescriptorMagic) { + return std::unexpected(OtaImageError::InvalidMagic); + } + + const char* projectName = + reinterpret_cast(desc + 4U + 4U + 32U); + if (!projectNameMatches(projectName)) { + return std::unexpected(OtaImageError::InvalidProject); + } + + return {}; +} + +} // namespace core diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 6b98b21..9344566 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -49,6 +49,7 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/DspProgram.cpp" "${CORE_SRC_DIR}/RegisterWrite.cpp" "${CORE_SRC_DIR}/DspProgramBlob.cpp" + "${CORE_SRC_DIR}/OtaAppDescriptor.cpp" ) target_include_directories(digiradio_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include" @@ -58,6 +59,10 @@ add_executable(dsp_program_blob_test dsp_program_blob_test.cpp) target_link_libraries(dsp_program_blob_test PRIVATE digiradio_core) add_test(NAME dsp_program_blob_test COMMAND dsp_program_blob_test) +add_executable(ota_app_descriptor_test ota_app_descriptor_test.cpp) +target_link_libraries(ota_app_descriptor_test PRIVATE digiradio_core) +add_test(NAME ota_app_descriptor_test COMMAND ota_app_descriptor_test) + add_executable(device_identity_test device_identity_test.cpp) target_link_libraries(device_identity_test PRIVATE digiradio_core) add_test(NAME device_identity_test COMMAND device_identity_test) diff --git a/Software/components/core/test/ota_app_descriptor_test.cpp b/Software/components/core/test/ota_app_descriptor_test.cpp new file mode 100644 index 0000000..dd24e09 --- /dev/null +++ b/Software/components/core/test/ota_app_descriptor_test.cpp @@ -0,0 +1,87 @@ +/** + * @file ota_app_descriptor_test.cpp + * @brief Host tests for OTA app descriptor validation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "core/OtaAppDescriptor.hpp" + +#include +#include +#include +#include + +namespace { + +void writeLe32(std::uint8_t* p, std::uint32_t value) +{ + p[0] = static_cast(value); + p[1] = static_cast(value >> 8); + p[2] = static_cast(value >> 16); + p[3] = static_cast(value >> 24); +} + +std::array makeValidPrefix() +{ + std::array image{}; + writeLe32(image.data() + core::kOtaAppDescriptorOffset, + core::kOtaAppDescriptorMagic); + std::memcpy(image.data() + core::kOtaAppDescriptorOffset + 40U, + core::kOtaProjectName, + std::strlen(core::kOtaProjectName)); + return image; +} + +void testValidDescriptor() +{ + const auto image = makeValidPrefix(); + const auto result = core::validateOtaAppDescriptor(image); + assert(result.has_value()); +} + +void testInsufficientHeader() +{ + const std::array shortImage{}; + const auto result = core::validateOtaAppDescriptor(shortImage); + assert(!result.has_value()); + assert(result.error() == core::OtaImageError::InsufficientHeader); +} + +void testInvalidMagic() +{ + auto image = makeValidPrefix(); + writeLe32(image.data() + core::kOtaAppDescriptorOffset, 0U); + const auto result = core::validateOtaAppDescriptor(image); + assert(!result.has_value()); + assert(result.error() == core::OtaImageError::InvalidMagic); +} + +void testInvalidProject() +{ + auto image = makeValidPrefix(); + const char foreign[] = "other-project"; + std::memcpy(image.data() + core::kOtaAppDescriptorOffset + 40U, + foreign, + sizeof(foreign)); + const auto result = core::validateOtaAppDescriptor(image); + assert(!result.has_value()); + assert(result.error() == core::OtaImageError::InvalidProject); +} + +} // namespace + +int main() +{ + testValidDescriptor(); + testInsufficientHeader(); + testInvalidMagic(); + testInvalidProject(); + return 0; +} diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt index 160922a..50f7c55 100644 --- a/Software/components/net/CMakeLists.txt +++ b/Software/components/net/CMakeLists.txt @@ -7,7 +7,7 @@ idf_component_register( "src/NetBootstrap.cpp" INCLUDE_DIRS "include" EMBED_FILES "www/index.html.gz" - REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns secure_store tuner audio bluetooth station integration bt1035 adau1701 + REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns secure_store tuner audio bluetooth station integration ota bt1035 adau1701 ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp index 386d3f5..29c36de 100644 --- a/Software/components/net/include/net/NetBootstrap.hpp +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -45,6 +45,10 @@ namespace integration { class IntegrationService; } // namespace integration +namespace ota { +class OtaService; +} // namespace ota + namespace tuner { class TunerService; } // namespace tuner @@ -74,6 +78,7 @@ public: * @param bluetooth Bluetooth pairing service for REST routes. * @param stations Station preset service for REST routes. * @param integration Application orchestration for preset recall. + * @param ota Firmware OTA service for POST /api/system/ota. * @param companionChips Boot flags exposed on GET /api/health. * @param deviceIdentity EEPROM-derived SSID, hostname, and serial. * @return NetBootstrap on success, or a NetError. @@ -87,6 +92,7 @@ public: audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, + ota::OtaService& ota, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity); diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index c46de24..924f3c2 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -43,6 +43,10 @@ namespace integration { class IntegrationService; } // namespace integration +namespace ota { +class OtaService; +} // namespace ota + namespace tuner { class TunerService; } // namespace tuner @@ -69,6 +73,7 @@ struct HttpRouteContext { bluetooth::BluetoothService* bluetooth; ///< Bluetooth pairing REST routes. station::StationService* stations; ///< Preset list REST routes. integration::IntegrationService* integration; ///< Preset recall orchestration. + ota::OtaService* ota; ///< Firmware OTA streaming. core::CompanionChipStatus companionChips; ///< Boot flags for /api/health. core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity. }; @@ -147,6 +152,7 @@ public: * @param bluetooth Bluetooth service for pairing REST routes. * @param stations Station preset service for list REST routes. * @param integration Application orchestration for preset recall. + * @param ota Firmware OTA service for POST /api/system/ota. * @param companionChips Boot flags for GET /api/health. * @param deviceIdentity Unit identity for /api/health serialNumber. * @return Ok on success, or NetError::HttpServerStartFailed. @@ -161,6 +167,7 @@ public: bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, + ota::OtaService& ota, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity); diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index 8dbf399..8a19c10 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -101,6 +101,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, + ota::OtaService& ota, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -114,8 +115,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner, audio, - bluetooth, stations, integration, companionChips, - deviceIdentity); + bluetooth, stations, integration, ota, + companionChips, deviceIdentity); !webResult) { return std::unexpected(webResult.error()); } @@ -144,6 +145,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, + ota::OtaService& ota, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -165,8 +167,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::StaConnected, tuner, audio, - bluetooth, stations, integration, companionChips, - deviceIdentity); + bluetooth, stations, integration, ota, + companionChips, deviceIdentity); !webResult) { return std::unexpected(webResult.error()); } @@ -186,6 +188,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, + ota::OtaService& ota, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -199,7 +202,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, if (store.hasWifiCredentials()) { auto staResult = startStaMode(store, tuner, audio, bluetooth, stations, - integration, companionChips, + integration, ota, companionChips, deviceIdentity); if (staResult) { return staResult; @@ -208,7 +211,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, } return startSetupMode(store, tuner, audio, bluetooth, stations, integration, - companionChips, deviceIdentity); + ota, companionChips, deviceIdentity); } NetBootstrap::NetBootstrap(std::optional softAp, diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 4942065..a2552ad 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -39,6 +39,9 @@ #include "station/StationService.hpp" #include "integration/IntegrationService.hpp" #include "adau1701/FlashDspProgramSource.hpp" +#include "ota/OtaService.hpp" +#include "ota/OtaError.hpp" +#include "core/OtaAppDescriptor.hpp" #include "bt1035/Bt1035Error.hpp" #include "esp_http_server.h" @@ -48,6 +51,7 @@ #include "freertos/task.h" #include +#include #include #include #include @@ -733,6 +737,90 @@ esp_err_t dspProgramPostHandler(httpd_req_t* req) return ESP_OK; } +/** + * @brief otaPostHandler — stream firmware image into inactive OTA slot. + * + * @dname otaPostHandler + * @param req HTTP request handle (raw application/octet-stream body). + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate writes inactive OTA partition; schedules reboot on success. + * + * @author Michele Bigi + * @date 2026-07-07 + */ +esp_err_t otaPostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->ota == nullptr) { + httpd_resp_set_status(req, "500 Internal Server Error"); + return httpd_resp_send(req, nullptr, 0); + } + + constexpr int kMaxOtaSize = 0x1B0000; + const int contentLen = req->content_len; + if (contentLen <= 0 || contentLen > kMaxOtaSize) { + httpd_resp_set_status(req, "413 Payload Too Large"); + return httpd_resp_send(req, nullptr, 0); + } + + ota::OtaService& otaService = *ctx->ota; + if (auto begun = otaService.beginStream(contentLen); !begun) { + const std::string json = std::string(R"({"error":")") + + ota::otaErrorToken(begun.error()) + + R"("})"; + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + std::array chunk{}; + int received = 0; + while (received < contentLen) { + const int toRead = std::min(static_cast(chunk.size()), + contentLen - received); + const int n = httpd_req_recv(req, + reinterpret_cast(chunk.data()), + toRead); + if (n <= 0) { + otaService.abort(); + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + + if (auto written = otaService.writeChunk( + {chunk.data(), static_cast(n)}); + !written) { + const char* token = ota::otaErrorToken(written.error()); + if (written.error() == ota::OtaError::WriteFailed) { + token = core::otaImageErrorToken(otaService.lastImageError()); + } + const std::string json = + std::string(R"({"error":")") + token + R"("})"; + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + received += n; + } + + if (auto finished = otaService.finishStream(); !finished) { + const std::string json = std::string(R"({"error":")") + + ota::otaErrorToken(finished.error()) + + R"("})"; + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + const std::string json = + std::string(R"({"status":"stored","reboot_sec":)") + + std::to_string(kRebootDelaySec) + "}"; + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json.c_str(), json.size()); + xTaskCreate(rebootTask, "reboot", 2048, nullptr, 5, nullptr); + return ESP_OK; +} + esp_err_t bluetoothStatusGetHandler(httpd_req_t* req) { auto* ctx = routeContextFrom(req); @@ -960,7 +1048,8 @@ SetupWebServer::SetupWebServer() , bluetooth_(nullptr) , stations_(nullptr) , integration_(nullptr) - , routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}} + , routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, {}} { } @@ -984,7 +1073,7 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept other.stations_ = nullptr; other.integration_ = nullptr; other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, - nullptr, {}, core::DeviceIdentity::unknown()}; + nullptr, nullptr, {}, core::DeviceIdentity::unknown()}; } SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept @@ -1022,8 +1111,8 @@ SetupWebServer::~SetupWebServer() httpd_stop(server_); server_ = nullptr; } - routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}, - core::DeviceIdentity::unknown()}; + routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, {}, core::DeviceIdentity::unknown()}; } std::expected SetupWebServer::start( @@ -1034,6 +1123,7 @@ std::expected SetupWebServer::start( bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, + ota::OtaService& ota, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -1054,6 +1144,7 @@ std::expected SetupWebServer::start( routeContext_.bluetooth = &bluetooth; routeContext_.stations = &stations; routeContext_.integration = &integration; + routeContext_.ota = &ota; routeContext_.companionChips = companionChips; routeContext_.deviceIdentity = deviceIdentity; @@ -1064,7 +1155,7 @@ std::expected SetupWebServer::start( if (httpd_start(&server_, &config) != ESP_OK) { ESP_LOGE(kTag, "httpd_start failed"); routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, - {}, core::DeviceIdentity::unknown()}; + nullptr, {}, core::DeviceIdentity::unknown()}; return std::unexpected(NetError::HttpServerStartFailed); } @@ -1182,6 +1273,14 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &dspProgramUri); + const httpd_uri_t otaUri = { + .uri = "/api/system/ota", + .method = HTTP_POST, + .handler = otaPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &otaUri); + const httpd_uri_t bluetoothStatusUri = { .uri = "/api/bluetooth/status", .method = HTTP_GET, diff --git a/Software/components/services/ota/CMakeLists.txt b/Software/components/services/ota/CMakeLists.txt new file mode 100644 index 0000000..9c288b1 --- /dev/null +++ b/Software/components/services/ota/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register( + SRCS + "src/OtaService.cpp" + "src/OtaError.cpp" + INCLUDE_DIRS "include" + REQUIRES core app_update esp_partition +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/services/ota/include/ota/OtaError.hpp b/Software/components/services/ota/include/ota/OtaError.hpp new file mode 100644 index 0000000..ab461db --- /dev/null +++ b/Software/components/services/ota/include/ota/OtaError.hpp @@ -0,0 +1,51 @@ +/** + * @file OtaError.hpp + * @brief Failure causes for ESP32 OTA streaming updates. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +namespace ota { + +/** + * @brief OtaError — OTA session and flash write failures. + * + * @dname OtaError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-07 + */ +enum class OtaError { + SessionActive, + NoUpdatePartition, + BeginFailed, + WriteFailed, + EndFailed, + ImageTooLarge, + SetBootFailed, + ConfirmFailed, +}; + +/** + * @brief otaErrorToken — stable API/JSON error string. + * + * @dname otaErrorToken + * @param error OTA failure from the service layer. + * @return Short token without secrets or image bytes. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] const char* otaErrorToken(OtaError error) noexcept; + +} // namespace ota diff --git a/Software/components/services/ota/include/ota/OtaService.hpp b/Software/components/services/ota/include/ota/OtaService.hpp new file mode 100644 index 0000000..cba1bf6 --- /dev/null +++ b/Software/components/services/ota/include/ota/OtaService.hpp @@ -0,0 +1,127 @@ +/** + * @file OtaService.hpp + * @brief ESP32 firmware OTA streaming and rollback confirmation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include "core/OtaImageError.hpp" +#include "ota/OtaError.hpp" + +#include +#include +#include +#include +#include + +namespace ota { + +/** + * @brief OtaService — streams firmware images into the inactive OTA slot. + * + * @dname OtaService + * @return n/a (type) + * @pubstate Holds at most one active esp_ota session. Call confirmBoot() + * once after network bootstrap on every boot. + * + * @author Michele Bigi + * @date 2026-07-07 + */ +class OtaService { +public: + /** + * @brief confirmBoot — cancel rollback after a healthy boot. + * + * @dname confirmBoot + * @return Ok when pending verification is cleared or absent. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] static std::expected confirmBoot(); + + /** + * @brief beginStream — open esp_ota session on the inactive slot. + * + * @dname beginStream + * @param contentLength Declared HTTP body size in bytes. + * @return Ok on success, or OtaError. + * @pubstate starts an active session until finishStream() or abort(). + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected beginStream(int contentLength); + + /** + * @brief writeChunk — append bytes and validate the app descriptor once. + * + * @dname writeChunk + * @param chunk Next bytes from the HTTP body. + * @return Ok on success, core::OtaImageError on descriptor mismatch, + * or OtaError on flash write failure. + * @pubstate accumulates header prefix until descriptor validation passes. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected writeChunk( + std::span chunk); + + /** + * @brief finishStream — finalize image and select the new boot slot. + * + * @dname finishStream + * @return Ok when esp_ota_end and set_boot_partition succeed. + * @pubstate closes the active session. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected finishStream(); + + /** + * @brief abort — cancel an in-progress OTA session. + * + * @dname abort + * @pubstate clears session state without changing the boot partition. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + void abort() noexcept; + + /** + * @brief lastImageError — image validation failure from writeChunk(). + * + * @dname lastImageError + * @return Most recent core::OtaImageError when writeChunk failed validation. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] core::OtaImageError lastImageError() const noexcept; + +private: + void resetSession() noexcept; + + bool active_ = false; + bool descriptorValidated_ = false; + int expectedSize_ = 0; + int bytesWritten_ = 0; + void* otaHandle_ = nullptr; + const void* updatePartition_ = nullptr; + std::vector headerPrefix_; + core::OtaImageError lastImageError_ = core::OtaImageError::InsufficientHeader; +}; + +} // namespace ota diff --git a/Software/components/services/ota/src/OtaError.cpp b/Software/components/services/ota/src/OtaError.cpp new file mode 100644 index 0000000..8b458fa --- /dev/null +++ b/Software/components/services/ota/src/OtaError.cpp @@ -0,0 +1,41 @@ +/** + * @file OtaError.cpp + * @brief OtaError token strings. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "ota/OtaError.hpp" + +namespace ota { + +const char* otaErrorToken(OtaError error) noexcept +{ + switch (error) { + case OtaError::SessionActive: + return "session_active"; + case OtaError::NoUpdatePartition: + return "no_update_partition"; + case OtaError::BeginFailed: + return "begin_failed"; + case OtaError::WriteFailed: + return "write_failed"; + case OtaError::EndFailed: + return "end_failed"; + case OtaError::ImageTooLarge: + return "image_too_large"; + case OtaError::SetBootFailed: + return "set_boot_failed"; + case OtaError::ConfirmFailed: + return "confirm_failed"; + } + return "unknown"; +} + +} // namespace ota diff --git a/Software/components/services/ota/src/OtaService.cpp b/Software/components/services/ota/src/OtaService.cpp new file mode 100644 index 0000000..b51e7f3 --- /dev/null +++ b/Software/components/services/ota/src/OtaService.cpp @@ -0,0 +1,204 @@ +/** + * @file OtaService.cpp + * @brief OtaService implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "ota/OtaService.hpp" + +#include "core/OtaAppDescriptor.hpp" + +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_partition.h" + +#include + +namespace ota { + +namespace { + +constexpr char kTag[] = "OtaService"; +constexpr int kMaxOtaImageSize = 0x1B0000; +constexpr std::size_t kHeaderCaptureMax = 512U; + +void captureHeaderPrefix(std::vector& prefix, + std::span chunk) +{ + if (prefix.size() >= kHeaderCaptureMax) { + return; + } + const std::size_t remaining = kHeaderCaptureMax - prefix.size(); + const std::size_t take = std::min(remaining, chunk.size()); + prefix.insert(prefix.end(), chunk.begin(), chunk.begin() + take); +} + +} // namespace + +std::expected OtaService::confirmBoot() +{ + const esp_partition_t* running = esp_ota_get_running_partition(); + if (running == nullptr) { + ESP_LOGE(kTag, "running partition unavailable"); + return std::unexpected(OtaError::ConfirmFailed); + } + + esp_ota_img_states_t state = ESP_OTA_IMG_UNDEFINED; + if (esp_ota_get_state_partition(running, &state) != ESP_OK) { + ESP_LOGE(kTag, "partition state read failed"); + return std::unexpected(OtaError::ConfirmFailed); + } + + if (state != ESP_OTA_IMG_PENDING_VERIFY) { + return {}; + } + + if (esp_ota_mark_app_valid_cancel_rollback() != ESP_OK) { + ESP_LOGE(kTag, "mark_app_valid failed"); + return std::unexpected(OtaError::ConfirmFailed); + } + + ESP_LOGI(kTag, "OTA image confirmed — rollback cancelled"); + return {}; +} + +std::expected OtaService::beginStream(int contentLength) +{ + if (active_) { + return std::unexpected(OtaError::SessionActive); + } + if (contentLength <= 0 || contentLength > kMaxOtaImageSize) { + return std::unexpected(OtaError::ImageTooLarge); + } + + const esp_partition_t* updatePartition = + esp_ota_get_next_update_partition(nullptr); + if (updatePartition == nullptr) { + return std::unexpected(OtaError::NoUpdatePartition); + } + + esp_ota_handle_t handle = 0; + if (esp_ota_begin(updatePartition, OTA_WITH_SEQUENTIAL_WRITES, &handle) + != ESP_OK) { + return std::unexpected(OtaError::BeginFailed); + } + + active_ = true; + descriptorValidated_ = false; + expectedSize_ = contentLength; + bytesWritten_ = 0; + otaHandle_ = reinterpret_cast(static_cast(handle)); + updatePartition_ = updatePartition; + headerPrefix_.clear(); + lastImageError_ = core::OtaImageError::InsufficientHeader; + return {}; +} + +std::expected OtaService::writeChunk( + std::span chunk) +{ + if (!active_ || otaHandle_ == nullptr) { + return std::unexpected(OtaError::BeginFailed); + } + + if (bytesWritten_ + static_cast(chunk.size()) > expectedSize_) { + abort(); + return std::unexpected(OtaError::ImageTooLarge); + } + + if (!descriptorValidated_) { + captureHeaderPrefix(headerPrefix_, chunk); + const auto validated = core::validateOtaAppDescriptor(headerPrefix_); + if (validated) { + descriptorValidated_ = true; + } else { + lastImageError_ = validated.error(); + if (validated.error() != core::OtaImageError::InsufficientHeader + || headerPrefix_.size() >= kHeaderCaptureMax) { + abort(); + return std::unexpected(OtaError::WriteFailed); + } + } + } + + const auto handle = + static_cast( + reinterpret_cast(otaHandle_)); + if (esp_ota_write(handle, chunk.data(), chunk.size()) != ESP_OK) { + abort(); + return std::unexpected(OtaError::WriteFailed); + } + + bytesWritten_ += static_cast(chunk.size()); + return {}; +} + +std::expected OtaService::finishStream() +{ + if (!active_ || otaHandle_ == nullptr || updatePartition_ == nullptr) { + return std::unexpected(OtaError::BeginFailed); + } + if (bytesWritten_ != expectedSize_) { + abort(); + return std::unexpected(OtaError::WriteFailed); + } + if (!descriptorValidated_) { + lastImageError_ = core::OtaImageError::InsufficientHeader; + abort(); + return std::unexpected(OtaError::WriteFailed); + } + + const auto handle = + static_cast( + reinterpret_cast(otaHandle_)); + if (esp_ota_end(handle) != ESP_OK) { + resetSession(); + return std::unexpected(OtaError::EndFailed); + } + + const auto* partition = + static_cast(updatePartition_); + if (esp_ota_set_boot_partition(partition) != ESP_OK) { + resetSession(); + return std::unexpected(OtaError::SetBootFailed); + } + + resetSession(); + return {}; +} + +void OtaService::abort() noexcept +{ + if (active_ && otaHandle_ != nullptr) { + const auto handle = + static_cast( + reinterpret_cast(otaHandle_)); + esp_ota_abort(handle); + } + resetSession(); +} + +core::OtaImageError OtaService::lastImageError() const noexcept +{ + return lastImageError_; +} + +void OtaService::resetSession() noexcept +{ + active_ = false; + descriptorValidated_ = false; + expectedSize_ = 0; + bytesWritten_ = 0; + otaHandle_ = nullptr; + updatePartition_ = nullptr; + headerPrefix_.clear(); +} + +} // namespace ota diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index 512e1ca..169ed0a 100644 --- a/Software/docs/manual/ch-api.tex +++ b/Software/docs/manual/ch-api.tex @@ -245,6 +245,29 @@ for invalid/truncated/CRC failures; \textbf{413} when the body exceeds 200\,KiB; \textbf{500} on flash write failure. Pack blobs with \texttt{tools/pack\_dsp\_program.py} or \texttt{core::serializeDspProgramBlob()}. +\subsection{\texttt{POST /api/system/ota}} +\label{sec:api-system-ota} + +Streams a raw ESP-IDF application \texttt{.bin} into the inactive OTA slot +(\texttt{ota\_0}/\texttt{ota\_1}). The body is raw bytes; the server validates +the embedded \texttt{esp\_app\_desc\_t} at offset~0x20 (magic and +\texttt{project\_name == "digiradio"}) while streaming. On success the new +slot is selected and the device reboots; the first healthy boot after Wi-Fi +and HTTP are up calls +\texttt{ota::OtaService::confirmBoot()} to cancel rollback. + +\begin{drnote}[Success response] +\begin{drcode}[JSON] +{"status":"stored","reboot_sec":3} +\end{drcode} +\end{drnote} + +HTTP status: \textbf{200 OK}; \textbf{400} with \texttt{\{"error":"..."\}} +for invalid project/magic or flash write failures; +\textbf{413} when the body exceeds 1.7\,MiB (\texttt{0x1B0000}); +\textbf{500} on \texttt{esp\_ota\_end}/set-boot failures. Push with +\texttt{curl --data-binary @build/digiradio.bin}. + \subsection{\texttt{POST /api/audio/reset}} \label{sec:api-audio-reset} diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index b90bdc2..535487b 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -323,3 +323,11 @@ Application service for BT1035 pairing and A2DP status (Chapter~\ref{ch:bt1035}). Delegates to \texttt{Bt1035Driver}; tracks whether discoverable mode was requested. Exposed on \texttt{/api/bluetooth/*}. + +\section{OtaService}\label{cls:OtaService} +Application service wrapping \texttt{esp\_ota\_ops}: streams firmware into +the inactive OTA slot, validates the app descriptor via +\texttt{core::validateOtaAppDescriptor()}, and exposes +\texttt{confirmBoot()} for rollback cancellation after a healthy network boot. + +% ------------------------------------------------------------------ diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index 778b002..84f6f90 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -3,5 +3,5 @@ idf_component_register( "main.cpp" "hardware_bootstrap.cpp" INCLUDE_DIRS "." - REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration eeprom24aa + REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration ota eeprom24aa ) diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 2c9a3f5..1c11fe8 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -15,6 +15,7 @@ #include "bluetooth/BluetoothService.hpp" #include "integration/IntegrationService.hpp" #include "net/NetBootstrap.hpp" +#include "ota/OtaService.hpp" #include "secure_store/NvsSecureStore.hpp" #include "station/StationService.hpp" #include "tuner/TunerService.hpp" @@ -78,6 +79,8 @@ extern "C" void app_main() static bluetooth::BluetoothService bluetoothService( hardware::HardwareBootstrap::bt1035Driver()); + static ota::OtaService otaService; + auto netResult = net::NetBootstrap::start( store, tunerService, @@ -85,6 +88,7 @@ extern "C" void app_main() bluetoothService, stationService, integration, + otaService, hardware::HardwareBootstrap::companionChipStatus(), hardware::HardwareBootstrap::deviceIdentity()); if (!netResult) { @@ -94,6 +98,10 @@ extern "C" void app_main() static net::NetBootstrap net = std::move(netResult.value()); + if (auto confirmed = ota::OtaService::confirmBoot(); !confirmed) { + ESP_LOGW(kTag, "OTA rollback confirm failed"); + } + if (xTaskCreate(heartbeatTask, "heartbeat", 2048, nullptr, 5, nullptr) != pdPASS) { ESP_LOGE(kTag, "heartbeat task create failed");