From eebba0cb9a7e10cf7662dd2159b22b9fed6d9170 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 15:10:44 -0700 Subject: [PATCH] Add opt-in RAK15002 SD firmware seeder with isolated SPI and cache regression tests --- .github/workflows/run-unit-tests.yml | 8 +- build_legacy.sh | 11 +- docs/ota_nrf52_qspi.md | 35 +++ scripts/build_local_release.py | 4 + src/helpers/CommonCLI.cpp | 5 + src/helpers/ota/OtaCacheSdNrf52.cpp | 4 + src/helpers/ota/OtaCli.cpp | 13 +- src/helpers/ota/OtaContext.h | 16 +- src/helpers/ota/OtaSdSpiBusNrf52.h | 45 +++ src/helpers/ota/OtaStoreSdNrf52.cpp | 10 +- .../sensors/EnvironmentSensorManager.cpp | 4 +- test/fixtures/ota_sd_cache/mocks/SdFat.h | 140 +++++++++ test/fixtures/ota_sd_cache/test.cpp | 280 ++++++++++++++++++ test/fixtures/rak15002_sd_seeder/cli.cpp | 107 +++++++ test/fixtures/rak15002_sd_seeder/context.cpp | 123 ++++++++ test/test_build_profiles.sh | 7 +- test/test_local_release_full_selection.py | 9 + test/test_ota_sd_cache.py | 63 ++++ test/test_rak15002_sd_seeder.py | 186 ++++++++++++ variants/rak4631/platformio.ini | 23 ++ variants/rak4631/variant.cpp | 5 + 21 files changed, 1082 insertions(+), 16 deletions(-) create mode 100644 src/helpers/ota/OtaSdSpiBusNrf52.h create mode 100644 test/fixtures/ota_sd_cache/mocks/SdFat.h create mode 100644 test/fixtures/ota_sd_cache/test.cpp create mode 100644 test/fixtures/rak15002_sd_seeder/cli.cpp create mode 100644 test/fixtures/rak15002_sd_seeder/context.cpp create mode 100644 test/test_ota_sd_cache.py create mode 100644 test/test_rak15002_sd_seeder.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 04308979d..a11780519 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -34,9 +34,13 @@ jobs: - name: Setup Build Environment uses: ./.github/actions/setup-build-environment - name: Verify nRF52 HEX packaging with installed build dependencies - run: python3 -B test/test_nrf52_endf_hex.py -v + run: | + python3 -B test/test_nrf52_endf_hex.py -v + python3 -B test/test_endf_restamp.py -v - name: Compile GPS Sensor tracker on nRF52 boards run: pio run -e t1000e_sensor -e RAK_WisMesh_Tag_sensor + - name: Compile optional RAK15002 SD archive (no hardware qualification) + run: pio run -e RAK_4631_repeater_rak15002_sd_seeder_only esp32-sensor-smoke: runs-on: ubuntu-latest @@ -687,6 +691,8 @@ jobs: run: | python3 -B test/test_nrf52_flash_trim.py -v python3 -B test/test_nrf52_tft_font_trim.py -v + python3 -B test/test_ota_sd_cache.py -v + python3 -B test/test_rak15002_sd_seeder.py -v python3 -B test/test_ota_target_names.py -v python3 -B test/test_ota_cli_listing.py -v diff --git a/build_legacy.sh b/build_legacy.sh index ade2a87f7..7af15515c 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3081,6 +3081,11 @@ get_reduced_lora_ota_target() { } is_nrf52_sensor_ota_pair_target() { + # Optional SD archives serve other nodes; never turn their source-only + # contract into the ordinary Full/Reduced self-updating receiver pair. + case "${1,,}" in + rak_4631_repeater_rak15002_sd_seeder_only) return 1 ;; + esac [ "${PIO_ENV_PLATFORM_BY_NAME[$1]:-}" = "NRF52_PLATFORM" ] \ && ! is_companion_build "$1" \ && ! is_kiss_modem_target "$1" \ @@ -5643,11 +5648,11 @@ is_runtime_setting_alias_target() { } is_redundant_bulk_build_target() { - # Bench fixtures and migration utilities use their dedicated PlatformIO - # recipes; they are not node firmware for the release/OTA packaging matrix. + # Bench fixtures, migration utilities and the unqualified optional SD + # source use exact recipes, not the ordinary release/OTA receiver matrix. case "${1,,}" in profile_switch_*|profile_fixed_*|profile_four_tx_v4_rx|*partition_migrator*|*partition_expander*|*_partition_legacy_seed|\ - *_legacy_partition_test|*_sim) + *_legacy_partition_test|*_sim|rak_4631_repeater_rak15002_sd_seeder_only) return 0 ;; esac diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index 9797276da..95af4196e 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -380,3 +380,38 @@ OTAFIX enters USB/BLE recovery rather than booting a partial image. See [Easy firmware updates over LoRa](ota_easy.md) for the transfer commands and [the OTA protocol](ota_protocol.md) for the container and handoff details. + +## Experimental RAK15002 SD archive (source-only) + +`RAK_4631_repeater_rak15002_sd_seeder_only` is an optional RAK4631 repeater +with a RAK15002 in the base board's IO slot. It keeps the reduced sensor/GPS, +OLED and BLE recovery features. It automatically caches up to **16** complete +peer `.mota` files on SD and serves them after reboot; the card also retains +interrupted captures for verified resume. It is not an ordinary OTA receiver +and is excluded from automatic Full/Reduced receiver releases. This profile +has host regression/build checks but **has not been physically tested with +a RAK15002**. + +The [official RAK15002 datasheet](https://docs.rakwireless.com/product-categories/wisblock/rak15002/datasheet/) +specifies connector pins 25/26/27/28 for CS/SCK/MISO/MOSI, pin 38 for +active-low card detection (IO6), and pin 5 for unswitched 3V3. On the RAK4631 +these SPI signals map to Arduino GPIO 26/3/29/30. A separate SPIM2 object at +4 MHz accesses SD; global SPI/SPIM3 remains dedicated to the radio. This exact +SD module does not need the GPS-only switched sensor rail held on. The card's +3V3 supply is not software-switchable, so its idle current remains a hardware +consideration. Card detection is not driven by this profile; absent or failed +media reports an archive error and is retried without blocking USB startup. + +Do not combine this profile with RAK13800 Ethernet, RAK15001, or another +external SPI/QSPI module. Compile-time guards reject the known conflicting +firmware combinations; software cannot identify every physically connected +module. Use a FAT/exFAT card supported by the pinned SdFat library. Existing +files outside `/mota` are left alone; automatic capture reserves 8 MiB free +space for manual staging/work rather than filling the card. + +Use `ota cache` for status, `ota cache off` to stop new captures (existing +files still seed), `ota cache on` to resume, and `ota announce` to advertise. +Source-only means `ota install`, bootloader installation, and `ota dev` apply +are disabled even if the installed bootloader supports SD. No RAK15002 SD +bootloader handoff is claimed. Update this trial firmware over USB/BLE; normal +RAK4631 receiver targets and MeshTower SD self-update are unchanged. diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index 806fdbb3b..f3fefe21a 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -259,6 +259,10 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: # host-modem role and the ordinary release command skips it separately. def ordinary_record(record: dict) -> bool: manifest = record["manifest"] + if manifest["target"].lower() == "rak_4631_repeater_rak15002_sd_seeder_only": + # An exact opt-in, source-only hardware trial is not a qualified + # self-updating nRF52 release, including when resuming old output. + return False if manifest["platform"] != "ESP32_PLATFORM": return True target = manifest["target"].lower() diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f330652e9..18ce5ab1a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3632,6 +3632,9 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { return true; } + // Heap-backed source-only builds may not have an OTA workspace yet. Never + // dereference it (or start card maintenance) after an allocation failure. + if (!mesh::ota::ota_acquire_context(reply, 160)) return true; mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); if (context.apply_pending) { strcpy(reply, "Error: OTA apply is pending"); @@ -3730,6 +3733,7 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { _sdcard_erase_at, now); snprintf(reply, 160, "> erase: %s", age); } else if (strcmp(query, "free") == 0) { + if (!mesh::ota::ota_acquire_context(reply, 160)) return true; uint64_t used_bytes = 0; uint64_t free_bytes = 0; mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); @@ -3761,6 +3765,7 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { } page = parsed; } + if (!mesh::ota::ota_acquire_context(reply, 160)) return true; mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore(); if (!store.listFiles(*_board, (uint16_t)page, reply, 160)) { snprintf(reply, 160, "Error: SD card list failed: %s", store.last_error()); diff --git a/src/helpers/ota/OtaCacheSdNrf52.cpp b/src/helpers/ota/OtaCacheSdNrf52.cpp index ad4fd722b..41839a882 100644 --- a/src/helpers/ota/OtaCacheSdNrf52.cpp +++ b/src/helpers/ota/OtaCacheSdNrf52.cpp @@ -219,6 +219,10 @@ bool OtaCacheSdNrf52::begin(uint32_t total_size) { pathFor(_mid, "mota", final_path, sizeof(final_path)); pathFor(_mid, "part", part_path, sizeof(part_path)); if (contains(_mid)) { fail("SD archive already contains this mOTA"); return false; } + // The index is also the advertised archive capacity. Do not spend LoRa + // airtime/card space on a new container that this build cannot seed. Check + // before quarantining or replacing any existing final/partial bytes. + if (_count >= OTA_SD_CACHE_MAX) { fail("SD archive entry limit reached"); return false; } if (_owner->_sd->exists(final_path) && !quarantineFinal(_mid)) return false; // begin() means resumeStaged() already rejected any old partial. Drop it before measuring space so a // corrupt/obsolete .part is not double-counted against the new allocation and the install reserve. diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 6a6ba974b..2a41316d0 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -407,7 +407,11 @@ static __attribute__((noinline)) bool handle_control_command( #if defined(OTA_SEEDER_ONLY) snprintf(reply, 160, "OTA seeder: status | stats | ls=find images | get folder=capture | cancel | " - "announce | folder | config. LoRa install is disabled."); + "announce | folder | " +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + "cache | " +#endif + "config. LoRa install is disabled."); #elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ !defined(OTA_QSPI_STORE) #if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) @@ -817,7 +821,7 @@ static __attribute__((noinline)) bool handle_control_command( } else if (is_cmd(a, "announce|adv", &rest)) { #if defined(OTA_SEEDER_ONLY) c.manager.announce(); - snprintf(reply, 160, "OK beacon sent (serving=%u host mOTA)", + snprintf(reply, 160, "OK beacon sent (serving=%u external mOTA)", (unsigned)c.manager.servedCount()); #else if (c.self_serve_supported && !c.serving) c.serving = ota_serve_self(c, 0); @@ -875,7 +879,10 @@ static __attribute__((noinline)) bool handle_control_command( #if defined(NRF52_PLATFORM) // nRF52 applies via the bootloader, so surface whether THIS device's bootloader can install this store. const OtaBlCaps& bl = c.bootloaderAppCaps(); // cached (flash scanned once) -#if defined(OTA_TOWER_AUTO_STORE) +#if defined(OTA_SEEDER_ONLY) + (void)bl; + snprintf(reply + n, 160 - n, " | source-only; local install disabled"); +#elif defined(OTA_TOWER_AUTO_STORE) snprintf(reply + n, 160 - n, " | store:%s | bootloader:%s", c.fetch_store.usesExternal() ? "SD" : c.fetch_store.usesInternal() ? "internal" : "unsafe", c.fetch_store.compatible() ? "apply OK" : "unsupported"); diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 031a0d1e6..9329347a7 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -114,6 +114,12 @@ struct OtaContext { // store object for OtaManager, while folder captures replace it with the // host-backed FolderMotaStore for the duration of the pull. OtaStoreRam<1> fetch_store; +#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) + // An archive is not an installation backend. Source-only boards retain a + // separate SD owner for the shared FAT volume without granting SD apply. + OtaStoreSdNrf52 sd_archive_store; + OtaCacheSdNrf52 sd_cache; +#endif #elif defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) OtaStoreTowerNrf52 fetch_store; // SD primary, qualified internal delta fallback OtaCacheSdNrf52 sd_cache; // always owns the stable SD store @@ -681,7 +687,9 @@ struct OtaContext { #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) OtaStoreSdNrf52& sdStagingStore() { -#if defined(OTA_TOWER_AUTO_STORE) +#if defined(OTA_SEEDER_ONLY) + return sd_archive_store; +#elif defined(OTA_TOWER_AUTO_STORE) return fetch_store.sdStore(); #else return fetch_store; @@ -819,7 +827,11 @@ struct OtaContext { if (!sd_cache.autoCaptureEnabled() || apply_pending || manager.fetchState() != OtaManager::IDLE || (_sd_cache_next_at && (int32_t)(now - _sd_cache_next_at) < 0)) return; - for (uint8_t i = 0; i < manager.catalogCount(); i++) { + // The index is also the publication limit: do not download containers + // that cannot be served. contains() can discover a valid file and grow + // the index, so recheck capacity before considering each catalog row. + for (uint8_t i = 0; i < manager.catalogCount() && + sd_cache.capturedCount() < OTA_SD_CACHE_MAX; i++) { const OtaManager::CatRow* row = manager.catalogRow(i); if (!row || row->have_max == 0 || sd_cache.contains(row->mid)) continue; if (!manager.catalogReady(row, now)) continue; diff --git a/src/helpers/ota/OtaSdSpiBusNrf52.h b/src/helpers/ota/OtaSdSpiBusNrf52.h new file mode 100644 index 000000000..2182c6fcb --- /dev/null +++ b/src/helpers/ota/OtaSdSpiBusNrf52.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#if defined(OTA_RAK15002_SD_CACHE) + #if !defined(NRF52_PLATFORM) || !defined(RAK_4631) || !defined(OTA_SD_STORE) || !defined(OTA_SEEDER_ONLY) + #error "RAK15002 SD cache requires the exact nRF52 RAK4631 seeder-only profile" + #endif + #if defined(ETHERNET_ENABLED) || defined(OTA_QSPI_STORE) || defined(OTA_RAK_AUTO_STORE) || defined(OTA_TOWER_AUTO_STORE) + #error "RAK15002 SD cache cannot share the WisBlock SPI/CS with Ethernet or OTA NOR" + #endif + #if SPI_INTERFACES_COUNT != 1 || (defined(SPI_32MHZ_INTERFACE) && SPI_32MHZ_INTERFACE != 0) + #error "RAK15002 SD cache requires radio SPI on SPIM3 and reserves SPIM2 for the SD card" + #endif + #if defined(OTA_SD_BOOTLOADER_UPDATE) || defined(OTA_INTERNAL_BOOTLOADER_UPDATE) || defined(OTA_QSPI_BOOTLOADER_UPDATE) + #error "RAK15002 cache profile has no qualified bootloader-update handoff" + #endif + #ifndef OTA_SD_CS_PIN + #define OTA_SD_CS_PIN 26 + #endif + static_assert(OTA_SD_CS_PIN == 26 && PIN_SPI_MISO == 29 && PIN_SPI_SCK == 3 && PIN_SPI_MOSI == 30, + "RAK15002 exact WisBlock SPI pin map changed; requalify the profile"); +#elif !defined(OTA_SD_CS_PIN) + #define OTA_SD_CS_PIN PIN_SPI1_NSS +#endif + +namespace mesh { namespace ota { + +inline SPIClass& ota_sd_spi_bus() { +#if defined(OTA_RAK15002_SD_CACHE) + // The RAK radio reassigns global SPI/SPIM3 to its private pins. Never use + // that object for the external WisBlock bus, or reconfigure it on SD mount. + static SPIClass sd_spi(NRF_SPIM2, 29, 3, 30); + return sd_spi; +#else + return SPI1; // unchanged MeshTower SD bus +#endif +} + +inline void ota_sd_prepare_chip_select() { + digitalWrite(OTA_SD_CS_PIN, HIGH); // preload latch before enabling output + pinMode(OTA_SD_CS_PIN, OUTPUT); +} + +} } // namespace mesh::ota diff --git a/src/helpers/ota/OtaStoreSdNrf52.cpp b/src/helpers/ota/OtaStoreSdNrf52.cpp index 4497b074c..2805af410 100644 --- a/src/helpers/ota/OtaStoreSdNrf52.cpp +++ b/src/helpers/ota/OtaStoreSdNrf52.cpp @@ -15,11 +15,9 @@ #include "OtaSdAuthRecord.h" #include "OtaSdBootToken.h" #include "OtaSelf.h" +#include "OtaSdSpiBusNrf52.h" #include "flash/flash_nrf5x.h" -#ifndef OTA_SD_CS_PIN -#define OTA_SD_CS_PIN PIN_SPI1_NSS -#endif #ifndef OTA_SD_SCK_MHZ #define OTA_SD_SCK_MHZ 8 #endif @@ -90,9 +88,10 @@ void OtaStoreSdNrf52::resetStoreState() { bool OtaStoreSdNrf52::beginCardOnly() { _error[0] = 0; resetStoreState(); + ota_sd_prepare_chip_select(); if (!_sd || !_file || !_sd->cardBegin(SdSpiConfig(OTA_SD_CS_PIN, DEDICATED_SPI, - SD_SCK_MHZ(OTA_SD_SCK_MHZ), &SPI1))) { + SD_SCK_MHZ(OTA_SD_SCK_MHZ), &ota_sd_spi_bus()))) { fail("SD card init failed"); return false; } @@ -102,8 +101,9 @@ bool OtaStoreSdNrf52::beginCardOnly() { bool OtaStoreSdNrf52::mount() { if (_mounted) return true; _error[0] = 0; + ota_sd_prepare_chip_select(); if (!_sd || !_file || !_sd->begin(SdSpiConfig(OTA_SD_CS_PIN, DEDICATED_SPI, - SD_SCK_MHZ(OTA_SD_SCK_MHZ), &SPI1))) { + SD_SCK_MHZ(OTA_SD_SCK_MHZ), &ota_sd_spi_bus()))) { fail("SD mount failed"); return false; } diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 6d0088c60..6d5618554 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1363,9 +1363,11 @@ uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds() { || (w25_id != 0 && w25_id != 0xFFFFFFUL)) { holds |= mesh::gps::ExternalStorage; } -#elif defined(OTA_QSPI_SHARED_WISBLOCK_SPI) || defined(OTA_SD_STORE) +#elif defined(OTA_QSPI_SHARED_WISBLOCK_SPI) || (defined(OTA_SD_STORE) && !defined(OTA_RAK15002_SD_CACHE)) holds |= mesh::gps::ExternalStorage; #endif + // Exact RAK15002 uses connector pin 5 (unswitched 3V3), not 3V3_S. Its + // archive must not accidentally keep a GPS-only sensor rail powered. return holds; } #endif diff --git a/test/fixtures/ota_sd_cache/mocks/SdFat.h b/test/fixtures/ota_sd_cache/mocks/SdFat.h new file mode 100644 index 000000000..9c7892d3f --- /dev/null +++ b/test/fixtures/ota_sd_cache/mocks/SdFat.h @@ -0,0 +1,140 @@ +#pragma once + +// In-memory filesystem boundary only. Cache/container/manifest code remains +// production code; this fixture never opens a host file or a real SD card. +#include +#include +#include +#include +#include +#include + +constexpr int O_RDONLY = 1, O_WRONLY = 2, O_RDWR = 4; +constexpr int O_CREAT = 8, O_TRUNC = 16, O_EXCL = 32; +namespace sd_test { +struct Node { + bool directory = false; + std::vector bytes; +}; +struct Medium { + std::map nodes; + bool connected = true, mount_ok = true, rename_ok = true; + bool file_sync_ok = true, remove_ok = true, preallocate_ok = true; + bool write_ok = true; + unsigned card_sync_calls = 0, fail_card_sync_at = 0; + uint32_t clusters = 32768, free_clusters = 16384, cluster_bytes = 4096; + void reset() { *this = Medium(); } +}; +inline Medium medium; +} // namespace sd_test + +class SdCard { +public: + bool syncDevice() { + auto& m = sd_test::medium; + return m.connected && ++m.card_sync_calls != m.fail_card_sync_at; + } + uint32_t sectorCount() const { return sd_test::medium.connected ? 1048576 : 0; } +}; + +class FsFile { + std::string _path; + size_t _position = 0, _next = 0; + std::vector _children; + sd_test::Node* node() const { + auto& m = sd_test::medium; + auto it = m.nodes.find(_path); + return m.connected && !_path.empty() && it != m.nodes.end() ? &it->second : nullptr; + } +public: + explicit operator bool() const { return node() != nullptr; } + bool open(const char* path, int flags) { + close(); + auto& m = sd_test::medium; + if (!m.connected || !path || !*path) return false; + auto it = m.nodes.find(path); + if ((flags & O_EXCL) && it != m.nodes.end()) return false; + if (it == m.nodes.end()) { + if (!(flags & O_CREAT)) return false; + it = m.nodes.emplace(path, sd_test::Node{}).first; + } + if (flags & O_TRUNC) it->second.bytes.clear(); + _path = path; + if (it->second.directory) { + const std::string prefix = _path + "/"; + for (const auto& entry : m.nodes) { + if (entry.first.compare(0, prefix.size(), prefix) == 0 && + entry.first.find('/', prefix.size()) == std::string::npos) + _children.push_back(entry.first); + } + } + return true; + } + bool openNext(FsFile* directory, int flags) { + close(); + return directory && directory->isDir() && directory->_next < directory->_children.size() + && open(directory->_children[directory->_next++].c_str(), flags); + } + void close() { _path.clear(); _position = 0; _next = 0; _children.clear(); } + bool isDir() const { auto* n = node(); return n && n->directory; } + uint64_t fileSize() const { auto* n = node(); return n ? n->bytes.size() : 0; } + bool getName(char* out, size_t cap) const { + if (!node() || !out || !cap) return false; + const auto slash = _path.rfind('/'); + const std::string name = _path.substr(slash == std::string::npos ? 0 : slash + 1); + const size_t count = std::min(name.size(), cap - 1); + memcpy(out, name.data(), count); out[count] = 0; return name.size() < cap; + } + bool seekSet(uint64_t position) { + if (!node() || position > fileSize()) return false; + _position = static_cast(position); return true; + } + int read(void* out, size_t count) { + auto* n = node(); + if (!n || !out) return -1; + count = std::min(count, n->bytes.size() - _position); + memcpy(out, n->bytes.data() + _position, count); _position += count; + return static_cast(count); + } + size_t write(const void* data, size_t count) { + auto* n = node(); + if (!n || !sd_test::medium.write_ok || !data || _position > SIZE_MAX - count) return 0; + n->bytes.resize(std::max(n->bytes.size(), _position + count), 0xFF); + memcpy(n->bytes.data() + _position, data, count); _position += count; return count; + } + bool preAllocate(uint32_t count) { + auto* n = node(); + if (!n || !sd_test::medium.preallocate_ok) return false; + n->bytes.resize(count, 0xFF); return true; + } + bool sync() const { return node() && sd_test::medium.file_sync_ok; } +}; + +class SdFs { + SdCard _card; +public: + bool begin() { return sd_test::medium.connected && sd_test::medium.mount_ok; } + void end() {} + bool exists(const char* path) const { + return sd_test::medium.connected && sd_test::medium.nodes.count(path); + } + bool mkdir(const char* path) { + return sd_test::medium.connected && + sd_test::medium.nodes.emplace(path, sd_test::Node{true, {}}).second; + } + FsFile open(const char* path, int flags) { FsFile f; f.open(path, flags); return f; } + bool remove(const char* path) { + return sd_test::medium.connected && sd_test::medium.remove_ok && + sd_test::medium.nodes.erase(path) != 0; + } + bool rename(const char* from, const char* to) { + auto& m = sd_test::medium; + auto it = m.nodes.find(from); + if (!m.connected || !m.rename_ok || it == m.nodes.end() || m.nodes.count(to)) return false; + m.nodes.emplace(to, std::move(it->second)); m.nodes.erase(it); return true; + } + uint32_t clusterCount() const { return sd_test::medium.clusters; } + uint32_t freeClusterCount() const { return sd_test::medium.free_clusters; } + uint32_t bytesPerCluster() const { return sd_test::medium.cluster_bytes; } + SdCard* card() { return sd_test::medium.connected ? &_card : nullptr; } +}; diff --git a/test/fixtures/ota_sd_cache/test.cpp b/test/fixtures/ota_sd_cache/test.cpp new file mode 100644 index 000000000..034d90418 --- /dev/null +++ b/test/fixtures/ota_sd_cache/test.cpp @@ -0,0 +1,280 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../test_ota/mota_vectors.h" +#include +#include +#include +#include + +using namespace mesh::ota; + +// The existing SD store owns the shared mounted volume. Replace only that +// hardware boundary: no boot authorization, flash writes, or apply call exists +// in this executable. The real archive implementation is compiled separately. +namespace mesh { namespace ota { +OtaStoreSdNrf52::OtaStoreSdNrf52() : _sd(new SdFs), _file(new FsFile) {} +OtaStoreSdNrf52::~OtaStoreSdNrf52() { delete _file; delete _sd; } +bool OtaStoreSdNrf52::mount() { + if (_mounted) return true; + _mounted = _sd && _sd->begin(); + if (!_mounted) fail("SD mount failed"); + return _mounted; +} +void OtaStoreSdNrf52::fail(const char* text) { snprintf(_error, sizeof(_error), "%s", text); } +bool OtaStoreSdNrf52::begin(uint32_t) { assert(false); return false; } +bool OtaStoreSdNrf52::write(uint32_t, const uint8_t*, uint32_t) { assert(false); return false; } +bool OtaStoreSdNrf52::read(uint32_t, uint8_t*, uint32_t) const { assert(false); return false; } +uint32_t OtaStoreSdNrf52::capacity() const { return 0; } +void OtaStoreSdNrf52::clear() { assert(false); } +bool OtaStoreSdNrf52::discard() { assert(false); return false; } +bool OtaStoreSdNrf52::set_meta_size(uint32_t) { assert(false); return false; } +bool OtaStoreSdNrf52::finalize() { assert(false); return false; } +void OtaStoreSdNrf52::checkpoint() { assert(false); } +bool OtaStoreSdNrf52::reopen() { assert(false); return false; } +bool OtaStoreSdNrf52::plan_layout(bool, uint32_t, uint32_t, uint32_t, bool) { + assert(false); return false; +} +} } + +static const uint8_t* const bytes = SIM_MOTA_2K; +static constexpr uint32_t length = SIM_MOTA_2K_LEN; +static const uint8_t* const mid = SIM_MOTA_2K + 8 + 20; +static std::string path(const char* ext) { + char out[40]; + snprintf(out, sizeof(out), "/mota/%02x%02x%02x%02x.%s", mid[0], mid[1], mid[2], mid[3], ext); + return out; +} +static void attach(OtaCacheSdNrf52& cache, OtaStoreSdNrf52& owner) { + cache.attach(owner); cache.set_mid(mid); +} +static void capture(OtaCacheSdNrf52& cache) { + assert(cache.begin(length)); + assert(cache.set_meta_size(8 + MOTA_MFL + SIM_MOTA_2K_BLOCKS * 4)); + assert(cache.write(0, bytes, length)); +} +static void require_valid_fixture() { + MotaManifest parsed; + assert(mota_parse(bytes, length, parsed) && parsed.is_signed()); + assert(mota_check_root(parsed) && mota_check_payload(parsed) && mota_check_image_hash_full(parsed)); +} +static void published(OtaCacheSdNrf52& cache) { + assert(cache.count() == 1 && cache.contains(mid)); + MotaDesc d; + assert(cache.describe(0, d) && !cache.describe(1, d)); + assert(d.target_id == SIM_TARGET_ID && d.total_size == length && d.block_count == 2); + assert(d.block_size_log2 == 11 && d.flags == (MFLAG_FULL | MFLAG_SIGNED)); + std::vector loaded(length); + assert(cache.read(0, 0, loaded.data(), length)); + assert(!memcmp(loaded.data(), bytes, length)); + assert(!cache.read(0, length - 1, loaded.data(), 2)); + assert(!cache.read(0, UINT32_MAX, loaded.data(), 2)); + assert(!cache.read(1, 0, loaded.data(), 1)); + assert(sd_test::medium.nodes.count(path("mota")) == 1); + assert(sd_test::medium.nodes.count(path("part")) == 0); +} + +struct Link { + OtaManager* peer = nullptr; + std::deque> packets; +}; +static bool send(void* context, const uint8_t* data, uint16_t count, bool) { + static_cast(context)->packets.emplace_back(data, data + count); + return true; +} +static void deliver(Link& link) { + while (!link.packets.empty()) { + auto data = std::move(link.packets.front()); link.packets.pop_front(); + link.peer->on_message(data.data(), static_cast(data.size())); + } +} + +static std::vector distinct_image(uint8_t value) { + std::vector image(bytes, bytes + length); + image[9] = MFLAG_FULL; // distinct unsigned fixtures, never a forged signature + memset(image.data() + 8 + MOTA_OFF_SIGNER, 0, 32 + 64); + MotaManifest manifest; + assert(mota_parse(image.data(), image.size(), manifest)); + auto* payload = const_cast(manifest.payload); + payload[0] = value; + for (uint32_t block = 0; block < manifest.block_count; ++block) { + const uint32_t offset = block * manifest.block_size(); + const uint32_t count = std::min(manifest.block_size(), manifest.payload_size - offset); + merkle_leaf(const_cast(manifest.leaves) + block * 4, payload + offset, count); + } + merkle_root(const_cast(manifest.merkle_root), manifest.leaves, manifest.block_count); + mh32(const_cast(manifest.image_hash), payload, manifest.payload_size); + assert(mota_check_root(manifest) && mota_check_payload(manifest) && mota_check_image_hash_full(manifest)); + return image; +} + +int main(int argc, char** argv) { + assert(argc == 2); + const int scenario = atoi(argv[1]); + sd_test::medium.reset(); require_valid_fixture(); + if (scenario == 0) { + { + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + capture(cache); assert(cache.count() == 0); assert(cache.finalize()); published(cache); + assert(!cache.begin(length)); // duplicate must not truncate the good archive + } + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 rebooted; attach(rebooted, owner); + assert(rebooted.initialize()); published(rebooted); + } else if (scenario == 1) { + { + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + assert(cache.begin(length)); assert(cache.write(0, bytes, 8 + MOTA_MFL)); + cache.checkpoint(); assert(cache.count() == 0); + } + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 resumed; attach(resumed, owner); + assert(resumed.initialize() && resumed.count() == 0 && resumed.reopen()); + assert(resumed.staged_size() == length); + uint8_t header[8]; assert(resumed.read(0, header, sizeof(header))); + assert(!memcmp(header, bytes, sizeof(header))); + assert(resumed.write(0, bytes, length) && resumed.finalize()); published(resumed); + } else if (scenario == 2) { + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); capture(cache); + sd_test::medium.rename_ok = false; + assert(!cache.finalize() && cache.count() == 0); + assert(sd_test::medium.nodes.count(path("part")) == 1); + sd_test::medium.rename_ok = true; + assert(cache.reopen() && cache.finalize()); published(cache); + } else if (scenario == 3) { + for (int kind = 0; kind < 3; ++kind) { + sd_test::medium.reset(); + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); capture(cache); + if (kind == 0) sd_test::medium.file_sync_ok = false; + else sd_test::medium.fail_card_sync_at = kind; + assert(!cache.finalize()); + if (kind == 2) { + // The rename occurred, but its subsequent device sync failed. Valid + // published data remains discoverable; do not pretend the call succeeded. + assert(cache.count() == 1); published(cache); + } else { + assert(cache.count() == 0 && sd_test::medium.nodes.count(path("part")) == 1); + sd_test::medium.file_sync_ok = true; sd_test::medium.fail_card_sync_at = 0; + assert(cache.finalize()); published(cache); + } + } + } else if (scenario == 4) { + for (int kind = 0; kind < 5; ++kind) { + sd_test::medium.reset(); + auto& m = sd_test::medium; + if (kind == 0) m.connected = false; + if (kind == 1) m.mount_ok = false; + if (kind == 2) m.free_clusters = 1; + if (kind == 3) m.free_clusters = m.clusters + 1; + if (kind == 4) m.cluster_bytes = 0; + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + assert(!cache.begin(length) && cache.staged_size() == 0 && cache.count() == 0); + assert(!m.nodes.count(path("part")) && cache.last_error()[0]); + } + } else if (scenario == 5) { + for (bool writes : {true, false}) { + sd_test::medium.reset(); sd_test::medium.preallocate_ok = false; + sd_test::medium.write_ok = writes; + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + if (writes) { capture(cache); assert(cache.finalize()); published(cache); } + else { + assert(!cache.begin(length) && cache.staged_size() == 0); + assert(!sd_test::medium.nodes.count(path("part"))); + } + } + } else if (scenario == 6) { + for (int kind = 0; kind < 5; ++kind) { + sd_test::medium.reset(); + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); capture(cache); + auto& data = sd_test::medium.nodes[path("part")].bytes; + if (kind == 0) data[0] ^= 1; // bad magic + if (kind == 1) data.back() ^= 1; // bad trailer + if (kind == 2) data[8 + 20] ^= 1; // MID not selected + if (kind == 3) data[4] ^= 1; // declared length mismatch + if (kind == 4) data[8 + 2] = 0; // unknown hash algorithm + assert(!cache.finalize() && cache.count() == 0); + assert(sd_test::medium.nodes.count(path("part")) == 1); + // Malformed complete files must not be listed even after reboot/rescan. + auto saved = std::move(sd_test::medium.nodes[path("part")]); + sd_test::medium.nodes.erase(path("part")); + sd_test::medium.nodes[path("mota")] = std::move(saved); + assert(cache.rescan() && cache.count() == 0 && !cache.contains(mid)); + } + } else if (scenario == 7) { + { + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + capture(cache); assert(cache.finalize()); assert(cache.setAutoCaptureEnabled(false)); + assert(!cache.autoCaptureEnabled()); published(cache); + } + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + assert(cache.initialize() && !cache.autoCaptureEnabled()); published(cache); + assert(cache.setAutoCaptureEnabled(true) && cache.autoCaptureEnabled()); + assert(!sd_test::medium.nodes.count("/mota/cache.off")); + } else if (scenario == 8) { + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + assert(cache.initialize()); + // A malformed existing final cannot suppress clean replacement. Keep the + // bad bytes under quarantine instead of silently erasing operator evidence. + sd_test::medium.nodes[path("mota")] = {false, {1, 2, 3}}; + capture(cache); + assert(sd_test::medium.nodes.count(path("bad")) == 1); + assert(cache.finalize()); published(cache); + } else if (scenario == 9) { + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + capture(cache); assert(cache.finalize()); + OtaManager seeder, receiver; + OtaStoreRam<8192> destination; + Link outbound{&receiver, {}}, inbound{&seeder, {}}; + seeder.begin(0, send, &outbound); + receiver.begin(SIM_TARGET_ID, send, &inbound); + receiver.set_fetch_store(&destination); + assert(seeder.add_source(&cache) && seeder.servedCount() == 1); + assert(receiver.pull(mid, SIM_TARGET_ID) == OtaManager::PULL_STARTED); + for (uint32_t now = 100; now <= 300000 && receiver.fetchState() != OtaManager::COMPLETE; + now += 100) { + seeder.set_clock(now); receiver.set_clock(now); + deliver(inbound); deliver(outbound); + seeder.serviceEgress(); receiver.serviceEgress(); + seeder.loop(); receiver.loop(); + assert(receiver.fetchState() != OtaManager::FAILED); + } + assert(receiver.fetchState() == OtaManager::COMPLETE); + assert(destination.staged_size() == length && !memcmp(destination.data(), bytes, length)); + MotaManifest fetched; + assert(mota_parse(destination.data(), destination.staged_size(), fetched)); + assert(mota_check_root(fetched) && mota_check_payload(fetched)); + } else if (scenario == 10) { + static_assert(OTA_SD_CACHE_MAX == 16, "fixture exercises the exact optional profile cap"); + sd_test::medium.nodes["/mota"] = {true, {}}; + std::string first; + for (unsigned i = 0; i < OTA_SD_CACHE_MAX; ++i) { + auto image = distinct_image(static_cast(i)); + const uint8_t* image_mid = image.data() + 8 + 20; + char name[40]; snprintf(name, sizeof(name), "/mota/%02x%02x%02x%02x.mota", + image_mid[0], image_mid[1], image_mid[2], image_mid[3]); + if (i == 0) first = name; + sd_test::medium.nodes[name] = {false, std::move(image)}; + } + // A previously interrupted seventeenth capture must remain untouched when + // no advertised slot is available, rather than being deleted by begin(). + sd_test::medium.nodes[path("part")] = {false, std::vector(bytes, bytes + length)}; + OtaStoreSdNrf52 owner; OtaCacheSdNrf52 cache; attach(cache, owner); + assert(cache.initialize() && cache.count() == OTA_SD_CACHE_MAX); + const auto original = sd_test::medium.nodes[path("part")].bytes; + const auto count = sd_test::medium.nodes.size(); + assert(!cache.begin(length)); + assert(strstr(cache.last_error(), "entry limit")); + assert(sd_test::medium.nodes.size() == count); + assert(sd_test::medium.nodes[path("part")].bytes == original); + // Once an operator makes room, the existing partial can be resumed and + // published without re-creating or truncating it. + sd_test::medium.nodes.erase(first); + assert(cache.rescan() && cache.count() == OTA_SD_CACHE_MAX - 1); + assert(cache.reopen() && cache.finalize() && cache.count() == OTA_SD_CACHE_MAX); + assert(cache.contains(mid) && !sd_test::medium.nodes.count(path("part"))); + } else return 2; + puts("PASS: production SD archive boundary"); +} diff --git a/test/fixtures/rak15002_sd_seeder/cli.cpp b/test/fixtures/rak15002_sd_seeder/cli.cpp new file mode 100644 index 000000000..2304b5b61 --- /dev/null +++ b/test/fixtures/rak15002_sd_seeder/cli.cpp @@ -0,0 +1,107 @@ +#include +#include +#include +#include +#include +#include +static uint32_t clock_ms = 500000; +static uint32_t millis() { return clock_ms; } +static const char* skipSpacesConst(const char* in) { while (*in == ' ' || *in == '\t') ++in; return in; } +static constexpr uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 300000; +struct Board {}; +namespace mesh { namespace ota { +struct OtaStoreSdNrf52 { + int formats = 0, erases = 0, spaces = 0, lists = 0; + uint16_t page = 0; + bool formatCard(Board&) { formats++; return true; } + bool eraseCard(Board&) { erases++; return true; } + bool getSpace(Board&, uint64_t& used, uint64_t& free) { spaces++; used = 12; free = 34; return true; } + bool listFiles(Board&, uint16_t value, char* reply, size_t cap) { + lists++; page = value; snprintf(reply, cap, "> files"); return true; + } + const char* last_error() const { return "error"; } +}; +struct OtaManager { + enum { IDLE }; + int resets = 0; + void reset_session() { resets++; } + void want(int) {} + void want_mid(const uint8_t*) {} +}; +struct OtaContext { + OtaManager manager; + OtaStoreSdNrf52 card; + bool apply_pending = false; + uint32_t session_started_ms = 0; + int prev_fstate = 0, prepares = 0, finishes = 0; + OtaStoreSdNrf52& sdStagingStore() { return card; } + void prepareSdCardReset() { prepares++; } + void finishSdCardReset(uint32_t) { finishes++; } +}; +static OtaContext context; +static bool acquired = false, out_of_memory = false; +static int acquisitions = 0, accesses = 0; +static bool ota_acquire_context(char* reply, size_t cap) { + acquisitions++; + if (out_of_memory) { snprintf(reply, cap, "ERR mOTA is out of memory"); return false; } + acquired = true; return true; +} +static OtaContext& ota_ctx() { assert(acquired); accesses++; return context; } +} namespace cli { +static void formatStorageBytes(char* reply, size_t cap, uint64_t value) { snprintf(reply, cap, "%uB", unsigned(value)); } +} } +struct CommonCLI { + Board board; + Board* _board = &board; + bool _sdcard_format_recorded = false, _sdcard_erase_recorded = false; + uint32_t _sdcard_format_at = 0, _sdcard_erase_at = 0; + bool handleSdCardSetCmd(const char*, char*); + bool handleSdCardGetCmd(const char*, char*); +}; +@METHODS@ + +int main() { + using namespace mesh::ota; + CommonCLI cli; + struct GuardedReply { char reply[160]; char sentinel[8]; } output{}; + strcpy(output.sentinel, "guarded"); + for (const char* cmd : {"sdcard format", "sdcard erase", "sdcard format --force", "sdcard erase --force"}) { + acquired = false; out_of_memory = true; + assert(cli.handleSdCardSetCmd(cmd, output.reply)); + assert(strstr(output.reply, "out of memory") && !acquired && accesses == 0); + assert(context.card.formats == 0 && context.card.erases == 0 && context.prepares == 0); + assert(strcmp(output.sentinel, "guarded") == 0); + } + for (const char* cmd : {"sdcard free", "sdcard ls", "sdcard dir 2"}) { + assert(cli.handleSdCardGetCmd(cmd, output.reply)); + assert(strstr(output.reply, "out of memory") && context.card.spaces == 0 && context.card.lists == 0); + assert(accesses == 0 && strcmp(output.sentinel, "guarded") == 0); + } + int previous_acquisitions = acquisitions; + for (const char* cmd : {"sdcard", "sdcard *", "sdcard format", "sdcard erase", "sdcard invalid", "sdcard ls 0", "sdcard dir -1", "sdcard lsoops"}) { + assert(cli.handleSdCardGetCmd(cmd, output.reply)); + assert(acquisitions == previous_acquisitions); // status and invalid commands never allocate + } + assert(cli.handleSdCardSetCmd("sdcard format unknown", output.reply)); + assert(acquisitions == previous_acquisitions); + assert(!cli.handleSdCardGetCmd("sdcardoops", output.reply)); + assert(!cli.handleSdCardSetCmd("other", output.reply)); + out_of_memory = false; + assert(cli.handleSdCardGetCmd("sdcard free", output.reply)); + assert(context.card.spaces == 1 && strstr(output.reply, "12B")); + acquired = false; + assert(cli.handleSdCardGetCmd("sdcard dir 65535", output.reply)); + assert(context.card.lists == 1 && context.card.page == 65535); + acquired = false; context.apply_pending = true; + assert(cli.handleSdCardSetCmd("sdcard format", output.reply)); + assert(strstr(output.reply, "apply is pending") && context.card.formats == 0); + context.apply_pending = false; + assert(cli.handleSdCardSetCmd("sdcard format", output.reply)); + assert(context.card.formats == 1 && context.prepares == 1 && context.finishes == 1); + acquired = false; previous_acquisitions = acquisitions; + assert(cli.handleSdCardSetCmd("sdcard format", output.reply)); + assert(strstr(output.reply, "--force") && acquisitions == previous_acquisitions); + assert(cli.handleSdCardSetCmd("sdcard erase", output.reply)); + assert(context.card.erases == 1 && context.card.formats == 2 && context.finishes == 2); + assert(strcmp(output.sentinel, "guarded") == 0); +} diff --git a/test/fixtures/rak15002_sd_seeder/context.cpp b/test/fixtures/rak15002_sd_seeder/context.cpp new file mode 100644 index 000000000..2fd311e8f --- /dev/null +++ b/test/fixtures/rak15002_sd_seeder/context.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +using namespace mesh::ota; + +struct OtaStoreSdNrf52 {}; +struct OtaCacheSdNrf52 : OtaStoreRam<128> { + OtaStoreSdNrf52* owner = nullptr; + bool enabled = true, init_ok = true, save_ok = true; + int checkpoints = 0, count = 0, resets = 0; + std::vector captured; + uint8_t selected_mid = 0; + void attach(OtaStoreSdNrf52& value) { owner = &value; } + bool initialize() { return init_ok; } + bool setAutoCaptureEnabled(bool value) { enabled = value; return save_ok; } + bool autoCaptureEnabled() const { return enabled; } + uint8_t capturedCount() const { return count; } + bool contains(const uint8_t* mid) { + for (auto id : captured) if (id == mid[0]) return true; + return false; + } + void set_mid(const uint8_t* mid) { selected_mid = mid[0]; } + void checkpoint() override { checkpoints++; } + void resetMedia() { resets++; } +}; +struct OtaManager { + enum FetchState { IDLE, FETCHING, COMPLETE, FAILED, PAUSED }; + struct CatRow { uint8_t mid[4]; uint32_t target_id; uint16_t have_max; bool ready; }; + FetchState state = IDLE; + OtaStore* store = nullptr; + OtaCacheSdNrf52* source = nullptr; + bool interest = false; + int pulls = 0, resets = 0, refreshes = 0, announces = 0, queries = 0; + uint32_t deferred_until = 0; + std::vector rows; + FetchState fetchState() const { return state; } + void reset_session() { state = IDLE; resets++; } + bool add_source(OtaCacheSdNrf52* value) { source = value; return true; } + void remove_source(OtaCacheSdNrf52*) { source = nullptr; } + void set_archive_interest(bool value) { interest = value; } + void set_fetch_store(OtaStore* value) { store = value; } + void want(uint32_t) {} + void want_mid(const uint8_t*) {} + void queryAll() { queries++; } + void refresh_sources() { refreshes++; } + void announce() { announces++; } + void deferCatalog(const uint8_t*, uint32_t until) { deferred_until = until; } + uint8_t catalogCount() const { return rows.size(); } + const CatRow* catalogRow(uint8_t i) { return &rows[i]; } + bool catalogReady(const CatRow* row, uint32_t) const { return row->ready; } + void pull_archive(const uint8_t*, uint32_t) { pulls++; state = FETCHING; } +}; +struct Context { +@MEMBERS@ + bool apply_pending = false, fetch_to_folder = false; + bool _sd_cache_source_attached = false, _sd_cache_fetching = false; + uint8_t _sd_cache_mid[4] = {}; + uint32_t _sd_cache_init_retry_at = 0, _sd_cache_next_at = 0; +@METHODS@ +}; + +static Context fresh() { return Context(); } +static void offer(Context& c) { + c.manager.rows.push_back({{1, 0, 0, 0}, 44, 1, true}); + c.manager.set_fetch_store(&c.fetch_store); +} +int main() { + { + Context c = fresh(); offer(c); + assert(c.fetch_store.capacity() == 1 && !c.fetch_store.begin(2)); + assert(&c.sdStagingStore() == &c.sd_archive_store); + c.serviceSdCache(100); + assert(c.sd_cache.owner == &c.sd_archive_store && c.manager.source == &c.sd_cache); + assert(c.manager.interest && c.manager.store == &c.sd_cache && c.sdCacheFetching()); + assert(c.sd_cache.begin(4)); uint8_t data[] = {1, 2, 3, 4}; + assert(c.sd_cache.write(0, data, sizeof(data))); + c.stopSdCacheFetch(); + assert(!c.sdCacheFetching() && c.manager.store == &c.fetch_store); + assert(c.sd_cache.checkpoints == 1 && c.sd_cache.staged_size() == 4); + assert(c.manager.source == &c.sd_cache); // install/manual work does not discard published archive + char msg[96] = {}; + assert(!c.apply_fetched_impl(nullptr, false, msg)); + assert(strstr(msg, "cannot install") && !c.apply_pending); + assert(!c.apply_fetched_bootloader(nullptr, nullptr, msg)); + assert(strstr(msg, "cannot update its bootloader") && !c.apply_pending); + } + for (auto state : {OtaManager::FAILED, OtaManager::PAUSED, OtaManager::IDLE}) { + Context c; offer(c); c.serviceSdCache(100); + assert(c.sd_cache.begin(4)); + c.manager.state = state; c.serviceSdCache(200); + assert(!c.sdCacheFetching() && c.manager.store == &c.fetch_store); + assert(c.sd_cache.checkpoints == 1 && c.sd_cache.staged_size() == 4); + assert(c.manager.source == &c.sd_cache); + if (state != OtaManager::IDLE) assert(c.manager.deferred_until == 300200); + c.serviceSdCache(300); assert(c.manager.pulls == 1); + c.serviceSdCache(3300); assert(c.manager.pulls == 2); + } + { + Context c; offer(c); c.serviceSdCache(100); + c.manager.state = OtaManager::COMPLETE; c.serviceSdCache(200); + assert(!c.sdCacheFetching() && c.manager.store == &c.fetch_store); + assert(c.manager.refreshes == 1 && c.manager.announces == 1); + } + { + Context c; offer(c); c.sd_cache.count = OTA_SD_CACHE_MAX; + c.serviceSdCache(100); + assert(c.manager.pulls == 0 && c.manager.source == &c.sd_cache); + assert(c.setSdCacheEnabled(false)); + assert(c.manager.source == &c.sd_cache && !c.manager.interest); + c.serviceSdCache(200); assert(c.manager.pulls == 0); + } + { + Context c; offer(c); c.sd_cache.init_ok = false; + c.serviceSdCache(100); assert(c.manager.pulls == 0 && c.manager.source == nullptr); + c.sd_cache.init_ok = true; + c.serviceSdCache(200); assert(c.manager.pulls == 0); + c.serviceSdCache(60100); assert(c.manager.pulls == 1); + c.prepareSdCardReset(); + assert(c.manager.source == nullptr && !c.sdCacheFetching()); + c.finishSdCardReset(60200); + assert(c.manager.source == &c.sd_cache && c.sd_cache.resets == 2); + } +} diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index d4ab771b9..d82f6dc90 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -43,11 +43,16 @@ for utility in profile_switch_t096_sx1262 profile_four_tx_v4_rx xiao_s3_partitio heltec_v4_partition_migrator_lora_repeater \ heltec_v4_partition_migrator_lora_repeater_lora_ota_no_external_sensors \ Xiao_S3_WIO_partition_expander \ - Heltec_v3_repeater_observer_mqtt_sim; do + Heltec_v3_repeater_observer_mqtt_sim \ + RAK_4631_repeater_rak15002_sd_seeder_only; do is_redundant_bulk_build_target "$utility" \ || fail "release matrix includes development utility $utility" done +if is_nrf52_sensor_ota_pair_target RAK_4631_repeater_rak15002_sd_seeder_only; then + fail "source-only SD archive was rewritten into a self-updating receiver pair" +fi + # Reduced RAK profiles all retain the compact INA set, but GPS depends on # whether the bridge already owns Serial1. Keep those independent contracts so # a valid Serial1 image cannot fail release qualification for an absent GPS diff --git a/test/test_local_release_full_selection.py b/test/test_local_release_full_selection.py index eebb04cc7..83c3c348e 100644 --- a/test/test_local_release_full_selection.py +++ b/test/test_local_release_full_selection.py @@ -81,6 +81,15 @@ class ReleaseFullSelectionTest(unittest.TestCase): self.assertCountEqual(select_ordinary_full_records([companion, modem]), [companion, modem]) + def test_optional_sd_source_is_not_published_as_an_ordinary_receiver(self): + primary = record("RAK_4631_repeater_unified_lora_ota", platform="NRF52_PLATFORM") + for profile in ("standard", "auto", "full", "reduced"): + source = record("RAK_4631_repeater_rak15002_sd_seeder_only", profile, + platform="NRF52_PLATFORM") + with self.subTest(profile=profile): + self.assertEqual(select_ordinary_full_records([source]), []) + self.assertEqual(select_ordinary_full_records([source, primary]), [primary]) + def test_unverified_selected_esp32_full_image_cannot_enter_release(self): for target in ("heltec_v4_room_server", "Station_G2_companion_radio_full"): for verified in (False, None): diff --git a/test/test_ota_sd_cache.py b/test/test_ota_sd_cache.py new file mode 100644 index 000000000..d4e6cb297 --- /dev/null +++ b/test/test_ota_sd_cache.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Exercise the production nRF52 SD archive with no card, flash or PlatformIO.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_serial_wifi_sessions import SANITIZER_FLAGS + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/ota_sd_cache" + + +class OtaSdCacheTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise unittest.SkipTest("a host C++17 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="ota-sd-cache-") + cls.binary = Path(cls.directory.name) / "sd-cache" + source_names = ("OtaCacheSdNrf52.cpp", "MotaContainer.cpp", "MerkleTree.cpp", + "OtaManager.cpp", "OtaProtocol.cpp") + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", *SANITIZER_FLAGS, + "-DNRF52_PLATFORM=1", "-DOTA_SD_STORE=1", "-DOTA_SD_CACHE_MAX=16", + "-I", str(FIXTURE / "mocks"), "-I", str(ROOT / "src"), + "-I", str(ROOT / "test/mocks"), str(FIXTURE / "test.cpp"), + *[str(ROOT / "src/helpers/ota" / name) for name in source_names], + str(ROOT / "src/Utils.cpp"), "-lcrypto", "-o", str(cls.binary), + ], capture_output=True, text=True, timeout=60) + if built.returncode: + cls.directory.cleanup() + raise AssertionError(built.stdout + built.stderr) + + @classmethod + def tearDownClass(cls): + cls.directory.cleanup() + + def run_case(self, scenario): + result = subprocess.run([str(self.binary), str(scenario)], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: production SD archive boundary", result.stdout) + + def test_capture_publish_read_and_reboot_enumeration(self): self.run_case(0) + def test_partial_is_hidden_and_reopens_after_reboot(self): self.run_case(1) + def test_failed_rename_retains_resumable_partial(self): self.run_case(2) + def test_file_device_and_post_publish_sync_failures(self): self.run_case(3) + def test_missing_card_and_invalid_or_insufficient_free_space(self): self.run_case(4) + def test_fragmented_allocation_fallback_and_cleanup(self): self.run_case(5) + def test_corrupt_container_and_mid_are_never_advertised(self): self.run_case(6) + def test_cache_off_survives_reboot_but_completed_files_still_seed(self): self.run_case(7) + def test_malformed_final_is_quarantined_before_replacement(self): self.run_case(8) + def test_real_managers_transfer_sd_image_and_verify_merkle_blocks(self): self.run_case(9) + def test_full_index_refuses_seventeenth_without_losing_resumable_partial(self): self.run_case(10) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rak15002_sd_seeder.py b/test/test_rak15002_sd_seeder.py new file mode 100644 index 000000000..0bb7cb5f3 --- /dev/null +++ b/test/test_rak15002_sd_seeder.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Qualify the optional source-only RAK15002 integration without RF or a card. + +Compile the actual bus helper and extracted production context/CLI methods; +the separate test_ota_sd_cache suite executes the real FAT cache and protocol. +No PlatformIO invocation is used here. Physical module qualification is still +required before promoting this experimental target to a receiver/release. +""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced +from test_upstream_power_policy_merge import Recipes + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "test/fixtures/rak15002_sd_seeder" +CONTEXT = ROOT / "src/helpers/ota/OtaContext.h" +ENV = "env:RAK_4631_repeater_rak15002_sd_seeder_only" +BASE = "env:RAK_4631_repeater_lora_ota_no_external_sensors" +DEFINES = ["NRF52_PLATFORM", "RAK_4631", "OTA_SD_STORE", "OTA_SEEDER_ONLY", + "OTA_RAK15002_SD_CACHE", "SPI_INTERFACES_COUNT=1", "PIN_SPI_MISO=29", + "PIN_SPI_SCK=3", "PIN_SPI_MOSI=30", "PIN_SPI1_NSS=12"] + + +def build(testcase, source, defines=(), files=None, expect_success=True): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + testcase.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="rak15002-") as folder: + work = Path(folder) + (work / "main.cpp").write_text(source, encoding="utf-8") + for name, content in (files or {}).items(): + (work / name).write_text(content, encoding="utf-8") + sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *sanitizer, + *["-D" + value for value in defines], + "-I" + str(work), "-I" + str(ROOT / "src"), + str(work / "main.cpp"), "-o", str(work / "test")], + capture_output=True, text=True) + if not expect_success: + testcase.assertNotEqual(result.returncode, 0, "unsafe configuration compiled") + return + testcase.assertEqual(result.returncode, 0, result.stdout + result.stderr) + run = subprocess.run([str(work / "test")], capture_output=True, text=True) + testcase.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + +SPI_MOCK = r''' +#pragma once +#include +#include +#define HIGH 1 +#define OUTPUT 1 +struct NRF_SPIM_Type {}; +inline NRF_SPIM_Type peripheral2, peripheral3; +#define NRF_SPIM2 (&peripheral2) +#define NRF_SPIM3 (&peripheral3) +struct SPIClass { + NRF_SPIM_Type* peripheral; + int miso, sck, mosi; + SPIClass(NRF_SPIM_Type* p, int i, int c, int o) : peripheral(p), miso(i), sck(c), mosi(o) {} +}; +inline SPIClass SPI(NRF_SPIM3, 45, 43, 44); +inline SPIClass SPI1(NRF_SPIM2, 9, 10, 11); +struct GpioEvent { int type, pin, value; }; +inline std::vector events; +inline void digitalWrite(int pin, int value) { events.push_back({0, pin, value}); } +inline void pinMode(int pin, int value) { events.push_back({1, pin, value}); } +''' + +BUS_TEST = r''' +#include +int main() { + using namespace mesh::ota; + auto& bus = ota_sd_spi_bus(); + assert(&bus == &ota_sd_spi_bus()); +#if defined(OTA_RAK15002_SD_CACHE) + assert(&bus != &SPI && &bus != &SPI1); + assert(bus.peripheral == NRF_SPIM2 && bus.miso == 29 && bus.sck == 3 && bus.mosi == 30); + assert(SPI.peripheral == NRF_SPIM3 && SPI.miso == 45 && SPI.sck == 43 && SPI.mosi == 44); + static_assert(OTA_SD_CS_PIN == 26, "exact RAK CS"); +#else + assert(&bus == &SPI1); + static_assert(OTA_SD_CS_PIN == PIN_SPI1_NSS, "unchanged legacy CS"); +#endif + ota_sd_prepare_chip_select(); + assert(events.size() == 2 && events[0].type == 0 && events[0].value == HIGH); + assert(events[1].type == 1 && events[1].value == OUTPUT); + assert(events[0].pin == OTA_SD_CS_PIN && events[1].pin == OTA_SD_CS_PIN); +} +''' + + +class Rak15002SdSeederTests(unittest.TestCase): + def test_exact_profile_preserves_features_and_bounds_source_only_memory(self): + recipes = Recipes() + self.assertEqual(recipes.config.get(ENV, "extends"), BASE) + flags = recipes.value(ENV, "build_flags") + for flag in ("OTA_SEEDER_ONLY=1", "OTA_SD_STORE=1", "OTA_RAK15002_SD_CACHE=1", + "OTA_SD_CS_PIN=26", "OTA_SD_SCK_MHZ=4", "OTA_SD_CACHE_MAX=16", + "OTA_MAX_SERVE=24", "OTA_HEAP_CONTEXT=1"): + self.assertRegex(flags, r"-D\s*" + re.escape(flag) + r"(?:\s|$)") + self.assertIn("-UOTA_FLASH_STORE", flags) + for flag in ("DISPLAY_CLASS=SSD1306Display", "NRF52_POWER_MANAGEMENT", + "WITH_RS232_BRIDGE=Serial2"): + self.assertIn(flag, flags) + for flag in ("OTA_QSPI_STORE", "OTA_RAK_AUTO_STORE", "OTA_TOWER_AUTO_STORE", + "OTA_SD_BOOTLOADER_UPDATE", "COMPANION_RADIO_FULL", "ETHERNET_ENABLED"): + self.assertNotRegex(flags, r"-D\s*" + flag + r"(?:=|\s|$)") + libraries = recipes.value(ENV, "lib_deps") + self.assertIn("SdFat-beta.git#a249cfa", libraries) + self.assertIn("u-blox", libraries) + self.assertIn("SSD1306", libraries) + self.assertNotIn("OTA_RAK15002_SD_CACHE", recipes.value(BASE, "build_flags")) + self.assertNotIn("SdFat", recipes.value(BASE, "lib_deps")) + + def test_real_bus_keeps_radio_pins_and_cs_safe(self): + build(self, BUS_TEST, DEFINES, {"SPI.h": SPI_MOCK}) + + def test_existing_tower_bus_is_unchanged(self): + build(self, BUS_TEST, ["PIN_SPI1_NSS=12"], {"SPI.h": SPI_MOCK}) + + def test_conflicting_buses_and_unqualified_handoffs_fail_closed(self): + for conflict in ("ETHERNET_ENABLED=0", "OTA_QSPI_STORE", "OTA_RAK_AUTO_STORE", + "OTA_TOWER_AUTO_STORE", "OTA_SD_BOOTLOADER_UPDATE", + "OTA_INTERNAL_BOOTLOADER_UPDATE", "OTA_QSPI_BOOTLOADER_UPDATE", + "SPI_32MHZ_INTERFACE=1", "OTA_SD_CS_PIN=25"): + with self.subTest(conflict=conflict): + build(self, BUS_TEST, [*DEFINES, conflict], {"SPI.h": SPI_MOCK}, False) + for missing in ("NRF52_PLATFORM", "RAK_4631", "OTA_SD_STORE", "OTA_SEEDER_ONLY"): + with self.subTest(missing=missing): + build(self, BUS_TEST, [d for d in DEFINES if d != missing], + {"SPI.h": SPI_MOCK}, False) + build(self, BUS_TEST, [d.replace("SPI_INTERFACES_COUNT=1", "SPI_INTERFACES_COUNT=2") + for d in DEFINES], {"SPI.h": SPI_MOCK}, False) + + def test_real_context_storage_ownership_service_preemption_and_apply_refusal(self): + context = CONTEXT.read_text(encoding="utf-8") + start = context.index(" OtaManager manager;") + end = context.index(" SignerAllowlist allow;", start) + signatures = ("OtaStoreSdNrf52& sdStagingStore()", "bool ensureSdCache()", + "bool setSdCacheEnabled(", "bool sdCacheFetching()", + "void stopSdCacheFetch()", "void prepareSdCardReset()", + "void finishSdCardReset(", "void serviceSdCache(", + "bool apply_fetched_impl(", "bool apply_fetched_bootloader(") + methods = "\n".join(extract_braced(context, sig) for sig in signatures) + fixture = (FIXTURES / "context.cpp").read_text() + build(self, fixture.replace("@MEMBERS@", context[start:end]).replace("@METHODS@", methods), + ["NRF52_PLATFORM", "OTA_SD_STORE", "OTA_SEEDER_ONLY", "OTA_SD_CACHE_MAX=16"]) + + def test_actual_rail_policy_leaves_unswitched_module_off_gps_rail(self): + source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text() + method = extract_braced(source, "uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds()") + harness = r''' +#include +#include +class EnvironmentSensorManager { public: uint8_t detectWisBlockStorageHolds(); }; +@METHOD@ +int main() { EnvironmentSensorManager sensors; assert(sensors.detectWisBlockStorageHolds() == EXPECT_HOLD); } +'''.replace("@METHOD@", method) + for defines in (["OTA_SD_STORE", "OTA_RAK15002_SD_CACHE", "EXPECT_HOLD=0"], + ["OTA_SD_STORE", "EXPECT_HOLD=mesh::gps::ExternalStorage"], + ["OTA_QSPI_SHARED_WISBLOCK_SPI", "EXPECT_HOLD=mesh::gps::ExternalStorage"], + ["EXPECT_HOLD=0"]): + with self.subTest(defines=defines): + build(self, harness, defines) + + def test_sd_cli_cold_context_oom_validation_and_card_actions(self): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + methods = "\n".join(extract_braced(source, signature) for signature in ( + "static void formatSdCardElapsed(", "bool CommonCLI::handleSdCardSetCmd(", + "bool CommonCLI::handleSdCardGetCmd(")) + fixture = (FIXTURES / "cli.cpp").read_text() + build(self, fixture.replace("@METHODS@", methods), ["NRF52_PLATFORM", "OTA_SD_STORE"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 18d5c290f..8f519d5ea 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -192,6 +192,29 @@ lib_deps = adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 +; Experimental exact RAK15002 IO-slot SD archive. This is source-only: it +; caches/serves peer mOTAs but never installs an application or bootloader. +; Keep the ordinary reduced sensor/GPS/OLED/BLE features; do not substitute it +; for a receiver in the automatic Full/Reduced OTA release matrix. +[env:RAK_4631_repeater_rak15002_sd_seeder_only] +extends = env:RAK_4631_repeater_lora_ota_no_external_sensors +build_flags = + ${env:RAK_4631_repeater_lora_ota_no_external_sensors.build_flags} + -UOTA_FLASH_STORE + -D OTA_SEEDER_ONLY=1 + -D OTA_SD_STORE=1 + -D OTA_RAK15002_SD_CACHE=1 + -D OTA_SD_CS_PIN=26 + -D OTA_SD_SCK_MHZ=4 + -D OTA_SD_CACHE_MAX=16 + -D OTA_MAX_SERVE=24 + -D OTA_HEAP_CONTEXT=1 + -UADVERT_NAME + -D ADVERT_NAME='"RAK4631 SD Seeder"' +lib_deps = + ${env:RAK_4631_repeater_lora_ota_no_external_sensors.lib_deps} + https://github.com/greiman/SdFat-beta.git#a249cfa121e25a96ee14d8813abf01755f1f6a25 + [env:RAK_4631_repeater_ethernet] extends = rak4631 build_flags = diff --git a/variants/rak4631/variant.cpp b/variants/rak4631/variant.cpp index ebd61165f..c01950ff3 100644 --- a/variants/rak4631/variant.cpp +++ b/variants/rak4631/variant.cpp @@ -53,6 +53,11 @@ void initVariant() digitalWrite(26, HIGH); pinMode(31, OUTPUT); digitalWrite(31, HIGH); +#elif defined(OTA_RAK15002_SD_CACHE) + // RAK15002 uses the unswitched 3V3 rail. Deselect it before any peripheral + // starts; its separate SPI object is constructed lazily after radio setup. + digitalWrite(26, HIGH); + pinMode(26, OUTPUT); #elif defined(OTA_QSPI_STORE) && defined(OTA_QSPI_CS_ARDUINO_PIN) // Deselect a wired OTA NOR before any other device can toggle shared // WisBlock SPI nets. The W25Q16 installation also needs a physical CS#