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:
@@ -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
|
||||
Reference in New Issue
Block a user