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
@@ -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