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

+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