From 19d36daf505322ff8174e2a27e02705513773ecd Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 01:43:48 -0700 Subject: [PATCH] Preserve non-SD OTA staging during card maintenance --- src/helpers/CommonCLI.cpp | 14 +- .../rak15002_sd_seeder/maintenance.cpp | 127 ++++++++++++++++++ test/test_rak_sd_context_handoff.py | 23 ++++ 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/rak15002_sd_seeder/maintenance.cpp diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 323bb026b..26c2861b9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3659,12 +3659,22 @@ bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) { strcpy(reply, "Error: internal OTA slot is occupied; use ota cancel first"); return true; } +#elif defined(OTA_RAK15002_SD_STORE) && !defined(OTA_SEEDER_ONLY) + // The optional card is independent of an internal/NOR application fetch + // or an internal bootloader package. Formatting it must not reset that + // unrelated receive session or strand its still-pinned staging bytes. + if (!context.fetch_store.usesSd() && + (context.manager.fetchState() != mesh::ota::OtaManager::IDLE || + context.fetch_store.staged_size() != 0)) { + strcpy(reply, "Error: non-SD OTA slot is occupied; use ota cancel first"); + return true; + } #endif mesh::ota::OtaStoreSdNrf52& card = context.sdStagingStore(); - // A card-wide destructive operation invalidates any staged fetch. First let - // an archive capture checkpoint and close its shared card handles. + // A card-wide destructive operation invalidates the staged SD fetch. First + // let an archive capture checkpoint and close its shared card handles. context.prepareSdCardReset(); context.manager.reset_session(); context.manager.want(0); diff --git a/test/fixtures/rak15002_sd_seeder/maintenance.cpp b/test/fixtures/rak15002_sd_seeder/maintenance.cpp new file mode 100644 index 000000000..c70273158 --- /dev/null +++ b/test/fixtures/rak15002_sd_seeder/maintenance.cpp @@ -0,0 +1,127 @@ +// Compile the real card-maintenance CLI around its side-effect boundaries. +// No filesystem, flash, bootloader, or radio hardware is accessed here. +#include +#include +#include +#include +#include + +static uint32_t clock_ms = 500000; +static uint32_t millis() { return clock_ms; } +static const char* skipSpacesConst(const char* in) { + while (*in == ' ' || *in == '\t') ++in; + return in; +} +static constexpr uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 300000; +struct Board {}; + +namespace mesh { namespace ota { +struct OtaManager { + enum State { IDLE, FETCHING, COMPLETE, PAUSED, FAILED }; + State state = IDLE; + unsigned resets = 0, wants = 0, mids = 0; + State fetchState() const { return state; } + void reset_session() { ++resets; state = IDLE; } + void want(unsigned) { ++wants; } + void want_mid(const uint8_t*) { ++mids; } +}; + +enum Medium { Internal, Qspi, Sd, Unselected }; +struct FetchStore { + Medium medium = Unselected; + uint32_t staged = 0; + uint32_t staged_size() const { return staged; } +#if !defined(OTA_SEEDER_ONLY) + // The real source-only Companion's OtaStoreRam has neither selector. Its + // maintenance path must compile without granting an install backend. + bool usesInternal() const { return medium == Internal; } + bool usesSd() const { return medium == Sd; } +#endif +}; +struct OtaStoreSdNrf52 { + unsigned formats = 0, erases = 0; + bool format_ok = true, erase_ok = true; + bool formatCard(Board&) { ++formats; return format_ok; } + bool eraseCard(Board&) { ++erases; return erase_ok; } + const char* last_error() const { return "card unavailable"; } +}; +struct OtaContext { + OtaManager manager; + FetchStore fetch_store; + OtaStoreSdNrf52 card; + bool apply_pending = false; + uint32_t session_started_ms = 1234; + int prev_fstate = OtaManager::FETCHING; + unsigned prepares = 0, finishes = 0; + OtaStoreSdNrf52& sdStagingStore() { return card; } + void prepareSdCardReset() { ++prepares; } + void finishSdCardReset(uint32_t) { ++finishes; } +}; +static OtaContext context; +static bool ota_acquire_context(char*, size_t) { return true; } +static OtaContext& ota_ctx() { return context; } +} } + +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*); +}; +@METHODS@ + +int main() { + using namespace mesh::ota; + unsigned checked = 0; + for (const char* command : {"sdcard format", "sdcard erase", + "sdcard format --force", "sdcard erase --force"}) { + for (Medium medium : {Internal, Qspi, Sd, Unselected}) { + for (auto state : {OtaManager::IDLE, OtaManager::FETCHING, + OtaManager::COMPLETE, OtaManager::PAUSED, OtaManager::FAILED}) { + for (uint32_t staged : {0u, 4096u}) { + context = OtaContext{}; + context.fetch_store.medium = medium; + context.fetch_store.staged = staged; + context.manager.state = state; + CommonCLI cli; + // --force only bypasses the action cooldown, never store ownership. + if (strstr(command, "--force")) { + cli._sdcard_format_recorded = cli._sdcard_erase_recorded = true; + cli._sdcard_format_at = cli._sdcard_erase_at = clock_ms; + } + struct Reply { char text[160]; char guard[8] = "guarded"; } reply{}; + assert(cli.handleSdCardSetCmd(command, reply.text)); + assert(strcmp(reply.guard, "guarded") == 0); + const bool occupied = state != OtaManager::IDLE || staged != 0; +#if defined(OTA_TOWER_AUTO_STORE) + const bool refused = medium == Internal && occupied; +#elif defined(OTA_RAK15002_SD_STORE) && !defined(OTA_SEEDER_ONLY) + const bool refused = medium != Sd && occupied; +#else + (void)occupied; + const bool refused = false; +#endif + if (refused) { + assert(strstr(reply.text, "OTA slot is occupied; use ota cancel first")); + assert(context.manager.state == state && context.fetch_store.staged == staged); + assert(!context.prepares && !context.finishes); + assert(!context.manager.resets && !context.manager.wants && !context.manager.mids); + assert(!context.card.formats && !context.card.erases); + assert(context.session_started_ms == 1234 && context.prev_fstate == OtaManager::FETCHING); + } else { + assert(strncmp(reply.text, "OK - SD card ", 13) == 0); + assert(context.manager.state == OtaManager::IDLE); + assert(context.prepares == 1 && context.finishes == 1); + assert(context.manager.resets == 1 && context.manager.wants == 1 && context.manager.mids == 1); + assert(context.card.formats == 1 && context.card.erases == unsigned(strstr(command, "erase") != nullptr)); + assert(context.session_started_ms == 0 && context.prev_fstate == OtaManager::IDLE); + } + ++checked; + } + } + } + } + assert(checked == 160); + puts("PASS: SD maintenance preserves unrelated install storage (160 cases)"); +} diff --git a/test/test_rak_sd_context_handoff.py b/test/test_rak_sd_context_handoff.py index b078d3c62..41ad224bb 100644 --- a/test/test_rak_sd_context_handoff.py +++ b/test/test_rak_sd_context_handoff.py @@ -3,6 +3,7 @@ from pathlib import Path import unittest from test_rak15002_sd_seeder import build +from test_rak15002_shared_spi import run_native from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] @@ -102,6 +103,28 @@ int main() { class RakSdContextHandoffTests(unittest.TestCase): + def test_card_maintenance_preserves_non_sd_install_sessions(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(')) + fixture = (ROOT / 'test/fixtures/rak15002_sd_seeder/maintenance.cpp').read_text() + for profile in ( + ['OTA_RAK15002_SD_STORE', 'OTA_RAK_AUTO_STORE'], + ['OTA_RAK15002_SD_STORE', 'OTA_SEEDER_ONLY'], + ['OTA_TOWER_AUTO_STORE'], + []): + with self.subTest(profile=profile): + run_native(self, fixture.replace('@METHODS@', methods), + defines=['NRF52_PLATFORM', 'OTA_SD_STORE', *profile]) + # A passing native compile alone is not a regression check. Removing + # only the new ownership guard must reproduce the destructive reset. + guard = extract_braced(methods, 'if (!context.fetch_store.usesSd() &&') + regressed = methods.replace(guard, '', 1) + self.assertNotEqual(regressed, methods) + run_native(self, fixture.replace('@METHODS@', regressed), + defines=['NRF52_PLATFORM', 'OTA_SD_STORE', + 'OTA_RAK15002_SD_STORE', 'OTA_RAK_AUTO_STORE'], negative=True) + def test_actual_idle_handoff_and_every_pinned_work_guard(self): source = (ROOT / 'src/helpers/ota/OtaContext.h').read_text() methods = '\n'.join(extract_braced(source, signature) for signature in (