From 3ce499a70084f970ca868551c97672c91d1eeda3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 22 Sep 2026 09:29:05 -0700 Subject: [PATCH] Fix nRF52 flash completion and preserve recoverable ExtraFS --- examples/companion_radio/DataStore.cpp | 77 ++- examples/companion_radio/DataStore.h | 6 +- examples/companion_radio/MyMesh.cpp | 36 +- examples/companion_radio/MyMesh.h | 1 + .../ResilientInternalExtraFS.cpp | 438 ++++++++++++++++++ .../ResilientInternalExtraFS.h | 101 ++++ examples/companion_radio/main.cpp | 37 +- platformio.ini | 1 + scripts/nrf52_internal_flash_fix.py | 59 +++ src/helpers/NRF52Board.cpp | 19 +- src/helpers/nrf52/InternalExtraFsPageMap.h | 41 ++ src/helpers/nrf52/SerialBLEInterface.cpp | 3 +- src/helpers/nrf52/SoftDeviceState.h | 18 + src/helpers/ota/OtaApply.cpp | 7 +- test/test_nrf52_ble_startup.py | 5 + test/test_nrf52_extrafs_contract.py | 12 +- test/test_nrf52_extrafs_page_map.py | 91 ++++ test/test_nrf52_internal_flash_fix.py | 46 ++ test/test_nrf52_uf2reset_cli.py | 2 +- variants/rak4631/RAK4631Board.cpp | 3 +- 20 files changed, 970 insertions(+), 33 deletions(-) create mode 100644 examples/companion_radio/ResilientInternalExtraFS.cpp create mode 100644 examples/companion_radio/ResilientInternalExtraFS.h create mode 100644 scripts/nrf52_internal_flash_fix.py create mode 100644 src/helpers/nrf52/InternalExtraFsPageMap.h create mode 100644 src/helpers/nrf52/SoftDeviceState.h create mode 100644 test/test_nrf52_extrafs_page_map.py create mode 100644 test/test_nrf52_internal_flash_fix.py diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 16e9b9c4..bc6c2173 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -210,7 +210,7 @@ void DataStore::begin() { #if defined(QSPIFLASH) #include #elif defined(EXTRAFS) - #include + #include "ResilientInternalExtraFS.h" #else #include #endif @@ -280,8 +280,10 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) { bool DataStore::recoverInternalExtraFSOnBoot() { if (_configuredFsExtra == nullptr) return false; - CustomLFS* extra = static_cast(_configuredFsExtra); + ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); if (_primary_storage_unavailable + || !extra->pageMapReady() || !mesh::storage::isExpectedInternalExtraFsGeometry( extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize()) || !mesh::storage::isInternalExtraFsReservedByApplication( @@ -291,6 +293,19 @@ bool DataStore::recoverInternalExtraFSOnBoot() { return false; } + if (extra->recoveryPending()) { + MESH_DEBUG_PRINTLN("DataStore: running requested boot-time ExtraFS scan"); + if (!reinitializeInternalExtraFS(true)) { + disableSecondaryFS(true); + return false; + } + _secondary_authority_unknown = false; + _contact_load_incomplete = false; + _identity_creation_blocked = false; + _prefs_load_incomplete = false; + return true; + } + const mesh::storage::InternalSecondaryFsRecoveryResult result = mesh::storage::recoverInternalSecondaryFilesystem( [this]() -> bool { @@ -306,14 +321,16 @@ bool DataStore::recoverInternalExtraFSOnBoot() { return true; }, [this]() -> bool { - MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS remains unusable; rebuilding 100 KiB (secondary-only data may be lost)"); - return reinitializeInternalExtraFS(); + MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS remains unusable after mount retry; scanning and rebuilding (secondary-only data may be lost)"); + return reinitializeInternalExtraFS(true); }); if (result == mesh::storage::InternalSecondaryFsRecoveryResult::Failed) { disableSecondaryFS(true); return false; } + extra->acknowledgeRecoveredBootHint(); + // This runs only before migration and user-data loading. Clear the initial // mount quarantine, not errors from a later incomplete contact/prefs load. // Migration below establishes authority again and can re-latch any error. @@ -324,13 +341,15 @@ bool DataStore::recoverInternalExtraFSOnBoot() { return true; } -bool DataStore::reinitializeInternalExtraFS() { +bool DataStore::reinitializeInternalExtraFS(bool scan_physical_pages) { if (_configuredFsExtra == nullptr) return false; - CustomLFS* extra = static_cast(_configuredFsExtra); + ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); if (!mesh::storage::isExpectedInternalExtraFsGeometry( extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize()) + || !extra->pageMapReady() || !mesh::storage::isInternalExtraFsReservedByApplication( (uint32_t)(uintptr_t)__flash_arduino_end)) { _fsExtra = nullptr; @@ -338,6 +357,16 @@ bool DataStore::reinitializeInternalExtraFS() { return false; } + // A physical test is destructive and must run only during early boot, before + // radio/UI tasks can issue competing SoftDevice flash operations. Ordinary + // USB repair and factory-reset paths retain the already-verified page map. + if ((scan_physical_pages && !extra->scanAndRetireBadPages()) + || (extra->pageMapNeedsSave() && !extra->savePageMap(*_fs))) { + _fsExtra = nullptr; + MESH_DEBUG_PRINTLN("DataStore: ExtraFS physical scan or bad-page map save failed"); + return false; + } + const mesh::storage::InternalSecondaryFsRepairResult result = mesh::storage::repairInternalSecondaryFilesystem( [extra]() -> bool { @@ -359,12 +388,15 @@ bool DataStore::reinitializeInternalExtraFS() { _fsExtra = nullptr; switch (result) { case mesh::storage::InternalSecondaryFsRepairResult::FormatFailed: + extra->setStage(ResilientInternalExtraFS::Stage::FormatFailed); MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair format failed"); break; case mesh::storage::InternalSecondaryFsRepairResult::MountFailed: + extra->setStage(ResilientInternalExtraFS::Stage::MountFailed); MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair mount failed"); break; case mesh::storage::InternalSecondaryFsRepairResult::ValidationFailed: + extra->setStage(ResilientInternalExtraFS::Stage::ValidationFailed); MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair validation failed"); break; default: @@ -374,6 +406,7 @@ bool DataStore::reinitializeInternalExtraFS() { } _fsExtra = _configuredFsExtra; + extra->setStage(ResilientInternalExtraFS::Stage::Repaired); MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repaired and reactivated"); return true; } @@ -648,6 +681,38 @@ bool DataStore::repairInternalExtraFS() { #endif } +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) +bool DataStore::requestInternalExtraFSBootScan() { + if (_primary_storage_unavailable || _configuredFsExtra == nullptr) { + return false; + } + ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); + return extra->requestBootScan(); +} + +bool DataStore::formatInternalExtraFSHealth(char* reply, + size_t reply_size) const { + if (reply == nullptr || reply_size == 0 || _configuredFsExtra == nullptr) { + return false; + } + const ResilientInternalExtraFS* extra = + static_cast(_configuredFsExtra); + snprintf(reply, reply_size, + "ExtraFS %s; bad=0x%07lX; detected=0x%07lX; pending=0x%07lX; usable=%luK; mount=%s; last=%s; scanned=%u; boot=0x%02X", + extra->pageMapReady() ? "mapped" : "map-unreadable", + (unsigned long)extra->badPages(), + (unsigned long)extra->detectedBadPages(), + (unsigned long)extra->pendingPages(), + (unsigned long)(extra->usableBytes() / 1024), + _fsExtra == _configuredFsExtra ? "active" : "unavailable", + ResilientInternalExtraFS::stageName(extra->stage()), + (unsigned)extra->scannedPages(), + (unsigned)extra->bootMarkerAtInit()); + return true; +} +#endif + bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) { #if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) if (!identity_store.recover("_main")) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index edea3ec1..bfa4e831 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -90,7 +90,7 @@ class DataStore void resetContactPageState(bool clear_incomplete = false); #if defined(EXTRAFS) && !defined(QSPIFLASH) bool recoverInternalExtraFSOnBoot(); - bool reinitializeInternalExtraFS(); + bool reinitializeInternalExtraFS(bool scan_physical_pages = false); #endif #endif @@ -106,6 +106,10 @@ public: void begin(); bool formatFileSystem(); bool repairInternalExtraFS(); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + bool requestInternalExtraFSBootScan(); + bool formatInternalExtraFSHealth(char* reply, size_t reply_size) const; +#endif FILESYSTEM* getPrimaryFS() const { return _fs; } FILESYSTEM* getSecondaryFS() const { return _fsExtra; } void markPrimaryFSUnavailable(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 19571ee7..68943c00 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2676,6 +2676,12 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + if (strcmp(command, "get flash.health") == 0) { + return _store->formatInternalExtraFSHealth(reply, reply_size); + } +#endif + if (strncmp(command, "get ", 4) == 0 && mesh::cli::handleStorageLayoutGet(command + 4, board, reply, reply_size)) { @@ -6036,7 +6042,9 @@ void MyMesh::repairInternalExtraFS(Stream& output) { const bool contacts_saved = _store->saveContacts(this, save_filter); const bool channels_saved = saveChannels(); - const bool capacity_ready = _store->getStorageTotalKb() == 100; + const uint32_t capacity_kib = _store->getStorageTotalKb(); + const bool capacity_ready = capacity_kib >= 84 && capacity_kib <= 100 + && (capacity_kib % 4) == 0; if (contacts_saved && channels_saved && capacity_ready && !_store->hasPendingContactWrites()) { mesh::resetLazyPersistenceAfterSuccess( @@ -6054,6 +6062,24 @@ void MyMesh::repairInternalExtraFS(Stream& output) { } } +void MyMesh::scanInternalExtraFS(Stream& output) { + if (!_store->requestInternalExtraFSBootScan()) { + output.println(" Error: could not record the boot-time ExtraFS scan request"); + char health[160]; + if (_store->formatInternalExtraFSHealth(health, sizeof(health))) { + output.printf(" > %s\n", health); + } + return; + } + output.println(" > boot-time physical scan scheduled; ExtraFS files will be erased; rebooting"); + // This command deliberately discards the secondary filesystem. The normal + // scheduled-reboot path refuses to reset when unsaved contacts exist, which + // would strand the durable scan request on a damaged store. + output.flush(); + delay(1000); + board.reboot(); +} + #if defined(MESHCORE_EXTRAFS_HIL) bool MyMesh::handleExtraFsHilCommand(const char* command, char* reply, size_t reply_size) { @@ -8349,6 +8375,8 @@ void MyMesh::handleTerminalCommand(char* command) { #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) } else if (strcmp(command, "repair extrafs") == 0) { repairInternalExtraFS(terminalOutput()); + } else if (strcmp(command, "scan extrafs erase") == 0) { + scanInternalExtraFS(terminalOutput()); #endif } else if (strcmp(command, "ver") == 0) { terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n", @@ -8362,6 +8390,9 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" board\r\n"); terminalOutput().print(" version\r\n"); terminalOutput().print(" get storage.layout\r\n"); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + terminalOutput().print(" get flash.health\r\n"); +#endif #if COMPANION_FEATURE_READER terminalOutput().print(" get reader : (World English Bible, offline)\r\n"); #endif @@ -8453,6 +8484,7 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" channel \r\n"); #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) terminalOutput().print(" repair extrafs (erases/rebuilds internal ExtraFS)\r\n"); + terminalOutput().print(" scan extrafs erase (USB only; reboot for destructive page test)\r\n"); #endif #if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI #if COMPANION_FEATURE_TEMP_RADIO @@ -8899,6 +8931,8 @@ void MyMesh::checkCLIRescueCmd() { #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) } else if (strcmp(cli_command, "repair extrafs") == 0) { repairInternalExtraFS(output); + } else if (strcmp(cli_command, "scan extrafs erase") == 0) { + scanInternalExtraFS(output); #endif } else if (strcmp(cli_command, "rebuild") == 0) { bool success = _store->formatFileSystem(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7d086dd7..0a82dae8 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -512,6 +512,7 @@ private: uint16_t released_slot); #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) void repairInternalExtraFS(Stream& output); + void scanInternalExtraFS(Stream& output); #if defined(MESHCORE_EXTRAFS_HIL) bool handleExtraFsHilCommand(const char* command, char* reply, size_t reply_size); diff --git a/examples/companion_radio/ResilientInternalExtraFS.cpp b/examples/companion_radio/ResilientInternalExtraFS.cpp new file mode 100644 index 00000000..aa30f953 --- /dev/null +++ b/examples/companion_radio/ResilientInternalExtraFS.cpp @@ -0,0 +1,438 @@ +#include "ResilientInternalExtraFS.h" + +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + +#include +#include +#include +#include +#include + +namespace { +constexpr char PAGE_MAP_PATH[] = "/extrafs.badpages"; +constexpr uint32_t PAGE_MAP_MAGIC = 0x31475042UL; // "BPG1" on nRF52 +#if defined(NRF52840_XXAA) +constexpr uint32_t PRIMARY_START = 0xED000UL; +#else +constexpr uint32_t PRIMARY_START = 0x6D000UL; +#endif +constexpr uint32_t PRIMARY_SIZE = 7UL * FLASH_NRF52_PAGE_SIZE; + +StaticSemaphore_t shared_flash_lock_storage; +SemaphoreHandle_t shared_flash_lock = nullptr; + +void feedInheritedWatchdog() { + if (NRF_WDT->RUNSTATUS == 0) return; + const uint32_t enabled = NRF_WDT->RREN & 0xFFUL; + for (uint8_t channel = 0; channel < 8; ++channel) { + if ((enabled & (1UL << channel)) != 0) { + NRF_WDT->RR[channel] = WDT_RR_RR_Reload; + } + } +} + +class FlashLock { +public: + FlashLock() { + if (shared_flash_lock == nullptr) { + shared_flash_lock = xSemaphoreCreateMutexStatic(&shared_flash_lock_storage); + } + xSemaphoreTake(shared_flash_lock, portMAX_DELAY); + } + ~FlashLock() { xSemaphoreGive(shared_flash_lock); } +}; + +int primaryRead(const struct lfs_config*, lfs_block_t block, lfs_off_t off, + void* buffer, lfs_size_t size) { + FlashLock lock; + return flash_nrf5x_read(buffer, PRIMARY_START + block * 128 + off, size) + == static_cast(size) ? 0 : LFS_ERR_IO; +} + +int primaryProg(const struct lfs_config*, lfs_block_t block, lfs_off_t off, + const void* buffer, lfs_size_t size) { + FlashLock lock; + return flash_nrf5x_write(PRIMARY_START + block * 128 + off, buffer, size) + == static_cast(size) ? 0 : LFS_ERR_IO; +} + +int primaryErase(const struct lfs_config*, lfs_block_t block) { + FlashLock lock; + const uint32_t address = PRIMARY_START + block * 128; + for (uint32_t offset = 0; offset < 128; offset++) { + if (flash_nrf5x_write8(address + offset, 0xFF) != 1) return LFS_ERR_IO; + } + return 0; +} + +int primarySync(const struct lfs_config*) { + FlashLock lock; + flash_nrf5x_flush(); + return 0; +} + +struct PageMapRecord { + uint32_t magic; + uint32_t bad_pages; + uint32_t pending_pages; + uint32_t check; +}; + +} // namespace + +using mesh::storage::countInternalExtraFsBadPages; + +struct lfs_config* ResilientInternalExtraFS::primaryConfig() { + static struct lfs_config config = {}; + config.read = primaryRead; + config.prog = primaryProg; + config.erase = primaryErase; + config.sync = primarySync; + config.read_size = 128; + config.prog_size = 128; + config.block_size = 128; + config.block_count = PRIMARY_SIZE / 128; + config.lookahead = 128; + return &config; +} + +ResilientInternalExtraFS::ResilientInternalExtraFS( + uint32_t flash_addr, uint32_t flash_size, uint32_t block_size) + : CustomLFS(flash_addr, flash_size, block_size) { + // Capture this before board power management can consume GPREGRET2. The + // bootloader leaves this register alone for an ordinary app reset. + const uint8_t retained = static_cast(NRF_POWER->GPREGRET2); + _boot_marker_at_init = retained; + if (retained == BOOT_SCAN_ALL) { + _boot_scan_requested = true; + _boot_scan_forced = true; + } else if (retained >= BOOT_SCAN_BAD_PAGE_BASE + && retained < BOOT_SCAN_BAD_PAGE_BASE + PAGE_COUNT) { + _retained_bad_page = retained - BOOT_SCAN_BAD_PAGE_BASE; + _boot_scan_requested = true; + } + _configure_lfs(); +} + +void ResilientInternalExtraFS::_configure_lfs() { + CustomLFS::_configure_lfs(); + _lfs_config.read = readCallback; + _lfs_config.prog = progCallback; + _lfs_config.erase = eraseCallback; + _lfs_config.sync = syncCallback; + _lfs_config.block_count = + (PAGE_COUNT - countInternalExtraFsBadPages(_bad_pages)) * BLOCKS_PER_PAGE; +} + +uint32_t ResilientInternalExtraFS::usableBytes() const { + return (PAGE_COUNT - countInternalExtraFsBadPages(_bad_pages)) * PAGE_SIZE; +} + +bool ResilientInternalExtraFS::loadPageMap(Adafruit_LittleFS& primary) { + _map_ready = false; + _bad_pages = 0; + _pending_pages = 0; + struct lfs_info info; + const int stat_result = lfs_stat(primary._getFS(), PAGE_MAP_PATH, &info); + if (stat_result == LFS_ERR_NOENT) { + _configure_lfs(); + _map_ready = true; + return true; + } + if (stat_result != LFS_ERR_OK || info.type != LFS_TYPE_REG + || info.size != sizeof(PageMapRecord)) return false; + + File file = primary.open(PAGE_MAP_PATH, FILE_O_READ); + if (!file) return false; + PageMapRecord record = {}; + const int read_count = file.read(reinterpret_cast(&record), + sizeof(record)); + file.close(); + if (read_count != sizeof(record) || record.magic != PAGE_MAP_MAGIC + || record.check != ~(record.magic ^ record.bad_pages + ^ record.pending_pages) + || (record.bad_pages & ~PAGE_MASK) != 0 + || (record.pending_pages & ~(PAGE_MASK | SCAN_REQUEST)) != 0 + || (record.bad_pages & record.pending_pages & PAGE_MASK) != 0 + || countInternalExtraFsBadPages( + record.bad_pages | (record.pending_pages & PAGE_MASK)) + > MAX_BAD_PAGES) { + return false; + } + _bad_pages = record.bad_pages; + _pending_pages = record.pending_pages; + _recorded_bad_pages = record.bad_pages; + _recorded_pending_pages = record.pending_pages; + _configure_lfs(); + _map_ready = true; + return true; +} + +bool ResilientInternalExtraFS::savePageMap(Adafruit_LittleFS& primary) { + if (!_map_ready) return false; + const PageMapRecord record = { + PAGE_MAP_MAGIC, _bad_pages, _pending_pages, + ~(PAGE_MAP_MAGIC ^ _bad_pages ^ _pending_pages)}; + mesh::AtomicFileWriter writer(&primary, PAGE_MAP_PATH); + const bool saved = writer + && writer.write(reinterpret_cast(&record), + sizeof(record)) == sizeof(record) + && writer.commit(); + if (saved) { + _recorded_bad_pages = _bad_pages; + _recorded_pending_pages = _pending_pages; + } + _stage = saved ? Stage::MapSaved : Stage::MapSaveFailed; + return saved; +} + +bool ResilientInternalExtraFS::pageMapNeedsSave() const { + return _bad_pages != _recorded_bad_pages + || _pending_pages != _recorded_pending_pages; +} + +bool ResilientInternalExtraFS::setBootScanRequest(uint8_t value) { + uint8_t sd_enabled = 0; + if (mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS) return false; + if (sd_enabled) { + volatile uint32_t readback = 0; + return sd_power_gpregret_clr(1, 0xFF) == NRF_SUCCESS + && sd_power_gpregret_set(1, value) == NRF_SUCCESS + && sd_power_gpregret_get(1, (uint32_t*)&readback) == NRF_SUCCESS + && static_cast(readback) == value; + } + NRF_POWER->GPREGRET2 = value; + return static_cast(NRF_POWER->GPREGRET2) == value; +} + +bool ResilientInternalExtraFS::clearBootScanRequest() { + if (!_boot_scan_requested) return true; + uint8_t sd_enabled = 0; + if (mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS) return false; + if (sd_enabled) { + if (sd_power_gpregret_clr(1, 0xFF) != NRF_SUCCESS) return false; + } else { + NRF_POWER->GPREGRET2 = 0; + } + _boot_scan_requested = false; + return true; +} + +bool ResilientInternalExtraFS::requestBootScan() { + if (!_map_ready) return false; + // Do not touch the primary filesystem while radio/BLE tasks are running. + // The suspect 4 KiB page (if known) fits in one retained byte. A fault hint + // first gets a non-destructive mount and traversal at boot; only an explicit + // user scan or unrecoverable filesystem triggers the destructive test. + uint8_t marker = BOOT_SCAN_ALL; + if ((_pending_pages & PAGE_MASK) != 0) { + for (uint8_t page = 0; page < PAGE_COUNT; ++page) { + if ((_pending_pages & (1UL << page)) != 0) { + marker = BOOT_SCAN_BAD_PAGE_BASE + page; + break; + } + } + } + return setBootScanRequest(marker); +} + +void ResilientInternalExtraFS::acknowledgeRecoveredBootHint() { + // A healthy mount and successful file traversal are enough to preserve + // the existing filesystem after a transient runtime flash error. The + // hint remains a diagnostic only; it is not a bad-page verdict. + if (!_boot_scan_forced) { + clearBootScanRequest(); + _pending_pages &= SCAN_REQUEST; + _retained_bad_page = 0xFF; + } +} + +const char* ResilientInternalExtraFS::stageName(Stage stage) { + switch (stage) { + case Stage::Ready: return "ready"; + case Stage::Scanning: return "scanning"; + case Stage::TooManyBadPages: return "too-many-bad-pages"; + case Stage::Scanned: return "scanned"; + case Stage::MapSaveFailed: return "map-save-failed"; + case Stage::MapSaved: return "map-saved"; + case Stage::FormatFailed: return "format-failed"; + case Stage::MountFailed: return "mount-failed"; + case Stage::ValidationFailed: return "validation-failed"; + case Stage::Repaired: return "repaired"; + } + return "unknown"; +} + +uint32_t ResilientInternalExtraFS::physicalPageForBlock( + lfs_block_t block) const { + if (block >= _lfs_config.block_count) return INVALID_PAGE; + return mesh::storage::internalExtraFsPhysicalPage( + _bad_pages, block / BLOCKS_PER_PAGE); +} + +void ResilientInternalExtraFS::retirePending(uint32_t physical_page) { + if (physical_page < PAGE_COUNT) { + _pending_pages |= 1UL << physical_page; + } + _io_failed = true; + _dirty = false; + _cached_page = INVALID_PAGE; +} + +int ResilientInternalExtraFS::commitPage() { + if (_io_failed) return LFS_ERR_CORRUPT; + if (!_dirty) return 0; + const uint32_t address = _flash_addr + _cached_page * PAGE_SIZE; + if (flash_nrf5x_write(address, _page_buffer, PAGE_SIZE) != PAGE_SIZE) { + retirePending(_cached_page); + return LFS_ERR_CORRUPT; + } + flash_nrf5x_flush(); + // Read actual flash, not flash_nrf5x_read(): that API may return its RAM + // cache even when the physical erase/program silently failed. + if (memcmp(reinterpret_cast(address), _page_buffer, + PAGE_SIZE) != 0) { + retirePending(_cached_page); + return LFS_ERR_CORRUPT; + } + _dirty = false; + return 0; +} + +int ResilientInternalExtraFS::loadPage(uint32_t physical_page) { + if (_io_failed || physical_page >= PAGE_COUNT) return LFS_ERR_CORRUPT; + if (physical_page == _cached_page) return 0; + const int result = commitPage(); + if (result != 0) return result; + flash_nrf5x_flush(); + memcpy(_page_buffer, + reinterpret_cast(_flash_addr + physical_page * PAGE_SIZE), + PAGE_SIZE); + _cached_page = physical_page; + return 0; +} + +int ResilientInternalExtraFS::readCallback( + const struct lfs_config* config, lfs_block_t block, lfs_off_t off, + void* buffer, lfs_size_t size) { + FlashLock lock; + auto* fs = static_cast(config->context); + const uint32_t page = fs->physicalPageForBlock(block); + if (page == INVALID_PAGE || off > fs->_block_size + || size > fs->_block_size - off || fs->loadPage(page) != 0) { + return LFS_ERR_CORRUPT; + } + memcpy(buffer, fs->_page_buffer + (block % BLOCKS_PER_PAGE) * fs->_block_size + + off, size); + return 0; +} + +int ResilientInternalExtraFS::progCallback( + const struct lfs_config* config, lfs_block_t block, lfs_off_t off, + const void* buffer, lfs_size_t size) { + FlashLock lock; + auto* fs = static_cast(config->context); + const uint32_t page = fs->physicalPageForBlock(block); + if (page == INVALID_PAGE || off > fs->_block_size + || size > fs->_block_size - off || fs->loadPage(page) != 0) { + return LFS_ERR_CORRUPT; + } + memcpy(fs->_page_buffer + (block % BLOCKS_PER_PAGE) * fs->_block_size + off, + buffer, size); + fs->_dirty = true; + return 0; +} + +int ResilientInternalExtraFS::eraseCallback( + const struct lfs_config* config, lfs_block_t block) { + FlashLock lock; + auto* fs = static_cast(config->context); + const uint32_t page = fs->physicalPageForBlock(block); + if (page == INVALID_PAGE || fs->loadPage(page) != 0) { + return LFS_ERR_CORRUPT; + } + memset(fs->_page_buffer + (block % BLOCKS_PER_PAGE) * fs->_block_size, + 0xFF, fs->_block_size); + fs->_dirty = true; + return 0; +} + +int ResilientInternalExtraFS::syncCallback(const struct lfs_config* config) { + FlashLock lock; + auto* fs = static_cast(config->context); + return fs->commitPage(); +} + +bool ResilientInternalExtraFS::rawPageEquals(uint32_t address, + uint8_t value) const { + const volatile uint8_t* raw = + reinterpret_cast(address); + for (uint32_t offset = 0; offset < PAGE_SIZE; offset++) { + if (raw[offset] != value) return false; + } + return true; +} + +bool ResilientInternalExtraFS::testPhysicalPage(uint32_t physical_page) { + const uint32_t address = _flash_addr + physical_page * PAGE_SIZE; + for (uint8_t attempt = 0; attempt < 2; attempt++) { + feedInheritedWatchdog(); + flash_nrf5x_flush(); + if (!flash_nrf5x_erase(address) || !rawPageEquals(address, 0xFF)) continue; + feedInheritedWatchdog(); + memset(_page_buffer, 0, PAGE_SIZE); + if (flash_nrf5x_write(address, _page_buffer, PAGE_SIZE) != PAGE_SIZE) { + continue; + } + flash_nrf5x_flush(); + if (!rawPageEquals(address, 0x00)) continue; + feedInheritedWatchdog(); + if (flash_nrf5x_erase(address) && rawPageEquals(address, 0xFF)) { + return true; + } + } + return false; +} + +bool ResilientInternalExtraFS::scanAndRetireBadPages() { + if (!_map_ready || _flash_addr != 0xD4000UL || _flash_total_size != 0x19000UL + || _block_size != 128) return false; + if (!clearBootScanRequest()) return false; + end(); + _cached_page = INVALID_PAGE; + _dirty = false; + _io_failed = false; + _stage = Stage::Scanning; + _scanned_pages = 0; + _detected_bad_pages = 0; + + // A runtime readback mismatch is a reason to scan, not proof of a dead + // physical page: SoftDevice contention can produce transient failures. + // Retire only a page that fails the isolated early-boot erase/write/read + // test on both attempts. + uint32_t bad = _bad_pages; + if (countInternalExtraFsBadPages(bad) > MAX_BAD_PAGES) { + _stage = Stage::TooManyBadPages; + return false; + } + for (uint32_t page = 0; page < PAGE_COUNT; page++) { + feedInheritedWatchdog(); + if ((bad & (1UL << page)) != 0) continue; + if (!testPhysicalPage(page)) { + bad |= 1UL << page; + _detected_bad_pages |= 1UL << page; + if (countInternalExtraFsBadPages(bad) > MAX_BAD_PAGES) { + _stage = Stage::TooManyBadPages; + return false; + } + } + _scanned_pages++; + } + _bad_pages = bad; + _pending_pages = 0; + _configure_lfs(); + _stage = Stage::Scanned; + return true; +} + +#endif diff --git a/examples/companion_radio/ResilientInternalExtraFS.h b/examples/companion_radio/ResilientInternalExtraFS.h new file mode 100644 index 00000000..b0209c3c --- /dev/null +++ b/examples/companion_radio/ResilientInternalExtraFS.h @@ -0,0 +1,101 @@ +#pragma once + +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + +#include +#include + +// The T1000-E and other nRF52 internal-ExtraFS companions use 25 physical +// 4 KiB pages. LittleFS sees 128-byte blocks; a failed physical page must be +// removed as a whole, not treated as 32 unrelated bad blocks. +class ResilientInternalExtraFS : public CustomLFS { +public: + enum class Stage : uint8_t { + Ready, Scanning, TooManyBadPages, Scanned, MapSaveFailed, + MapSaved, FormatFailed, MountFailed, ValidationFailed, Repaired + }; + static constexpr uint32_t PAGE_COUNT = + mesh::storage::INTERNAL_EXTRAFS_PAGE_COUNT; + static constexpr uint32_t PAGE_SIZE = FLASH_NRF52_PAGE_SIZE; + static constexpr uint32_t BLOCKS_PER_PAGE = PAGE_SIZE / 128; + + ResilientInternalExtraFS(uint32_t flash_addr, uint32_t flash_size, + uint32_t block_size); + static struct lfs_config* primaryConfig(); + + // Read the per-device page map from primary identity storage before the + // first secondary mount. An unreadable map must never be guessed as empty. + bool loadPageMap(Adafruit_LittleFS& primary); + bool pageMapReady() const { return _map_ready; } + bool recoveryPending() const { + return _boot_scan_forced || (_pending_pages & SCAN_REQUEST) != 0; + } + bool ioFailed() const { return _io_failed; } + uint32_t badPages() const { return _bad_pages; } + uint32_t pendingPages() const { return _pending_pages; } + uint32_t usableBytes() const; + Stage stage() const { return _stage; } + uint8_t scannedPages() const { return _scanned_pages; } + uint32_t detectedBadPages() const { return _detected_bad_pages; } + uint8_t bootMarkerAtInit() const { return _boot_marker_at_init; } + static const char* stageName(Stage stage); + void setStage(Stage stage) { _stage = stage; } + + // Only call after ExtraFS has been unmounted and its contents have been + // declared unrecoverable. Tests every physical page by erase / program / + // direct flash readback / erase, then maps LittleFS around bad pages. + bool scanAndRetireBadPages(); + bool savePageMap(Adafruit_LittleFS& primary); + bool pageMapNeedsSave() const; + bool requestBootScan(); + void acknowledgeRecoveredBootHint(); + +protected: + void _configure_lfs() override; + +private: + static constexpr uint32_t INVALID_PAGE = 0xFFFFFFFFUL; + static constexpr uint32_t PAGE_MASK = + mesh::storage::INTERNAL_EXTRAFS_PAGE_MASK; + static constexpr uint32_t SCAN_REQUEST = 1UL << 31; + static constexpr uint8_t MAX_BAD_PAGES = 4; + // GPREGRET2 is retained across a software reset. These values are distinct + // from the OTA/bootloader handoff and power-management reason codes. + static constexpr uint8_t BOOT_SCAN_ALL = 0x9F; + static constexpr uint8_t BOOT_SCAN_BAD_PAGE_BASE = 0x80; + + uint32_t _bad_pages = 0; + uint32_t _pending_pages = 0; + bool _boot_scan_requested = false; + bool _boot_scan_forced = false; + uint8_t _boot_marker_at_init = 0; + uint8_t _retained_bad_page = 0xFF; + uint32_t _recorded_bad_pages = 0; + uint32_t _recorded_pending_pages = 0; + uint32_t _cached_page = INVALID_PAGE; + bool _dirty = false; + bool _io_failed = false; + bool _map_ready = false; + Stage _stage = Stage::Ready; + uint8_t _scanned_pages = 0; + uint32_t _detected_bad_pages = 0; + alignas(4) uint8_t _page_buffer[PAGE_SIZE]; + + uint32_t physicalPageForBlock(lfs_block_t block) const; + int loadPage(uint32_t physical_page); + int commitPage(); + bool testPhysicalPage(uint32_t physical_page); + bool rawPageEquals(uint32_t address, uint8_t value) const; + void retirePending(uint32_t physical_page); + bool setBootScanRequest(uint8_t value); + bool clearBootScanRequest(); + + static int readCallback(const struct lfs_config* config, lfs_block_t block, + lfs_off_t off, void* buffer, lfs_size_t size); + static int progCallback(const struct lfs_config* config, lfs_block_t block, + lfs_off_t off, const void* buffer, lfs_size_t size); + static int eraseCallback(const struct lfs_config* config, lfs_block_t block); + static int syncCallback(const struct lfs_config* config); +}; + +#endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d3648815..2eeea8ee 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -198,9 +198,9 @@ static bool isNetworkTerminalActive(); DataStore store(InternalFS, QSPIFlash, rtc_clock); #else #if defined(EXTRAFS) - #include + #include "ResilientInternalExtraFS.h" extern "C" uint32_t __flash_arduino_end[]; - CustomLFS ExtraFS(0xD4000, 0x19000, 128); + ResilientInternalExtraFS ExtraFS(0xD4000, 0x19000, 128); DataStore store(InternalFS, ExtraFS, rtc_clock); #else DataStore store(InternalFS, rtc_clock); @@ -2799,7 +2799,12 @@ void setup() { const mesh::storage::InternalSecondaryFsBootResult primary_fs_boot = mesh::storage::prepareInternalSecondaryFilesystem( []() -> bool { +#if defined(EXTRAFS) && !defined(QSPIFLASH) + return InternalFS.Adafruit_LittleFS::begin( + ResilientInternalExtraFS::primaryConfig()); +#else return InternalFS.Adafruit_LittleFS::begin(); +#endif }, []() -> bool { return mesh::storage::isErasedFlashRange( @@ -2848,6 +2853,10 @@ void setup() { } #else #if defined(EXTRAFS) + const bool extra_fs_map_ready = + (primary_fs_boot == mesh::storage::InternalSecondaryFsBootResult::Mounted + || primary_fs_boot == mesh::storage::InternalSecondaryFsBootResult::InitializedBlank) + && ExtraFS.loadPageMap(InternalFS); const bool extra_fs_geometry_valid = mesh::storage::isExpectedInternalExtraFsGeometry( ExtraFS.getFlashAddr(), ExtraFS.getFlashSize(), @@ -2856,10 +2865,15 @@ void setup() { (uint32_t)(uintptr_t)__flash_arduino_end); // Initial mount is always non-destructive, even for blank media. // DataStore validates primary first, then retries/rebuilds ExtraFS. - if (!extra_fs_geometry_valid + if (!extra_fs_map_ready || !extra_fs_geometry_valid + || ExtraFS.recoveryPending() || !ExtraFS.Adafruit_LittleFS::begin()) { - if (!extra_fs_geometry_valid) { + if (!extra_fs_map_ready) { + MESH_DEBUG_PRINTLN("CustomLFS: bad-page map unreadable; preserving ExtraFS and blocking writes"); + } else if (!extra_fs_geometry_valid) { MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS geometry is not 100 KiB; refusing to mount or erase it"); + } else if (ExtraFS.recoveryPending()) { + MESH_DEBUG_PRINTLN("CustomLFS: recorded physical page fault; deferring scan until primary validation"); } else { MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS mount failed; deferring automatic recovery until primary validation"); } @@ -3185,6 +3199,21 @@ void loop() { expireUsbBinaryStartupProbeBeforeDispatch(); #endif the_mesh.loop(); +#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) + // The flash-page number is retained across a software reset without trying + // to write primary identity storage while radio/BLE tasks are running. + // Early boot then verifies and records the page map before remounting. + if (ExtraFS.ioFailed()) { + static uint32_t last_fault_record_attempt = 0; + if (static_cast(millis() - last_fault_record_attempt) >= 5000) { + last_fault_record_attempt = millis(); + if (ExtraFS.requestBootScan()) { + MESH_DEBUG_PRINTLN("ExtraFS: physical write failed; rebooting for isolated page scan"); + board.reboot(); + } + } + } +#endif #ifdef RECOVERABLE_EXTERNAL_RADIO serviceCompanionRadioRecovery(); #endif diff --git a/platformio.ini b/platformio.ini index 017246f9..5926a96a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -154,6 +154,7 @@ platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 extra_scripts = create-uf2.py pre:scripts/nrf52_usb_power_fix.py + pre:scripts/nrf52_internal_flash_fix.py pre:scripts/nrf52_internal_bootloader_link.py ; Nordic's builder otherwise overwrites the LTO-aware archive tools. pre:scripts/nrf52_lto_toolchain.py diff --git a/scripts/nrf52_internal_flash_fix.py b/scripts/nrf52_internal_flash_fix.py new file mode 100644 index 00000000..cac9db4b --- /dev/null +++ b/scripts/nrf52_internal_flash_fix.py @@ -0,0 +1,59 @@ +"""Build-local fix for the nRF52 InternalFS SoftDevice flash wait. + +GCC 14/LTO can treat the naked SVC wrapper as not writing through its pointer +argument. It then folds wait_for_async_flash_op_completion() into an immediate +success return, allowing LittleFS to read or rewrite a page before the async +erase/program has completed. Keep the SoftDevice state volatile and add a +compiler barrier after the SVC call. Do not modify PlatformIO's shared SDK. +""" + +from pathlib import Path + + +OLD_WAIT = """ uint8_t sd_en = 0; + (void) sd_softdevice_is_enabled(&sd_en); + + if (sd_en) { +""" +FIXED_WAIT = """ volatile uint8_t sd_en = 0; + (void) sd_softdevice_is_enabled((uint8_t*) &sd_en); + __asm volatile ("" ::: "memory"); + + if (sd_en) { +""" + + +def patched_source(source): + source = source.replace("\r\n", "\n") + if FIXED_WAIT in source and OLD_WAIT not in source: + return source + if source.count(OLD_WAIT) != 1: + raise RuntimeError( + "nRF52 flash fix: unrecognized InternalFS flash driver; review " + "the framework update before building (shared SDK not modified)" + ) + return source.replace(OLD_WAIT, FIXED_WAIT) + + +def replace_driver(build_env, node): + source = Path(node.srcnode().get_abspath()) + patched = patched_source(source.read_text(encoding="utf-8")) + build_env.AppendUnique(CPPPATH=[str(source.parent)]) + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-flash" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists() or destination.read_text(encoding="utf-8") != patched: + destination.write_text(patched, encoding="utf-8") + print("nRF52 flash: build-local SoftDevice completion wait fix enabled") + return build_env.File(str(destination)) + + +def install(build_env): + build_env.AddBuildMiddleware( + replace_driver, + "*InternalFileSytem*src*flash*flash_nrf5x.c", + ) + + +if "Import" in globals(): + Import("env") + install(env) diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 8c484096..781ec55a 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -2,6 +2,7 @@ #include "NRF52Board.h" #include "PowerManagementUtils.h" #include "Nrf52BootloaderVersion.h" +#include "nrf52/SoftDeviceState.h" #include #ifdef USER_GPIO_CONTROL #include "UserGpioPinPolicy.h" @@ -103,7 +104,7 @@ bool NRF52Board::isUserGpioAvailable(uint8_t pin) const { bool NRF52Board::rebootToUf2Bootloader() { uint8_t sd_enabled = 0; - if (sd_softdevice_is_enabled(&sd_enabled) != NRF_SUCCESS) return false; + if (mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS) return false; if (sd_enabled) { if (sd_power_gpregret_clr(0, 0xFF) != NRF_SUCCESS @@ -211,7 +212,7 @@ void NRF52Board::initPowerMgr() { // Clear registers for next boot // Note: At this point SoftDevice may or may not be enabled uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { sd_power_reset_reason_clr(0xFFFFFFFF); sd_power_gpregret_clr(1, 0xFF); @@ -308,7 +309,7 @@ void NRF52Board::enterSystemOff(uint8_t reason) { // Record shutdown reason in GPREGRET2 uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { sd_power_gpregret_clr(1, 0xFF); sd_power_gpregret_set(1, reason); @@ -399,7 +400,7 @@ void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { void NRF52Board::armVbusWake() { // Configure VBUS (USB power) wake alongside (or instead of) LPCOMP. uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { sd_power_usbdetected_enable(1); } else { @@ -415,7 +416,7 @@ void NRF52BoardDCDC::begin() { // Enable DC/DC converter for improved power efficiency uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { sd_power_dcdc_mode_set(NRF_POWER_DCDC_ENABLE); } else { @@ -426,7 +427,7 @@ void NRF52BoardDCDC::begin() { bool NRF52Board::isExternalPowered() { // Check if SoftDevice is enabled before using its API uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { uint32_t usb_status; @@ -470,7 +471,7 @@ void NRF52Board::sleep(uint32_t secs) { // On nRF52, we use event-driven sleep instead of timed sleep // The 'secs' parameter is ignored - we wake on any interrupt uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { // A single call is required here. If an interrupt arrived since the last @@ -492,7 +493,7 @@ void NRF52Board::sleep(uint32_t secs) { // Temperature from NRF52 MCU float NRF52Board::getMCUTemperature() { uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { uint32_t err_code; int32_t temp; @@ -587,7 +588,7 @@ void NRF52Board::powerOff() { // Enter SYSTEMOFF uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { // SoftDevice is enabled sd_power_system_off(); } else { // SoftDevice is not enable diff --git a/src/helpers/nrf52/InternalExtraFsPageMap.h b/src/helpers/nrf52/InternalExtraFsPageMap.h new file mode 100644 index 00000000..b81badfd --- /dev/null +++ b/src/helpers/nrf52/InternalExtraFsPageMap.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace mesh { +namespace storage { + +static const uint32_t INTERNAL_EXTRAFS_PAGE_COUNT = 25; +static const uint32_t INTERNAL_EXTRAFS_PAGE_MASK = + (1UL << INTERNAL_EXTRAFS_PAGE_COUNT) - 1UL; + +inline uint8_t countInternalExtraFsBadPages(uint32_t mask) { + uint8_t count = 0; + while (mask != 0) { + count += static_cast(mask & 1U); + mask >>= 1; + } + return count; +} + +// The old all-good layout is the identity map. Once a confirmed bad page is +// retired, every logical 4 KiB page after it moves to the next good physical +// page. A fixed 128-byte LittleFS block never straddles those page boundaries. +inline uint32_t internalExtraFsPhysicalPage(uint32_t bad_mask, + uint32_t logical_page) { + if ((bad_mask & ~INTERNAL_EXTRAFS_PAGE_MASK) != 0 + || logical_page >= INTERNAL_EXTRAFS_PAGE_COUNT + - countInternalExtraFsBadPages(bad_mask)) { + return UINT32_MAX; + } + for (uint32_t physical = 0; physical < INTERNAL_EXTRAFS_PAGE_COUNT; + physical++) { + if ((bad_mask & (1UL << physical)) != 0) continue; + if (logical_page == 0) return physical; + logical_page--; + } + return UINT32_MAX; +} + +} // namespace storage +} // namespace mesh diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 57a38e1c..c7c05c67 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -1,6 +1,7 @@ #include "SerialBLEInterface.h" #include "../BluetoothMac.h" #include "../CompanionFrameQueue.h" +#include "SoftDeviceState.h" #include #include #include "ble_gap.h" @@ -439,7 +440,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, if (!Bluefruit.begin()) { uint8_t softdevice_enabled = 0; const uint32_t softdevice_status = - sd_softdevice_is_enabled(&softdevice_enabled); + mesh_nrf52::softdeviceIsEnabled(softdevice_enabled); ble_gap_addr_t address = {}; const uint32_t gap_status = softdevice_enabled ? sd_ble_gap_addr_get(&address) : NRF_ERROR_INVALID_STATE; diff --git a/src/helpers/nrf52/SoftDeviceState.h b/src/helpers/nrf52/SoftDeviceState.h new file mode 100644 index 00000000..f8e09c03 --- /dev/null +++ b/src/helpers/nrf52/SoftDeviceState.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace mesh_nrf52 { + +// sd_softdevice_is_enabled is an SVC wrapper. With LTO, its assembly body +// does not tell GCC that the output pointer is written by the supervisor. +// Load the result from volatile storage after a compiler memory barrier. +inline uint32_t softdeviceIsEnabled(uint8_t& enabled) { + volatile uint8_t state = 0; + const uint32_t result = sd_softdevice_is_enabled((uint8_t*)&state); + __asm volatile ("" ::: "memory"); + enabled = state; + return result; +} + +} // namespace mesh_nrf52 diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 2aa4fc83..23e2bedb 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -26,6 +26,7 @@ #include "nrf.h" #include "nrf_soc.h" #include "nrf_sdm.h" + #include "../nrf52/SoftDeviceState.h" #if defined(OTA_SD_STORE) #include "OtaStoreSdNrf52.h" #endif @@ -467,7 +468,7 @@ static OtaStoreFlashNrf52* g_nrf52_apply_store = nullptr; static void ota_nrf52_set_reset_handoff(uint8_t request, uint8_t source) { uint8_t sd_en = 0; - sd_softdevice_is_enabled(&sd_en); + mesh_nrf52::softdeviceIsEnabled(sd_en); if (sd_en) { // POWER is SD-restricted while the SoftDevice runs sd_power_gpregret_clr(1, 0xFFFFFFFF); sd_power_gpregret_set(1, source); @@ -481,7 +482,7 @@ static void ota_nrf52_set_reset_handoff(uint8_t request, uint8_t source) { static bool ota_nrf52_clear_reset_reasons() { uint8_t sd_en = 0; - if (sd_softdevice_is_enabled(&sd_en) != NRF_SUCCESS) return false; + if (mesh_nrf52::softdeviceIsEnabled(sd_en) != NRF_SUCCESS) return false; if (sd_en) { return sd_power_reset_reason_clr(0xFFFFFFFFu) == NRF_SUCCESS; } @@ -527,7 +528,7 @@ void ota_reboot_to_bootloader_update() { source = GPREGRET2_OTA_STAGE_SD; #endif uint8_t sd_en = 0; - sd_softdevice_is_enabled(&sd_en); + mesh_nrf52::softdeviceIsEnabled(sd_en); if (sd_en) { sd_power_gpregret_clr(1, 0xFFFFFFFF); sd_power_gpregret_set(1, source); diff --git a/test/test_nrf52_ble_startup.py b/test/test_nrf52_ble_startup.py index 26bb0948..569044a0 100644 --- a/test/test_nrf52_ble_startup.py +++ b/test/test_nrf52_ble_startup.py @@ -63,6 +63,11 @@ struct ble_gap_conn_params_t { unsigned min_conn_interval, max_conn_interval, sl int sd_ble_gap_addr_set(ble_gap_addr_t*) { return NRF_SUCCESS; } int sd_ble_gap_addr_get(ble_gap_addr_t* a) { memset(a, 0, sizeof(*a)); return NRF_SUCCESS; } int sd_softdevice_is_enabled(uint8_t* enabled) { *enabled = 0; return NRF_SUCCESS; } +namespace mesh_nrf52 { +int softdeviceIsEnabled(uint8_t& enabled) { + return sd_softdevice_is_enabled(&enabled); +} +} int sd_ble_gap_ppcp_set(ble_gap_conn_params_t*) { return NRF_SUCCESS; } namespace mesh { struct Logging { void println(const char*) {} }; diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index a11b8805..85c8e15c 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -363,7 +363,7 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): main = MAIN.read_text(encoding="utf-8") geometry = re.search( - r"CustomLFS\s+ExtraFS\s*\(\s*(0x[0-9A-Fa-f]+)\s*,\s*" + r"ResilientInternalExtraFS\s+ExtraFS\s*\(\s*(0x[0-9A-Fa-f]+)\s*,\s*" r"(0x[0-9A-Fa-f]+)\s*,\s*128\s*\)", main, ) @@ -463,7 +463,7 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): ready_at = recover.index("validateLfsFilesystem(_fsExtra)", policy_at) unmount_at = recover.index("extra->end()", ready_at) remount_at = recover.index("extra->Adafruit_LittleFS::begin()", unmount_at) - repair_at = recover.index("reinitializeInternalExtraFS()", remount_at) + repair_at = recover.index("reinitializeInternalExtraFS(true)", remount_at) self.assertLess(ready_at, unmount_at) self.assertLess(unmount_at, remount_at) self.assertLess(remount_at, repair_at) @@ -475,7 +475,7 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): store = STORE.read_text(encoding="utf-8") recover = function_body(store, "bool DataStore::recoverInternalExtraFSOnBoot()") failure_at = recover.index("InternalSecondaryFsRecoveryResult::Failed") - clear_at = recover.index("_secondary_authority_unknown = false;") + clear_at = recover.rindex("_secondary_authority_unknown = false;") failure = recover[failure_at:clear_at] self.assertIn("disableSecondaryFS(true);", failure) self.assertIn("return false;", failure) @@ -509,7 +509,7 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertNotIn("format()", active_retry) reinitialize = function_body( - store, "bool DataStore::reinitializeInternalExtraFS()" + store, "bool DataStore::reinitializeInternalExtraFS(bool scan_physical_pages)" ) geometry_at = reinitialize.index("isExpectedInternalExtraFsGeometry(") self.assertIn("isInternalExtraFsReservedByApplication(", reinitialize) @@ -944,7 +944,7 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): # nonblank filesystem after any mount failure. The sole occurrence is # the separately scoped STM32 fallback. self.assertEqual(setup.count("InternalFS.begin();"), 1) - self.assertGreater(setup.index("#else", primary_policy), primary_format) + self.assertGreater(setup.index("#else\n InternalFS.begin();", primary_policy), primary_format) self.assertIn("store.disableSecondaryFS(true);", setup) secondary_geometry = setup.index( "const bool extra_fs_geometry_valid =" @@ -980,7 +980,7 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): self.assertIn("_store->repairInternalExtraFS()", repair) self.assertIn("_store->saveContacts(this, save_filter)", repair) self.assertIn("const bool channels_saved = saveChannels();", repair) - self.assertIn("_store->getStorageTotalKb() == 100", repair) + self.assertIn("capacity_kib >= 84 && capacity_kib <= 100", repair) self.assertIn("getStorageTotalKb()", repair) rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()") diff --git a/test/test_nrf52_extrafs_page_map.py b/test/test_nrf52_extrafs_page_map.py new file mode 100644 index 00000000..2a6adf24 --- /dev/null +++ b/test/test_nrf52_extrafs_page_map.py @@ -0,0 +1,91 @@ +"""Host coverage for physical-page retirement in nRF52 internal ExtraFS.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class InternalExtraFsPageMapTest(unittest.TestCase): + def test_mapping_skips_whole_bad_pages_and_keeps_legacy_identity_layout(self): + source = r""" +#include +#include + +using mesh::storage::countInternalExtraFsBadPages; +using mesh::storage::internalExtraFsPhysicalPage; + +int main() { + for (uint32_t p = 0; p < 25; ++p) { + if (internalExtraFsPhysicalPage(0, p) != p) return 1; + } + if (internalExtraFsPhysicalPage(0, 25) != UINT32_MAX) return 2; + + const uint32_t middle = 1UL << 12; + if (countInternalExtraFsBadPages(middle) != 1) return 3; + if (internalExtraFsPhysicalPage(middle, 11) != 11) return 4; + if (internalExtraFsPhysicalPage(middle, 12) != 13) return 5; + if (internalExtraFsPhysicalPage(middle, 23) != 24) return 6; + if (internalExtraFsPhysicalPage(middle, 24) != UINT32_MAX) return 7; + + const uint32_t edges = (1UL << 0) | (1UL << 24); + if (internalExtraFsPhysicalPage(edges, 0) != 1) return 8; + if (internalExtraFsPhysicalPage(edges, 22) != 23) return 9; + if (internalExtraFsPhysicalPage(edges, 23) != UINT32_MAX) return 10; + + const uint32_t four = (1UL << 0) | (1UL << 4) + | (1UL << 17) | (1UL << 24); + if (countInternalExtraFsBadPages(four) != 4) return 11; + uint32_t previous = 0; + for (uint32_t logical = 0; logical < 21; logical++) { + const uint32_t physical = internalExtraFsPhysicalPage(four, logical); + if (physical >= 25 || (four & (1UL << physical))) return 12; + if (logical && physical <= previous) return 13; + previous = physical; + } + if (internalExtraFsPhysicalPage(four, 21) != UINT32_MAX) return 14; + if (internalExtraFsPhysicalPage(1UL << 25, 0) != UINT32_MAX) return 15; + return 0; +} +""" + with tempfile.TemporaryDirectory() as temp_dir: + executable = Path(temp_dir) / "extrafs_page_map" + compiled = subprocess.run( + ["c++", "-std=c++11", f"-I{ROOT / 'src'}", "-x", "c++", + "-", "-o", str(executable)], + input=source, + text=True, + capture_output=True, + ) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + executed = subprocess.run([str(executable)], capture_output=True, + text=True) + self.assertEqual(executed.returncode, 0, executed.stderr) + + def test_destructive_scan_only_runs_during_early_boot(self): + store = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + device = (ROOT / "examples/companion_radio/ResilientInternalExtraFS.cpp").read_text() + self.assertIn("reinitializeInternalExtraFS(true)", store) + self.assertIn("scan_physical_pages && !extra->scanAndRetireBadPages()", store) + self.assertIn("extra->requestBootScan()", store) + self.assertIn("setBootScanRequest(marker)", device) + self.assertIn("BOOT_SCAN_ALL", device) + self.assertIn("uint32_t bad = _bad_pages;", device) + self.assertNotIn("_bad_pages | (_pending_pages & PAGE_MASK)", device) + self.assertIn("return _boot_scan_forced ||", ( + ROOT / "examples/companion_radio/ResilientInternalExtraFS.h" + ).read_text()) + self.assertIn("extra->acknowledgeRecoveredBootHint()", store) + self.assertIn("extra->pageMapNeedsSave()", store) + scan_command = mesh.split("void MyMesh::scanInternalExtraFS(Stream& output)", 1)[1] + scan_command = scan_command.split("#if defined(MESHCORE_EXTRAFS_HIL)", 1)[0] + self.assertIn("_store->requestInternalExtraFSBootScan()", scan_command) + self.assertNotIn("scanAndRetireBadPages", scan_command) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_internal_flash_fix.py b/test/test_nrf52_internal_flash_fix.py new file mode 100644 index 00000000..b2d30910 --- /dev/null +++ b/test/test_nrf52_internal_flash_fix.py @@ -0,0 +1,46 @@ +"""Guard the build-local nRF52 SoftDevice flash-completion repair.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/nrf52_internal_flash_fix.py" +spec = spec_from_file_location("nrf52_internal_flash_fix", SCRIPT) +module = module_from_spec(spec) +spec.loader.exec_module(module) + + +class Nrf52InternalFlashFixTest(unittest.TestCase): + def test_wait_cannot_be_optimized_into_immediate_success(self): + source = "before\n" + module.OLD_WAIT + " do_wait();\n }\nafter\n" + patched = module.patched_source(source) + self.assertIn("volatile uint8_t sd_en", patched) + self.assertIn('''__asm volatile ("" ::: "memory");''', patched) + self.assertIn("if (sd_en)", patched) + self.assertEqual(module.patched_source(patched), patched) + + def test_unrecognized_framework_fails_closed(self): + with self.assertRaises(RuntimeError): + module.patched_source("changed SDK layout") + + def test_all_nrf52_builds_load_fix(self): + ini = (ROOT / "platformio.ini").read_text(encoding="utf-8") + self.assertIn("pre:scripts/nrf52_internal_flash_fix.py", ini) + self.assertIn("*InternalFileSytem*src*flash*flash_nrf5x.c", SCRIPT.read_text()) + + def test_application_softdevice_checks_use_compiler_safe_wrapper(self): + helper = ROOT / "src/helpers/nrf52/SoftDeviceState.h" + source = helper.read_text(encoding="utf-8") + self.assertIn("volatile uint8_t state", source) + self.assertIn('''__asm volatile ("" ::: "memory");''', source) + for path in (ROOT / "src", ROOT / "variants", ROOT / "examples/companion_radio"): + for source_file in path.rglob("*.cpp"): + self.assertNotIn("sd_softdevice_is_enabled(", + source_file.read_text(encoding="utf-8"), + str(source_file)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_nrf52_uf2reset_cli.py b/test/test_nrf52_uf2reset_cli.py index f54ca1e3..7fc44d4c 100644 --- a/test/test_nrf52_uf2reset_cli.py +++ b/test/test_nrf52_uf2reset_cli.py @@ -18,7 +18,7 @@ chat = (ROOT / "examples/simple_secure_chat/main.cpp").read_text() assert "virtual bool rebootToUf2Bootloader() { return false; }" in main_board assert "bool rebootToUf2Bootloader() override;" in nrf_board_h assert "bool NRF52Board::rebootToUf2Bootloader()" in nrf_board_cpp -assert "sd_softdevice_is_enabled(&sd_enabled) != NRF_SUCCESS" in nrf_board_cpp +assert "mesh_nrf52::softdeviceIsEnabled(sd_enabled) != NRF_SUCCESS" in nrf_board_cpp clear = nrf_board_cpp.index("sd_power_gpregret_clr(0, 0xFF)") set_magic = nrf_board_cpp.index( "sd_power_gpregret_set(0, DFU_MAGIC_UF2_RESET)" diff --git a/variants/rak4631/RAK4631Board.cpp b/variants/rak4631/RAK4631Board.cpp index 3e5f6777..4b177422 100644 --- a/variants/rak4631/RAK4631Board.cpp +++ b/variants/rak4631/RAK4631Board.cpp @@ -5,6 +5,7 @@ #include "nrf_soc.h" #include "RAK4631Board.h" +#include "../../src/helpers/nrf52/SoftDeviceState.h" #ifdef ETHERNET_ENABLED // Drive WB_IO2 HIGH as early as possible using direct register access. @@ -47,7 +48,7 @@ void RAK4631Board::begin() { // merely skipping NRF52BoardDCDC::begin() would inherit any prior state. NRF52Board::begin(); uint8_t sd_enabled = 0; - sd_softdevice_is_enabled(&sd_enabled); + mesh_nrf52::softdeviceIsEnabled(sd_enabled); if (sd_enabled) { sd_power_dcdc_mode_set(NRF_POWER_DCDC_DISABLE); } else {