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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<SoftApHost> softAp,
|
||||
|
||||
@@ -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 <array>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -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<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)
|
||||
{
|
||||
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<void, NetError> SetupWebServer::start(
|
||||
@@ -1034,6 +1123,7 @@ std::expected<void, NetError> 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<void, NetError> 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<void, NetError> 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<void, NetError> 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,
|
||||
|
||||
Reference in New Issue
Block a user