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:
@@ -37,6 +37,7 @@ idf_component_register(
|
||||
"src/DspProgram.cpp"
|
||||
"src/RegisterWrite.cpp"
|
||||
"src/DspProgramBlob.cpp"
|
||||
"src/OtaAppDescriptor.cpp"
|
||||
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}/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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user