From 346cf2fd1f2224533b7e508159f30f2c218bc0a2 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 29 Sep 2026 03:08:53 -0700 Subject: [PATCH] Support compatible RAK bootloader updates with adaptive app storage --- src/helpers/ota/OtaApply.cpp | 19 +++- src/helpers/ota/OtaBlInfo.h | 41 ++++++- src/helpers/ota/OtaContext.h | 4 +- src/helpers/ota/OtaRakStoragePolicy.h | 7 +- src/helpers/ota/OtaStoreAdaptiveNrf52.h | 52 ++++++++- test/test_ota/test_ota_core.cpp | 25 +++++ test/test_rak_adaptive_bootloader.py | 141 ++++++++++++++++++++++++ test/test_rak_storage_policy.py | 8 ++ tools/lora_ota/lora_ota.py | 11 +- tools/lora_ota/test_rak_bootloader.py | 48 ++++++++ tools/mota/motalib.py | 39 ++++++- tools/mota/test_mota.py | 57 +++++++++- 12 files changed, 430 insertions(+), 22 deletions(-) create mode 100644 test/test_rak_adaptive_bootloader.py create mode 100644 tools/lora_ota/test_rak_bootloader.py diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index abf73f9f2..bbdd2b117 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -1257,9 +1257,7 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, } const OtaBlCaps current_caps = ota_bootloader_update_caps(); - if (!ota_bootloader_self_update_caps_valid(current_caps) || - current_caps.storage_flags != (OTA_BL_STORAGE_STAGE_CEILING | - OTA_BL_STORAGE_BOOT_UPDATE)) { + if (!ota_bootloader_self_update_caps_valid(current_caps)) { strcpy(msg, "installed bootloader cannot safely self-update from internal flash"); return false; } @@ -1303,10 +1301,23 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store, !ota_bootloader_identity_matches(installed, candidate) || !ota_bootloader_caps_from_image( image, OTA_BOOT_IMAGE_SIZE, - OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_BOOT_UPDATE, + current_caps.storage_flags, candidate_caps)) { strcpy(msg, "candidate bootloader identity/capability/CRC mismatch"); return false; } + if (current_caps.storage_flags == OTA_BL_PROFILE_RAK_AUTO_BOOT_UPDATE && + (!ota_bootloader_supports_hybrid(ota_ram_caps_scan_aligned(image, OTA_BOOT_IMAGE_SIZE)) || + ota_boot_rd32(image) > MOTA_NRF52_HYBRID_RAM_START)) { + strcpy(msg, "candidate adaptive bootloader must retain the hybrid RAM arena"); return false; + } +#if defined(OTA_RAK_AUTO_STORE) + const uint8_t optional_storage = ota_bl_optional_app_storage( + (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START, OTA_BOOT_IMAGE_SIZE); + if (optional_storage != 0 && + ota_bl_optional_app_storage(image, OTA_BOOT_IMAGE_SIZE) != optional_storage) { + strcpy(msg, "candidate must retain optional RAK application storage"); return false; + } +#endif const OtaBootloaderContinuityGate continuity = ota_bootloader_continuity_gate( installed, candidate, m.fw_version, OTA_BOOT_CONTINUITY_FAMILY_S140, ota_runtime_softdevice_fwid(), mota_nrf52_app_base(), diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h index 80199cdf6..641dac02b 100644 --- a/src/helpers/ota/OtaBlInfo.h +++ b/src/helpers/ota/OtaBlInfo.h @@ -28,6 +28,7 @@ struct OtaBlCaps { uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2) uint8_t storage_flags = 0; // OTA_BL_STORAGE_* capability bits + uint8_t optional_app_storage = 0; // separate MOTASTOR record, never a boot-update backend }; // Independent marker for the reset-retained hybrid source. Keep this separate @@ -93,6 +94,8 @@ static const uint8_t OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE = static const uint8_t OTA_BL_PROFILE_QSPI_BOOT_UPDATE = OTA_BL_STORAGE_STAGE_CEILING | OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_BOOT_UPDATE; +static const uint8_t OTA_BL_PROFILE_RAK_AUTO_BOOT_UPDATE = + OTA_BL_PROFILE_QSPI_BOOT_UPDATE | OTA_BL_STORAGE_HEADER_W25; // A successor must retain both application update paths used by qualified // external stores: bit 0 CODEC_FULL and bit 2 CODEC_DETOOLS_INPLACE. static const uint16_t OTA_BL_REQUIRED_APP_CODEC_MASK = 0x0005u; @@ -116,10 +119,15 @@ inline uint8_t ota_bootloader_update_storage_flags() { inline bool ota_bootloader_self_update_caps_valid(const OtaBlCaps& c) { const uint8_t required = ota_bootloader_update_storage_flags(); + const bool profile_ok = c.storage_flags == required +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) && defined(OTA_RAK_AUTO_STORE) + || c.storage_flags == OTA_BL_PROFILE_RAK_AUTO_BOOT_UPDATE +#endif + ; return required != 0 && c.present && c.apply_abi >= 3u && (c.codec_mask & OTA_BL_REQUIRED_APP_CODEC_MASK) == OTA_BL_REQUIRED_APP_CODEC_MASK && - c.storage_flags == required; + profile_ok; } // Prefer a continuity-capable marker over a numerically newer legacy-looking candidate. Bootloader @@ -209,8 +217,30 @@ inline OtaBlCaps ota_bl_legacy_app_caps_scan_halfword(const uint8_t* bytes, return c; } +// Unified RAK loaders preserve the deployed board's privileged MOTABLDR=0x0A +// contract. A separate record advertises optional APPLICATION storage. It is +// adjacent to the unchanged, unique 64 KiB RAM marker and grants no new +// bootloader staging backend. +inline uint8_t ota_bl_optional_app_storage(const uint8_t* bytes, size_t len) { + static const uint8_t record[32] = { + 'M', 'O', 'T', 'A', 'R', 'A', 'M', 'A', 1, 0, 72, 0, 0, 0, 1, 0, + 'M', 'O', 'T', 'A', 'S', 'T', 'O', 'R', 1, 0, 16, 0, 0x14, 0, 0, 0, + }; + if (!bytes || !ota_bootloader_supports_hybrid(ota_ram_caps_scan_aligned(bytes, len))) return 0; + unsigned matches = 0; + for (size_t off = 0; off + sizeof(record) <= len; off += 4) { + if (memcmp(bytes + off, record, sizeof(record)) == 0 && ++matches > 1) return 0; + } + return matches == 1 ? OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25 : 0; +} + inline OtaBlCaps ota_bl_app_caps_scan(const uint8_t* bytes, size_t len) { - const OtaBlCaps aligned = ota_bl_caps_scan_aligned(bytes, len, false); + OtaBlCaps aligned = ota_bl_caps_scan_aligned(bytes, len, false); + if (aligned.present && aligned.apply_abi >= 3 && + aligned.storage_flags == OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE) { + aligned.optional_app_storage = ota_bl_optional_app_storage(bytes, len); + aligned.storage_flags |= aligned.optional_app_storage; + } if (aligned.present) return aligned; return ota_bl_legacy_app_caps_scan_halfword(bytes, len); } @@ -255,8 +285,15 @@ inline OtaBlCaps ota_bootloader_update_caps() { defined(OTA_SD_BOOTLOADER_UPDATE)) const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START; const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END; +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) && defined(OTA_RAK_AUTO_STORE) + // Unified RAK applications support the existing internal board identity as + // well as the adaptive successor. Both require one privileged marker. + const OtaBlCaps caps = ota_bl_caps_scan_aligned(lo, (size_t)(hi - lo), true); + return ota_bootloader_self_update_caps_valid(caps) ? caps : OtaBlCaps(); +#else return ota_bl_update_caps_scan_aligned( lo, (size_t)(hi - lo), ota_bootloader_update_storage_flags()); +#endif #else return OtaBlCaps(); #endif diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index c0cf8a048..5ae3804c6 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -360,7 +360,7 @@ struct OtaContext { const OtaBootloaderIdentity& installed = bootloaderIdentity(); #if defined(OTA_RAK_AUTO_STORE) if (!fetch_store.usesInternal()) { - strncpy(msg, "bootloader LoRa update needs the internal OTAFIX profile", 96); + strncpy(msg, "bootloader package must be staged in internal flash", 96); msg[95] = 0; return false; } bool ok = ota_prepare_bootloader_update_nrf52( @@ -704,7 +704,7 @@ struct OtaContext { (defined(OTA_QSPI_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || \ defined(OTA_SD_BOOTLOADER_UPDATE)) #if defined(OTA_RAK_AUTO_STORE) - manager.set_accept_bootloader(fetch_store.usesInternal() && + manager.set_accept_bootloader( ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps())); #else manager.set_accept_bootloader(true); diff --git a/src/helpers/ota/OtaRakStoragePolicy.h b/src/helpers/ota/OtaRakStoragePolicy.h index 2eb573d07..e0f8cd5d6 100644 --- a/src/helpers/ota/OtaRakStoragePolicy.h +++ b/src/helpers/ota/OtaRakStoragePolicy.h @@ -15,11 +15,14 @@ inline RakStorageChoice rak_storage_choice(uint8_t detected, bool qspi_bootloader, bool identity_valid, const char* device_name, - bool rak3401) { + bool rak3401, + bool optional_app_storage = false) { if (detected > 3u || detected == 3u) return RakStorageChoice::Unsafe; const char* auto_name = rak3401 ? "3401_AUTO_DFU" : "4631_AUTO_DFU"; + const char* board_name = rak3401 ? "3401_DFU" : "4631_DFU"; const bool merged_bootloader = identity_valid && device_name && - strcmp(device_name, auto_name) == 0; + (strcmp(device_name, auto_name) == 0 || + (optional_app_storage && strcmp(device_name, board_name) == 0)); if (detected == 0u) return !qspi_bootloader || merged_bootloader ? RakStorageChoice::Internal : RakStorageChoice::Unsafe; if (!qspi_bootloader) return RakStorageChoice::Internal; diff --git a/src/helpers/ota/OtaStoreAdaptiveNrf52.h b/src/helpers/ota/OtaStoreAdaptiveNrf52.h index 47ec2b6ca..3b2627091 100644 --- a/src/helpers/ota/OtaStoreAdaptiveNrf52.h +++ b/src/helpers/ota/OtaStoreAdaptiveNrf52.h @@ -14,12 +14,14 @@ namespace mesh { namespace ota { // One RAK application image supports the board's internal OTA store and its -// explicitly matched external NOR. Selection is fixed for the lifetime of an -// app boot; no staged container is ever moved between backends. +// explicitly matched external NOR. Application placement follows the detected +// hardware. Bootloader packages always use internal flash; select the backend +// before begin(), never move a partially fetched container between stores. class OtaStoreAdaptiveNrf52 : public OtaStore { enum Mode : uint8_t { UNSELECTED, INTERNAL_MODE, QSPI_MODE, UNSAFE }; mutable Mode _mode = UNSELECTED; mutable const char* _reason = "not probed"; + bool _bootloader_store = false; // Only one backend is usable for a given board/bootloader pairing. Sharing // their two flash-page buffers saves 8 KiB of scarce nRF52840 runtime RAM. union StoreStorage { @@ -39,6 +41,26 @@ class OtaStoreAdaptiveNrf52 : public OtaStore { _mode = QSPI_MODE; } + bool selectBootloader() { + if (!ota_bootloader_self_update_caps_valid(ota_bootloader_update_caps())) return false; + if (_mode != INTERNAL_MODE) { + if (_mode == QSPI_MODE) _storage.external.~OtaStoreQspiNrf52(); + activateInternal(); + } + _bootloader_store = true; + _reason = "bootloader package in internal flash"; + return true; + } + + void selectApplication() { + if (_bootloader_store) { + _storage.internal.~OtaStoreFlashNrf52(); + _mode = UNSELECTED; + _bootloader_store = false; + } + select(); + } + void select() const { if (_mode != UNSELECTED) return; const uint8_t detected = OtaStoreQspiNrf52::autoDetect(); @@ -66,7 +88,8 @@ class OtaStoreAdaptiveNrf52 : public OtaStore { #endif const RakStorageChoice choice = rak_storage_choice( detected, qspi_bootloader, identity_valid, - identity_valid ? identity.device_name : nullptr, rak3401); + identity_valid ? identity.device_name : nullptr, rak3401, + caps.optional_app_storage == (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25)); if (choice == RakStorageChoice::Internal) { activateInternal(); _reason = detected == 0u ? "no external NOR" : @@ -138,12 +161,31 @@ public: } bool finalize() override { OtaStore* s = active(); return s && s->finalize(); } void checkpoint() override { OtaStore* s = active(); if (s) s->checkpoint(); } - bool reopen() override { OtaStore* s = active(); return s && s->reopen(); } + bool reopen() override { return reopenFor(nullptr, 0u); } bool reopenFor(const uint8_t* mid, uint32_t target) override { - OtaStore* s = active(); return s && s->reopenFor(mid, target); + selectApplication(); + OtaStore* s = active(); + if (s && s->reopenFor(mid, target)) return true; + // Only an explicit MID pull may resume privileged data. Automatic resume + // stays on the application backend. Validate kind before adopting this + // alternative store; the manager repeats all manifest and block checks. + if (!mid || _mode == INTERNAL_MODE || !selectBootloader()) return false; + uint8_t raw[MOTA_MFL]; + MotaManifest manifest; + if (_storage.internal.reopenFor(mid, target) && + _storage.internal.read(8u, raw, sizeof(raw)) && + mota_parse_manifest(raw, sizeof(raw), manifest) && manifest.is_bootloader()) + return true; + selectApplication(); + return false; } bool plan_layout(bool full, uint32_t image, uint32_t payload_off, uint32_t payload_size, bool bootloader) override { + if (bootloader) { + if (!selectBootloader()) return false; + } else { + selectApplication(); + } OtaStore* s = active(); return s && s->plan_layout(full, image, payload_off, payload_size, bootloader); } diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index b9acac5bf..71e33b2a3 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -763,6 +763,31 @@ TEST(OtaHybridHandoff, EncodesExactGeometryAndRejectsCorruption) { MOTA_NRF52_FLASH_PAGE, ram, hash)); } +TEST(OtaBootPackage, OptionalRakApplicationStoragePreservesLegacyBootUpdateContract) { + uint8_t image[128] = {}; + const uint8_t boot[16] = {'M','O','T','A','B','L','D','R', 3,0,5,0,0x0A,0,0,0}; + const uint8_t bundle[32] = { + 'M','O','T','A','R','A','M','A',1,0,72,0,0,0,1,0, + 'M','O','T','A','S','T','O','R',1,0,16,0,0x14,0,0,0, + }; + memcpy(image, boot, sizeof(boot)); + memcpy(image + 32, bundle, sizeof(bundle)); + EXPECT_EQ(ota_bl_app_caps_scan(image, sizeof(image)).storage_flags, 0x1Eu); + EXPECT_EQ(ota_bl_app_caps_scan(image, sizeof(image)).optional_app_storage, 0x14u); + EXPECT_EQ(ota_bl_update_caps_scan_aligned(image, sizeof(image), 0x0A).storage_flags, 0x0Au); + for (unsigned byte : {32u, 40u, 42u, 46u, 48u, 56u, 58u, 60u, 63u}) { + image[byte] ^= 1; + EXPECT_EQ(ota_bl_app_caps_scan(image, sizeof(image)).storage_flags, 0x0Au); + image[byte] ^= 1; + } + memcpy(image + 80, bundle, sizeof(bundle)); + EXPECT_EQ(ota_bl_optional_app_storage(image, sizeof(image)), 0u); + memset(image + 80, 0, sizeof(bundle)); + memset(image + 48, 0, 16); // genuine old board has no optional record + EXPECT_EQ(ota_bl_app_caps_scan(image, sizeof(image)).storage_flags, 0x0Au); + EXPECT_TRUE(ota_bl_update_caps_scan_aligned(image, sizeof(image), 0x0A).present); +} + TEST(OtaBootPackage, LegacyAndCurrentBootloadersHaveSeparateCapabilityViews) { const uint8_t internal_profile = OTA_BL_PROFILE_INTERNAL_BOOT_UPDATE; const uint8_t legacy_marker[16] = { diff --git a/test/test_rak_adaptive_bootloader.py b/test/test_rak_adaptive_bootloader.py new file mode 100644 index 000000000..c60270e65 --- /dev/null +++ b/test/test_rak_adaptive_bootloader.py @@ -0,0 +1,141 @@ +"""Exercise the actual adaptive store with simulated internal and QSPI media.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class AdaptiveBootloaderTest(unittest.TestCase): + def test_storage_switch_and_manual_resume(self): + header = (ROOT / "src/helpers/ota/OtaStoreAdaptiveNrf52.h").read_text() + body = header[header.index("class OtaStoreAdaptiveNrf52"): + header.index("} // namespace ota")] + source = r''' +#include +#include +#include "src/helpers/ota/OtaStore.h" +#include "src/helpers/ota/OtaBlInfo.h" +#include "src/helpers/ota/OtaRakStoragePolicy.h" +using namespace mesh::ota; +namespace mesh { namespace ota { +static bool capable = true, boot_resume = false, app_resume = false; +static unsigned detected = 2, internal_begins = 0, external_begins = 0; +static unsigned internal_live = 0, external_live = 0; +struct OtaBootloaderIdentity { bool crc_ok = true; const char* device_name = "4631_DFU"; }; +bool ota_installed_bootloader_identity(OtaBootloaderIdentity&) { return true; } +struct MotaManifest { bool is_bootloader() const { return boot_resume; } }; +bool mota_parse_manifest(const uint8_t*, size_t, MotaManifest&) { return true; } +OtaBlCaps simulatedCaps() { + OtaBlCaps c; c.present = true; c.apply_abi = capable ? 3 : 2; + c.codec_mask = 5; c.storage_flags = capable ? 0x1e : 0x16; + c.optional_app_storage = capable ? 0x14 : 0; return c; +} +OtaBlCaps simulatedBootCaps() { + auto c = simulatedCaps(); c.storage_flags = capable ? 0x0a : 0x02; + c.optional_app_storage = 0; return c; +} +class Store : public OtaStore { +public: + uint32_t size = 0; + bool begin(uint32_t n) override { size = n; return true; } + bool write(uint32_t, const uint8_t*, uint32_t) override { return true; } + bool read(uint32_t, uint8_t*, uint32_t) const override { return true; } + uint32_t capacity() const override { return 65536; } + uint32_t staged_size() const override { return size; } + void clear() override { size = 0; } +}; +class OtaStoreFlashNrf52 : public Store { +public: + OtaStoreFlashNrf52() { ++internal_live; assert(external_live == 0); } + ~OtaStoreFlashNrf52() { --internal_live; } + bool begin(uint32_t n) override { ++internal_begins; return Store::begin(n); } + bool reopenFor(const uint8_t*, uint32_t) override { return boot_resume; } +}; +class OtaStoreQspiNrf52 : public Store { +public: + OtaStoreQspiNrf52() { ++external_live; assert(internal_live == 0); } + ~OtaStoreQspiNrf52() { --external_live; } + static uint8_t autoDetect() { return detected; } + static bool headerW25Detected() { return true; } + uint32_t jedec_id() const { return 0xef4015; } + uint8_t status1() const { return 0; } + const char* last_stage() const { return "ok"; } + const char* last_error() const { return "ok"; } + bool begin(uint32_t n) override { ++external_begins; return Store::begin(n); } + bool reopenFor(const uint8_t*, uint32_t) override { return app_resume; } +}; +#define ota_bootloader_app_caps simulatedCaps +#define ota_bootloader_update_caps simulatedBootCaps +''' + source += body + source += r''' +} } +int main() { + uint8_t mid[4] = {1, 2, 3, 4}; + { + OtaStoreAdaptiveNrf52 store; + assert(store.usesExternal()); + assert(store.plan_layout(true, 40960, 365, 40960, true)); + assert(store.usesInternal()); + assert(store.begin(41330)); + assert(internal_begins == 1 && external_begins == 0); + assert(store.plan_layout(true, 100000, 365, 100000, false)); + assert(store.usesExternal()); + assert(store.begin(100370)); + assert(external_begins == 1); + boot_resume = true; + assert(!store.reopenFor(nullptr, 0)); + assert(store.usesExternal()); + assert(store.reopenFor(mid, 0)); + assert(store.usesInternal()); + boot_resume = false; + assert(!store.reopenFor(mid, 0)); + assert(store.usesExternal()); + app_resume = true; + assert(store.reopenFor(mid, 0)); + assert(store.usesExternal()); + capable = false; + assert(!store.plan_layout(true, 40960, 365, 40960, true)); + assert(store.usesExternal()); + } + assert(internal_live == 0 && external_live == 0); + capable = true; detected = 0; + { + OtaStoreAdaptiveNrf52 store; + assert(store.usesInternal()); + assert(store.plan_layout(true, 40960, 365, 40960, true)); + assert(store.begin(41330)); + assert(store.plan_layout(false, 40960, 365, 40960, false)); + assert(store.usesInternal()); + } + assert(internal_live == 0 && external_live == 0); + for (uint8_t flags : {uint8_t(0x0a), uint8_t(0x1e)}) { + OtaBlCaps c = simulatedCaps(); c.storage_flags = flags; + assert(ota_bootloader_self_update_caps_valid(c)); + c.apply_abi = 2; assert(!ota_bootloader_self_update_caps_valid(c)); + } + for (uint8_t flags : {uint8_t(0x09), uint8_t(0x0e), uint8_t(0x16), uint8_t(0x1f)}) { + OtaBlCaps c = simulatedCaps(); c.storage_flags = flags; + assert(!ota_bootloader_self_update_caps_valid(c)); + } +} +''' + source = "#include \n" + source + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / "adaptive.cpp" + exe = Path(directory) / "adaptive" + cpp.write_text(source) + subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DOTA_RAK_AUTO_STORE", "-DOTA_INTERNAL_BOOTLOADER_UPDATE", + "-I", str(ROOT), str(cpp), "-o", str(exe), + ], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rak_storage_policy.py b/test/test_rak_storage_policy.py index 15f54bed4..c9ea1c62d 100644 --- a/test/test_rak_storage_policy.py +++ b/test/test_rak_storage_policy.py @@ -25,6 +25,14 @@ int main() { const auto Q = RakStorageChoice::Qspi; const auto U = RakStorageChoice::Unsafe; for (bool rak3401 : {false, true}) { + const char* board = rak3401 ? "3401_DFU" : "4631_DFU"; + assert(rak_storage_choice(0, true, true, board, rak3401, true) == I); + assert(rak_storage_choice(2, true, true, board, rak3401, true) == Q); + assert(rak_storage_choice(2, true, true, board, rak3401, false) == U); + assert(rak_storage_choice(1, true, true, board, rak3401, true) == (rak3401 ? U : Q)); + assert(rak_storage_choice(3, true, true, board, rak3401, true) == U); + assert(rak_storage_choice(2, true, true, + rak3401 ? "4631_DFU" : "3401_DFU", rak3401, true) == U); assert(rak_storage_choice(0, false, false, nullptr, rak3401) == I); assert(rak_storage_choice(0, true, false, nullptr, rak3401) == U); assert(rak_storage_choice(0, true, true, diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index eef34b747..256d4c530 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -1089,12 +1089,19 @@ def compatible_mota(info: MotaInfo, target: TargetInfo) -> tuple[bool, str]: return False, "bootloader self-update requires installed ABI 3 or newer" if target.bootloader_codecs != 0x5: return False, "bootloader self-update requires FULL|INPLACE codecs 0x5" - expected_storage = 0x09 if target.nrf_sd else 0x0E if target.nrf_qspi else 0x0A + adaptive_bootloader = target.boot_hw_id in ( + "NRF_BL_239A0029_3401_AUTO_DFU", "NRF_BL_239A0029_4631_AUTO_DFU", + ) + internal_rak_bootloader = target.boot_hw_id in ( + "NRF_BL_239A0029_3401_DFU", "NRF_BL_239A0029_4631_DFU", + ) + expected_storage = (0x0A if internal_rak_bootloader else 0x1E if adaptive_bootloader else + 0x09 if target.nrf_sd else 0x0E if target.nrf_qspi else 0x0A) if ( target.boot_storage != expected_storage or info.bootloader_storage != expected_storage ): - return False, "bootloader package, installed capabilities, and application storage differ" + return False, "bootloader package and installed capabilities differ" if info.block_size > target.max_block_size: return False, f"bootloader block exceeds destination maxblk:{target.max_block_size}" return True, "" diff --git a/tools/lora_ota/test_rak_bootloader.py b/tools/lora_ota/test_rak_bootloader.py new file mode 100644 index 000000000..ff2cd20c9 --- /dev/null +++ b/tools/lora_ota/test_rak_bootloader.py @@ -0,0 +1,48 @@ +"""Adaptive bootloader packages use internal flash regardless of app storage.""" + +from dataclasses import replace +import unittest + +import lora_ota as ota + + +class RakBootloaderCompatibilityTest(unittest.TestCase): + def fixture(self, name, target_id, external): + hw = f"NRF_BL_239A0029_{name}_DFU" + package = ota.MotaInfo( + path=None, blob=b"", flags=ota.MOTA_FLAG_FULL | ota.MOTA_FLAG_BOOTLOADER, + target_id=target_id, fw_version=0x02040A02, image_size=40960, + payload_size=40960, block_size=1024, merkle_root=b"\0" * 4, + image_hash=b"\0" * 8, codec_id=0, hw_id=hw, + base_hash=b"\0" * 8, payload_offset=365, bootloader_storage=0x0A, + ) + target = ota.TargetInfo( + name=name, target_id=1, base_hash=b"\0" * 8, platform="nrf52", + nrf_sd=False, nrf_qspi=external, hw_id=name, + bootloader_version="OTAFIX2.4.10-preview.1", bootloader_abi=3, + bootloader_codecs=5, status="", self_status="", + boot_target_id=target_id, boot_hw_id=hw, boot_storage=0x0A, + ) + return package, target + + def test_internal_boot_update_with_either_application_backend(self): + for name, target_id in (("3401", 0x23818A80), ("4631", 0x2D0DF000)): + for external in (False, True): + with self.subTest(board=name, external=external): + package, target = self.fixture(name, target_id, external) + self.assertTrue(ota.compatible_mota(package, target)[0]) + for flags in (0x1E, 0x0E, 0x16): + self.assertFalse(ota.compatible_mota( + replace(package, bootloader_storage=flags), target)[0]) + self.assertFalse(ota.compatible_mota( + package, replace(target, boot_storage=flags))[0]) + self.assertFalse(ota.compatible_mota( + package, replace(target, bootloader_abi=2))[0]) + self.assertFalse(ota.compatible_mota( + package, replace(target, boot_hw_id="NRF_BL_239A0029_4631_AUTO_DFU"))[0]) + self.assertFalse(ota.compatible_mota( + package, replace(target, boot_target_id=target_id ^ 1))[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mota/motalib.py b/tools/mota/motalib.py index a80370f45..820105b21 100644 --- a/tools/mota/motalib.py +++ b/tools/mota/motalib.py @@ -108,11 +108,17 @@ BOOT_STORAGE_SD = 0x01 BOOT_STORAGE_STAGE_CEILING = 0x02 XIAO_BOOT_STORAGE_QSPI = 0x04 XIAO_BOOT_STORAGE_UPDATE = 0x08 -BOOT_STORAGE_KNOWN = 0x0F +BOOT_STORAGE_HEADER_W25 = 0x10 +BOOT_STORAGE_KNOWN = 0x1F BOOT_STORAGE_SD_UPDATE = BOOT_STORAGE_SD | XIAO_BOOT_STORAGE_UPDATE BOOT_STORAGE_QSPI_UPDATE = (BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_QSPI | XIAO_BOOT_STORAGE_UPDATE) BOOT_STORAGE_INTERNAL_UPDATE = BOOT_STORAGE_STAGE_CEILING | XIAO_BOOT_STORAGE_UPDATE +BOOT_STORAGE_RAK_AUTO_UPDATE = BOOT_STORAGE_QSPI_UPDATE | BOOT_STORAGE_HEADER_W25 +RAK_AUTO_BOOTLOADER_IDENTITIES = ( + (0x239A0029, "3401_AUTO_DFU"), + (0x239A0029, "4631_AUTO_DFU"), +) BOOT_REQUIRED_APP_CODEC_MASK = ((1 << CODEC_FULL) | (1 << CODEC_DETOOLS_INPLACE)) @@ -696,7 +702,7 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict: identities = ( (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"), - ) + INTERNAL_BOOTLOADER_IDENTITIES + ) + INTERNAL_BOOTLOADER_IDENTITIES + RAK_AUTO_BOOTLOADER_IDENTITIES app_ids = set(application_target_ids) targets = {} for identity in identities: @@ -714,6 +720,7 @@ def audit_bootloader_target_inventory(application_target_ids=()) -> dict: def bootloader_identity_is_buildable(board_id: int, device_name: str) -> bool: return ((board_id, device_name) in INTERNAL_BOOTLOADER_IDENTITIES or + (board_id, device_name) in RAK_AUTO_BOOTLOADER_IDENTITIES or (board_id, device_name) in ( (XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU"))) @@ -734,6 +741,8 @@ def bootloader_qualified_platform_profile(board_id: int, device_name: str): def bootloader_qualified_storage_profiles(board_id: int, device_name: str): """Return the exact allowed capability-marker profiles for one identity.""" identity = (board_id, device_name) + if identity in RAK_AUTO_BOOTLOADER_IDENTITIES: + return (BOOT_STORAGE_RAK_AUTO_UPDATE,) if identity in ((XIAO_BOOT_BOARD_ID_BASE, "XIAO_DFU"), (XIAO_BOOT_BOARD_ID_SENSE, "XIAO_DFU")): return (BOOT_STORAGE_QSPI_UPDATE,) @@ -836,7 +845,7 @@ def bootloader_caps_storage(image: bytes) -> Optional[int]: if valid_count != 1: return None if storage in (BOOT_STORAGE_SD_UPDATE, BOOT_STORAGE_QSPI_UPDATE, - BOOT_STORAGE_INTERNAL_UPDATE): + BOOT_STORAGE_INTERNAL_UPDATE, BOOT_STORAGE_RAK_AUTO_UPDATE): found = storage return found if valid_count == 1 else None @@ -845,6 +854,19 @@ def xiao_bootloader_caps_ok(image: bytes) -> bool: return bootloader_caps_storage(image) == BOOT_STORAGE_QSPI_UPDATE +def bootloader_optional_app_storage(image: bytes) -> int: + record = (b"MOTARAMA" + struct.pack(" BootloaderIdentity: if len(image) != XIAO_BOOT_IMAGE_SIZE: @@ -876,6 +898,17 @@ def validate_bootloader_image(image: bytes, target_id: Optional[int] = None, if actual_storage not in expected_storage: expected = "/".join(f"0x{value:02X}" for value in expected_storage) raise ValueError(f"bootloader lacks exact ABI 3 self-update capabilities {expected}") + optional_storage = bootloader_optional_app_storage(image) + if optional_storage and (actual_storage != BOOT_STORAGE_INTERNAL_UPDATE or + identity.board_id != 0x239A0029 or + identity.device_name not in ("3401_DFU", "4631_DFU")): + raise ValueError("optional RAK application storage requires the deployed board identity") + if actual_storage == BOOT_STORAGE_RAK_AUTO_UPDATE or optional_storage: + ram_marker = b"MOTARAMA" + struct.pack(" 0x20030000: + raise ValueError("adaptive bootloader must retain its 64 KiB hybrid RAM arena") expected_platform = bootloader_qualified_platform_profile( identity.board_id, identity.device_name) if expected_platform is not None: diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index eb55a8261..0cf55f79f 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -1500,7 +1500,7 @@ def test_xiao_bootloader_caps_rejects_malformed_or_unaligned_markers(): malformed_decoy = bytearray(caps()) struct.pack_into("<8sHHB3s", malformed_decoy, 24, ml.XIAO_BOOT_CAPS_MAGIC, ml.BOOT_FORMAT_VER, - ml.BOOT_REQUIRED_APP_CODEC_MASK, 0x1C, b"\0\0\0") + ml.BOOT_REQUIRED_APP_CODEC_MASK, 0x2C, b"\0\0\0") assert ml.bootloader_caps_storage(bytes(malformed_decoy)) == \ ml.BOOT_STORAGE_QSPI_UPDATE @@ -1672,7 +1672,9 @@ def test_bootloader_build_inventory_is_unique_and_disjoint_from_app_targets(): for value in re.findall(r"\{ 0x([0-9a-fA-F]{8}),", target_header) } audited = ml.audit_bootloader_target_inventory(app_targets) - assert len(audited) == len(expected) + 2 # generic internal plus two legacy XIAOs + assert len(audited) == len(expected) + 4 # internal, two XIAOs, two adaptive RAKs + assert audited[0xD04AB3AB] == (0x239A0029, "3401_AUTO_DFU") + assert audited[0xFEEAFD1B] == (0x239A0029, "4631_AUTO_DFU") # Generic parsing remains useful for inspecting a future candidate, but a # signing/build call fails closed until its exact identity is qualified. @@ -1762,6 +1764,57 @@ def test_qualified_bootloader_platform_and_storage_profiles_are_exact(): pass +def test_adaptive_bootloader_preserves_update_and_hybrid_capabilities(): + for name in ("3401_AUTO_DFU", "4631_AUTO_DFU"): + image = _generic_bootloader_image( + device_name=name, storage=ml.BOOT_STORAGE_RAK_AUTO_UPDATE) + marker = b"MOTARAMA" + struct.pack("