Add opt-in RAK15002 SD firmware seeder with isolated SPI and cache regression tests
This commit is contained in:
1 parent
41775ed707
commit
eebba0cb9a
21 files changed
+1082
-16
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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; }
|
||||
};
|
||||
Vendored
+280
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 =
|
||||
|
||||
@@ -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#
|
||||
|
||||
Reference in new issue
Block a user