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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 09:41:29 +02:00
co-authored by Cursor
parent cc4a03168e
commit b0cff8369e
20 changed files with 862 additions and 13 deletions
+1
View File
@@ -37,6 +37,7 @@ idf_component_register(
"src/DspProgram.cpp" "src/DspProgram.cpp"
"src/RegisterWrite.cpp" "src/RegisterWrite.cpp"
"src/DspProgramBlob.cpp" "src/DspProgramBlob.cpp"
"src/OtaAppDescriptor.cpp"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
) )
@@ -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 <cstddef>
#include <cstdint>
#include <expected>
#include <span>
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<void, OtaImageError>
validateOtaAppDescriptor(std::span<const std::uint8_t> imagePrefix);
} // namespace core
@@ -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
@@ -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 <cstring>
#include <expected>
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<std::uint32_t>(p[0])
| (static_cast<std::uint32_t>(p[1]) << 8)
| (static_cast<std::uint32_t>(p[2]) << 16)
| (static_cast<std::uint32_t>(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<void, OtaImageError>
validateOtaAppDescriptor(std::span<const std::uint8_t> 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<const char*>(desc + 4U + 4U + 32U);
if (!projectNameMatches(projectName)) {
return std::unexpected(OtaImageError::InvalidProject);
}
return {};
}
} // namespace core
@@ -49,6 +49,7 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/DspProgram.cpp" "${CORE_SRC_DIR}/DspProgram.cpp"
"${CORE_SRC_DIR}/RegisterWrite.cpp" "${CORE_SRC_DIR}/RegisterWrite.cpp"
"${CORE_SRC_DIR}/DspProgramBlob.cpp" "${CORE_SRC_DIR}/DspProgramBlob.cpp"
"${CORE_SRC_DIR}/OtaAppDescriptor.cpp"
) )
target_include_directories(digiradio_core PUBLIC target_include_directories(digiradio_core PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/../include" "${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) target_link_libraries(dsp_program_blob_test PRIVATE digiradio_core)
add_test(NAME dsp_program_blob_test COMMAND dsp_program_blob_test) 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) add_executable(device_identity_test device_identity_test.cpp)
target_link_libraries(device_identity_test PRIVATE digiradio_core) target_link_libraries(device_identity_test PRIVATE digiradio_core)
add_test(NAME device_identity_test COMMAND device_identity_test) add_test(NAME device_identity_test COMMAND device_identity_test)
@@ -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 <array>
#include <cassert>
#include <cstring>
#include <cstdint>
namespace {
void writeLe32(std::uint8_t* p, std::uint32_t value)
{
p[0] = static_cast<std::uint8_t>(value);
p[1] = static_cast<std::uint8_t>(value >> 8);
p[2] = static_cast<std::uint8_t>(value >> 16);
p[3] = static_cast<std::uint8_t>(value >> 24);
}
std::array<std::uint8_t, 256> makeValidPrefix()
{
std::array<std::uint8_t, 256> 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<std::uint8_t, 32> 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;
}
+1 -1
View File
@@ -7,7 +7,7 @@ idf_component_register(
"src/NetBootstrap.cpp" "src/NetBootstrap.cpp"
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
EMBED_FILES "www/index.html.gz" 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) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -45,6 +45,10 @@ namespace integration {
class IntegrationService; class IntegrationService;
} // namespace integration } // namespace integration
namespace ota {
class OtaService;
} // namespace ota
namespace tuner { namespace tuner {
class TunerService; class TunerService;
} // namespace tuner } // namespace tuner
@@ -74,6 +78,7 @@ public:
* @param bluetooth Bluetooth pairing service for REST routes. * @param bluetooth Bluetooth pairing service for REST routes.
* @param stations Station preset service for REST routes. * @param stations Station preset service for REST routes.
* @param integration Application orchestration for preset recall. * @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 companionChips Boot flags exposed on GET /api/health.
* @param deviceIdentity EEPROM-derived SSID, hostname, and serial. * @param deviceIdentity EEPROM-derived SSID, hostname, and serial.
* @return NetBootstrap on success, or a NetError. * @return NetBootstrap on success, or a NetError.
@@ -87,6 +92,7 @@ public:
audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, audio::AudioService& audio, bluetooth::BluetoothService& bluetooth,
station::StationService& stations, station::StationService& stations,
integration::IntegrationService& integration, integration::IntegrationService& integration,
ota::OtaService& ota,
core::CompanionChipStatus companionChips, core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity); const core::DeviceIdentity& deviceIdentity);
@@ -43,6 +43,10 @@ namespace integration {
class IntegrationService; class IntegrationService;
} // namespace integration } // namespace integration
namespace ota {
class OtaService;
} // namespace ota
namespace tuner { namespace tuner {
class TunerService; class TunerService;
} // namespace tuner } // namespace tuner
@@ -69,6 +73,7 @@ struct HttpRouteContext {
bluetooth::BluetoothService* bluetooth; ///< Bluetooth pairing REST routes. bluetooth::BluetoothService* bluetooth; ///< Bluetooth pairing REST routes.
station::StationService* stations; ///< Preset list REST routes. station::StationService* stations; ///< Preset list REST routes.
integration::IntegrationService* integration; ///< Preset recall orchestration. integration::IntegrationService* integration; ///< Preset recall orchestration.
ota::OtaService* ota; ///< Firmware OTA streaming.
core::CompanionChipStatus companionChips; ///< Boot flags for /api/health. core::CompanionChipStatus companionChips; ///< Boot flags for /api/health.
core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity. core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity.
}; };
@@ -147,6 +152,7 @@ public:
* @param bluetooth Bluetooth service for pairing REST routes. * @param bluetooth Bluetooth service for pairing REST routes.
* @param stations Station preset service for list REST routes. * @param stations Station preset service for list REST routes.
* @param integration Application orchestration for preset recall. * @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 companionChips Boot flags for GET /api/health.
* @param deviceIdentity Unit identity for /api/health serialNumber. * @param deviceIdentity Unit identity for /api/health serialNumber.
* @return Ok on success, or NetError::HttpServerStartFailed. * @return Ok on success, or NetError::HttpServerStartFailed.
@@ -161,6 +167,7 @@ public:
bluetooth::BluetoothService& bluetooth, bluetooth::BluetoothService& bluetooth,
station::StationService& stations, station::StationService& stations,
integration::IntegrationService& integration, integration::IntegrationService& integration,
ota::OtaService& ota,
core::CompanionChipStatus companionChips, core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity); const core::DeviceIdentity& deviceIdentity);
+9 -6
View File
@@ -101,6 +101,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
bluetooth::BluetoothService& bluetooth, bluetooth::BluetoothService& bluetooth,
station::StationService& stations, station::StationService& stations,
integration::IntegrationService& integration, integration::IntegrationService& integration,
ota::OtaService& ota,
core::CompanionChipStatus companionChips, core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity) const core::DeviceIdentity& deviceIdentity)
{ {
@@ -114,8 +115,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
SetupWebServer webServer; SetupWebServer webServer;
if (auto webResult = if (auto webResult =
webServer.start(store, NetState::SoftApSetup, tuner, audio, webServer.start(store, NetState::SoftApSetup, tuner, audio,
bluetooth, stations, integration, companionChips, bluetooth, stations, integration, ota,
deviceIdentity); companionChips, deviceIdentity);
!webResult) { !webResult) {
return std::unexpected(webResult.error()); return std::unexpected(webResult.error());
} }
@@ -144,6 +145,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
bluetooth::BluetoothService& bluetooth, bluetooth::BluetoothService& bluetooth,
station::StationService& stations, station::StationService& stations,
integration::IntegrationService& integration, integration::IntegrationService& integration,
ota::OtaService& ota,
core::CompanionChipStatus companionChips, core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity) const core::DeviceIdentity& deviceIdentity)
{ {
@@ -165,8 +167,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
SetupWebServer webServer; SetupWebServer webServer;
if (auto webResult = if (auto webResult =
webServer.start(store, NetState::StaConnected, tuner, audio, webServer.start(store, NetState::StaConnected, tuner, audio,
bluetooth, stations, integration, companionChips, bluetooth, stations, integration, ota,
deviceIdentity); companionChips, deviceIdentity);
!webResult) { !webResult) {
return std::unexpected(webResult.error()); return std::unexpected(webResult.error());
} }
@@ -186,6 +188,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
bluetooth::BluetoothService& bluetooth, bluetooth::BluetoothService& bluetooth,
station::StationService& stations, station::StationService& stations,
integration::IntegrationService& integration, integration::IntegrationService& integration,
ota::OtaService& ota,
core::CompanionChipStatus companionChips, core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity) const core::DeviceIdentity& deviceIdentity)
{ {
@@ -199,7 +202,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
if (store.hasWifiCredentials()) { if (store.hasWifiCredentials()) {
auto staResult = startStaMode(store, tuner, audio, bluetooth, stations, auto staResult = startStaMode(store, tuner, audio, bluetooth, stations,
integration, companionChips, integration, ota, companionChips,
deviceIdentity); deviceIdentity);
if (staResult) { if (staResult) {
return staResult; return staResult;
@@ -208,7 +211,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
} }
return startSetupMode(store, tuner, audio, bluetooth, stations, integration, return startSetupMode(store, tuner, audio, bluetooth, stations, integration,
companionChips, deviceIdentity); ota, companionChips, deviceIdentity);
} }
NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp, NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp,
+104 -5
View File
@@ -39,6 +39,9 @@
#include "station/StationService.hpp" #include "station/StationService.hpp"
#include "integration/IntegrationService.hpp" #include "integration/IntegrationService.hpp"
#include "adau1701/FlashDspProgramSource.hpp" #include "adau1701/FlashDspProgramSource.hpp"
#include "ota/OtaService.hpp"
#include "ota/OtaError.hpp"
#include "core/OtaAppDescriptor.hpp"
#include "bt1035/Bt1035Error.hpp" #include "bt1035/Bt1035Error.hpp"
#include "esp_http_server.h" #include "esp_http_server.h"
@@ -48,6 +51,7 @@
#include "freertos/task.h" #include "freertos/task.h"
#include <array> #include <array>
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -733,6 +737,90 @@ esp_err_t dspProgramPostHandler(httpd_req_t* req)
return ESP_OK; 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<std::uint8_t, 4096> chunk{};
int received = 0;
while (received < contentLen) {
const int toRead = std::min(static_cast<int>(chunk.size()),
contentLen - received);
const int n = httpd_req_recv(req,
reinterpret_cast<char*>(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<std::size_t>(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) esp_err_t bluetoothStatusGetHandler(httpd_req_t* req)
{ {
auto* ctx = routeContextFrom(req); auto* ctx = routeContextFrom(req);
@@ -960,7 +1048,8 @@ SetupWebServer::SetupWebServer()
, bluetooth_(nullptr) , bluetooth_(nullptr)
, stations_(nullptr) , stations_(nullptr)
, integration_(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.stations_ = nullptr;
other.integration_ = nullptr; other.integration_ = nullptr;
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, {}, core::DeviceIdentity::unknown()}; nullptr, nullptr, {}, core::DeviceIdentity::unknown()};
} }
SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
@@ -1022,8 +1111,8 @@ SetupWebServer::~SetupWebServer()
httpd_stop(server_); httpd_stop(server_);
server_ = nullptr; server_ = nullptr;
} }
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}, routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
core::DeviceIdentity::unknown()}; nullptr, {}, core::DeviceIdentity::unknown()};
} }
std::expected<void, NetError> SetupWebServer::start( std::expected<void, NetError> SetupWebServer::start(
@@ -1034,6 +1123,7 @@ std::expected<void, NetError> SetupWebServer::start(
bluetooth::BluetoothService& bluetooth, bluetooth::BluetoothService& bluetooth,
station::StationService& stations, station::StationService& stations,
integration::IntegrationService& integration, integration::IntegrationService& integration,
ota::OtaService& ota,
core::CompanionChipStatus companionChips, core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity) const core::DeviceIdentity& deviceIdentity)
{ {
@@ -1054,6 +1144,7 @@ std::expected<void, NetError> SetupWebServer::start(
routeContext_.bluetooth = &bluetooth; routeContext_.bluetooth = &bluetooth;
routeContext_.stations = &stations; routeContext_.stations = &stations;
routeContext_.integration = &integration; routeContext_.integration = &integration;
routeContext_.ota = &ota;
routeContext_.companionChips = companionChips; routeContext_.companionChips = companionChips;
routeContext_.deviceIdentity = deviceIdentity; routeContext_.deviceIdentity = deviceIdentity;
@@ -1064,7 +1155,7 @@ std::expected<void, NetError> SetupWebServer::start(
if (httpd_start(&server_, &config) != ESP_OK) { if (httpd_start(&server_, &config) != ESP_OK) {
ESP_LOGE(kTag, "httpd_start failed"); ESP_LOGE(kTag, "httpd_start failed");
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
{}, core::DeviceIdentity::unknown()}; nullptr, {}, core::DeviceIdentity::unknown()};
return std::unexpected(NetError::HttpServerStartFailed); return std::unexpected(NetError::HttpServerStartFailed);
} }
@@ -1182,6 +1273,14 @@ std::expected<void, NetError> SetupWebServer::start(
}; };
httpd_register_uri_handler(server_, &dspProgramUri); 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 = { const httpd_uri_t bluetoothStatusUri = {
.uri = "/api/bluetooth/status", .uri = "/api/bluetooth/status",
.method = HTTP_GET, .method = HTTP_GET,
@@ -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)
@@ -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
@@ -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 <cstddef>
#include <cstdint>
#include <expected>
#include <span>
#include <vector>
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<void, OtaError> 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<void, OtaError> 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<void, OtaError> writeChunk(
std::span<const std::uint8_t> 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<void, OtaError> 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<std::uint8_t> headerPrefix_;
core::OtaImageError lastImageError_ = core::OtaImageError::InsufficientHeader;
};
} // namespace ota
@@ -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
@@ -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 <algorithm>
namespace ota {
namespace {
constexpr char kTag[] = "OtaService";
constexpr int kMaxOtaImageSize = 0x1B0000;
constexpr std::size_t kHeaderCaptureMax = 512U;
void captureHeaderPrefix(std::vector<std::uint8_t>& prefix,
std::span<const std::uint8_t> 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<void, OtaError> 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<void, OtaError> 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<void*>(static_cast<std::uintptr_t>(handle));
updatePartition_ = updatePartition;
headerPrefix_.clear();
lastImageError_ = core::OtaImageError::InsufficientHeader;
return {};
}
std::expected<void, OtaError> OtaService::writeChunk(
std::span<const std::uint8_t> chunk)
{
if (!active_ || otaHandle_ == nullptr) {
return std::unexpected(OtaError::BeginFailed);
}
if (bytesWritten_ + static_cast<int>(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<esp_ota_handle_t>(
reinterpret_cast<std::uintptr_t>(otaHandle_));
if (esp_ota_write(handle, chunk.data(), chunk.size()) != ESP_OK) {
abort();
return std::unexpected(OtaError::WriteFailed);
}
bytesWritten_ += static_cast<int>(chunk.size());
return {};
}
std::expected<void, OtaError> 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<esp_ota_handle_t>(
reinterpret_cast<std::uintptr_t>(otaHandle_));
if (esp_ota_end(handle) != ESP_OK) {
resetSession();
return std::unexpected(OtaError::EndFailed);
}
const auto* partition =
static_cast<const esp_partition_t*>(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<esp_ota_handle_t>(
reinterpret_cast<std::uintptr_t>(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
+23
View File
@@ -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 200\,KiB; \textbf{500} on flash write failure. Pack blobs with
\texttt{tools/pack\_dsp\_program.py} or \texttt{core::serializeDspProgramBlob()}. \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}} \subsection{\texttt{POST /api/audio/reset}}
\label{sec:api-audio-reset} \label{sec:api-audio-reset}
+8
View File
@@ -323,3 +323,11 @@ Application service for BT1035 pairing and A2DP status
(Chapter~\ref{ch:bt1035}). Delegates to \texttt{Bt1035Driver}; tracks (Chapter~\ref{ch:bt1035}). Delegates to \texttt{Bt1035Driver}; tracks
whether discoverable mode was requested. Exposed on whether discoverable mode was requested. Exposed on
\texttt{/api/bluetooth/*}. \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.
% ------------------------------------------------------------------
+1 -1
View File
@@ -3,5 +3,5 @@ idf_component_register(
"main.cpp" "main.cpp"
"hardware_bootstrap.cpp" "hardware_bootstrap.cpp"
INCLUDE_DIRS "." 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
) )
+8
View File
@@ -15,6 +15,7 @@
#include "bluetooth/BluetoothService.hpp" #include "bluetooth/BluetoothService.hpp"
#include "integration/IntegrationService.hpp" #include "integration/IntegrationService.hpp"
#include "net/NetBootstrap.hpp" #include "net/NetBootstrap.hpp"
#include "ota/OtaService.hpp"
#include "secure_store/NvsSecureStore.hpp" #include "secure_store/NvsSecureStore.hpp"
#include "station/StationService.hpp" #include "station/StationService.hpp"
#include "tuner/TunerService.hpp" #include "tuner/TunerService.hpp"
@@ -78,6 +79,8 @@ extern "C" void app_main()
static bluetooth::BluetoothService bluetoothService( static bluetooth::BluetoothService bluetoothService(
hardware::HardwareBootstrap::bt1035Driver()); hardware::HardwareBootstrap::bt1035Driver());
static ota::OtaService otaService;
auto netResult = net::NetBootstrap::start( auto netResult = net::NetBootstrap::start(
store, store,
tunerService, tunerService,
@@ -85,6 +88,7 @@ extern "C" void app_main()
bluetoothService, bluetoothService,
stationService, stationService,
integration, integration,
otaService,
hardware::HardwareBootstrap::companionChipStatus(), hardware::HardwareBootstrap::companionChipStatus(),
hardware::HardwareBootstrap::deviceIdentity()); hardware::HardwareBootstrap::deviceIdentity());
if (!netResult) { if (!netResult) {
@@ -94,6 +98,10 @@ extern "C" void app_main()
static net::NetBootstrap net = std::move(netResult.value()); 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) if (xTaskCreate(heartbeatTask, "heartbeat", 2048, nullptr, 5, nullptr)
!= pdPASS) { != pdPASS) {
ESP_LOGE(kTag, "heartbeat task create failed"); ESP_LOGE(kTag, "heartbeat task create failed");