Add opt-in RAK15002 SD firmware seeder with isolated SPI and cache regression tests

This commit is contained in:
mikecarper committed 2026-10-15 15:10:44 -07:00
1 parent 41775ed707
commit eebba0cb9a
21 files changed
+1082 -16

No files matched your search

+7 -1
View File
@@ -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
+8 -3
View File
@@ -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
+35
View File
@@ -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.
+4
View File
@@ -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()
+5
View File
@@ -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());
+4
View File
@@ -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.
+10 -3
View File
@@ -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 <id> 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");
+14 -2
View File
@@ -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;
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <SPI.h>
#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
+5 -5
View File
@@ -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;
}
@@ -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
+140
View File
@@ -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 <algorithm>
#include <cstdint>
#include <cstring>
#include <map>
#include <string>
#include <vector>
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<uint8_t> bytes;
};
struct Medium {
std::map<std::string, Node> 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<std::string> _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<size_t>(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<int>(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; }
};
+280
View File
@@ -0,0 +1,280 @@
#include <SdFat.h>
#include <helpers/ota/OtaCacheSdNrf52.h>
#include <helpers/ota/OtaStoreSdNrf52.h>
#include <helpers/ota/MotaContainer.h>
#include <helpers/ota/OtaManager.h>
#include <helpers/ota/MerkleTree.h>
#include <helpers/ota/Multihash.h>
#include <helpers/ota/OtaByteIO.h>
#include "../../test_ota/mota_vectors.h"
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <deque>
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<uint8_t> 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<std::vector<uint8_t>> packets;
};
static bool send(void* context, const uint8_t* data, uint16_t count, bool) {
static_cast<Link*>(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<uint16_t>(data.size()));
}
}
static std::vector<uint8_t> distinct_image(uint8_t value) {
std::vector<uint8_t> 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<uint8_t*>(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<uint8_t*>(manifest.leaves) + block * 4, payload + offset, count);
}
merkle_root(const_cast<uint8_t*>(manifest.merkle_root), manifest.leaves, manifest.block_count);
mh32(const_cast<uint8_t*>(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<uint8_t>(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<uint8_t>(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");
}
+107
View File
@@ -0,0 +1,107 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
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);
}
+123
View File
@@ -0,0 +1,123 @@
#include <helpers/ota/OtaStore.h>
#include <cassert>
#include <vector>
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<uint8_t> 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<CatRow> 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);
}
}
+6 -1
View File
@@ -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
@@ -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):
+63
View File
@@ -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()
+186
View File
@@ -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 <cassert>
#include <vector>
#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<GpioEvent> 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 <helpers/ota/OtaSdSpiBusNrf52.h>
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 <helpers/sensors/WisBlockSharedPowerPolicy.h>
#include <cassert>
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()
+23
View File
@@ -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 =
+5
View File
@@ -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#