Fix nRF52 contact persistence and ExtraFS recovery

This commit is contained in:
mikecarper committed 2026-09-02 11:41:38 -07:00
1 parent 9e874917b4
commit 610a88e8d5
23 files changed
+1591 -113

No files matched your search

+16 -1
View File
@@ -26,16 +26,30 @@ enum class UIEventType {
ack
};
class UIShutdownGuard {
public:
virtual ~UIShutdownGuard() = default;
virtual bool prepareForUiShutdown() = 0;
};
class AbstractUITask {
protected:
mesh::MainBoard* _board;
MultiSerialInterface* _interfaceManager;
UIShutdownGuard* _shutdownGuard;
bool _connected;
AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager) : _board(board), _interfaceManager(interfaceManager) {
AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager)
: _board(board), _interfaceManager(interfaceManager),
_shutdownGuard(nullptr) {
_connected = false;
}
bool prepareForShutdown() {
return _shutdownGuard == nullptr
|| _shutdownGuard->prepareForUiShutdown();
}
bool isDisplayAutoOffDue(unsigned long configured_deadline,
unsigned long configured_timeout_millis) const {
unsigned long deadline = configured_deadline;
@@ -58,6 +72,7 @@ protected:
}
public:
void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; }
void setHasConnection(bool connected) { _connected = connected; }
bool hasConnection() const { return _connected; }
bool hasBluetoothConnection() const { return _interfaceManager->isBluetoothConnected(); }
+332 -42
View File
@@ -4,6 +4,9 @@
#if defined(NRF52_PLATFORM)
#include <helpers/AtomicFileWriter.h>
#if defined(EXTRAFS) && !defined(QSPIFLASH)
#include <helpers/nrf52/InternalSecondaryFsRepair.h>
#endif
#endif
// Linked presence of this symbol is the authoritative signal that this firmware actually mounts the
@@ -11,6 +14,7 @@
// reserved range even though nrf52_base defines EXTRAFS globally.
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
extern "C" __attribute__((used)) const uint8_t g_meshcore_internal_extrafs = 1u;
extern "C" uint32_t __flash_arduino_end[];
#endif
#if defined(EXTRAFS) || defined(QSPIFLASH)
@@ -19,7 +23,8 @@ extern "C" __attribute__((used)) const uint8_t g_meshcore_internal_extrafs = 1u;
#define MAX_BLOBRECS 20
#endif
DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr), _clock(&clock),
DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr),
_configuredFsExtra(nullptr), _clock(&clock),
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
identity_store(fs, "")
#elif defined(RP2040_PLATFORM)
@@ -31,7 +36,8 @@ DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra
}
#if defined(EXTRAFS) || defined(QSPIFLASH)
DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(&fsExtra), _clock(&clock),
DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(&fsExtra),
_configuredFsExtra(&fsExtra), _clock(&clock),
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
identity_store(fs, "")
#elif defined(RP2040_PLATFORM)
@@ -75,9 +81,10 @@ void DataStore::begin() {
primary_ready = recoverPrimaryFilesystem(_fs);
}
if (_fsExtra != nullptr && !validateLfsFilesystem(_fsExtra)) {
// Do not erase removable/external storage automatically. Keep it intact
// for recovery and boot from the known-good internal filesystem.
MESH_DEBUG_PRINTLN("DataStore: secondary LittleFS metadata is corrupt; using internal storage");
// A traversal-failed filesystem may still be recoverable offline. Keep
// both removable QSPI and managed on-chip ExtraFS intact until an explicit
// local repair/factory-reset operation authorizes erasing it.
MESH_DEBUG_PRINTLN("DataStore: secondary LittleFS metadata is corrupt; preserving it and using primary storage");
_fsExtra = nullptr;
}
if (primary_ready) cleanupAtomicTempFiles(_fs);
@@ -85,7 +92,9 @@ void DataStore::begin() {
resetContactPageState();
#endif
#if defined(EXTRAFS) || defined(QSPIFLASH)
if (_fsExtra != nullptr) migrateToSecondaryFS();
if (_fsExtra != nullptr && !migrateToSecondaryFS()) {
MESH_DEBUG_PRINTLN("DataStore: one or more secondary filesystem migrations remain pending");
}
#endif
checkAdvBlobFile();
#else
@@ -163,7 +172,8 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) {
static const char* fixed_temp_paths[] = {
"/_main.id.tmp", "/new_prefs.tmp", "/channels2.tmp",
"/contacts3.tmp", "/contacts4.mig.tmp", "/adv_blobs.tmp"};
"/contacts3.tmp", "/contacts4.mig.tmp", "/adv_blobs.tmp",
"/.extrafs.mig.tmp"};
for (size_t i = 0; i < sizeof(fixed_temp_paths) / sizeof(fixed_temp_paths[0]); i++) {
if (fs->exists(fixed_temp_paths[i])) fs->remove(fixed_temp_paths[i]);
}
@@ -181,6 +191,62 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) {
if (fs->exists(path)) fs->remove(path);
}
}
#if defined(EXTRAFS) && !defined(QSPIFLASH)
bool DataStore::reinitializeInternalExtraFS() {
if (_configuredFsExtra == nullptr) return false;
CustomLFS* extra = static_cast<CustomLFS*>(_configuredFsExtra);
if (!mesh::storage::isExpectedInternalExtraFsGeometry(
extra->getFlashAddr(), extra->getFlashSize(),
extra->getBlockSize())
|| !mesh::storage::isInternalExtraFsReservedByApplication(
(uint32_t)(uintptr_t)__flash_arduino_end)) {
_fsExtra = nullptr;
MESH_DEBUG_PRINTLN("DataStore: refusing internal ExtraFS repair with unexpected geometry");
return false;
}
const mesh::storage::InternalSecondaryFsRepairResult result =
mesh::storage::repairInternalSecondaryFilesystem(
[extra]() -> bool {
// Avoid CustomLFS::formatRegion(): it probes corrupt metadata with
// open("/") before deciding whether to unmount. end() is safe for
// both mounted and unmounted Adafruit LittleFS instances.
extra->end();
return extra->format();
},
[extra]() -> bool {
// Call the base mount explicitly. CustomLFS::begin() would erase
// the complete region again if this mount failed.
return extra->Adafruit_LittleFS::begin();
},
[this]() -> bool {
return validateLfsFilesystem(_configuredFsExtra);
});
if (result != mesh::storage::InternalSecondaryFsRepairResult::Repaired) {
_fsExtra = nullptr;
switch (result) {
case mesh::storage::InternalSecondaryFsRepairResult::FormatFailed:
MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair format failed");
break;
case mesh::storage::InternalSecondaryFsRepairResult::MountFailed:
MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair mount failed");
break;
case mesh::storage::InternalSecondaryFsRepairResult::ValidationFailed:
MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repair validation failed");
break;
default:
break;
}
return false;
}
_fsExtra = _configuredFsExtra;
MESH_DEBUG_PRINTLN("DataStore: internal ExtraFS repaired and reactivated");
return true;
}
#endif
#endif
uint32_t DataStore::getStorageUsedKb() const {
@@ -252,11 +318,17 @@ bool DataStore::formatFileSystem() {
#if defined(NRF52_PLATFORM)
resetContactPageState();
#endif
if (_fsExtra == nullptr) {
return _fs->format();
} else {
return _fs->format() && _fsExtra->format();
}
const bool primary_success = _fs->format();
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
// Factory reset/rebuild is already an explicit destructive operation. Use
// the configured pointer so it also clears and reactivates an ExtraFS which
// normal boot deliberately quarantined after failed traversal validation.
const bool secondary_success = _configuredFsExtra == nullptr
|| reinitializeInternalExtraFS();
#else
const bool secondary_success = _fsExtra == nullptr || _fsExtra->format();
#endif
return primary_success && secondary_success;
#elif defined(RP2040_PLATFORM)
return LittleFS.format();
#elif defined(ESP32)
@@ -268,6 +340,35 @@ bool DataStore::formatFileSystem() {
#endif
}
bool DataStore::repairInternalExtraFS() {
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
// A healthy active ExtraFS must never be erased. Re-running the explicit
// command is still useful, though: it retries any verified migration which
// failed after a previously repaired filesystem was activated.
if (_fsExtra != nullptr) {
CustomLFS* extra = static_cast<CustomLFS*>(_fsExtra);
if (_fsExtra != _configuredFsExtra
|| !mesh::storage::isExpectedInternalExtraFsGeometry(
extra->getFlashAddr(), extra->getFlashSize(), extra->getBlockSize())
|| !mesh::storage::isInternalExtraFsReservedByApplication(
(uint32_t)(uintptr_t)__flash_arduino_end)) {
MESH_DEBUG_PRINTLN("DataStore: refusing internal ExtraFS migration with unexpected geometry");
return false;
}
cleanupAtomicTempFiles(_fsExtra);
return migrateToSecondaryFS();
}
if (!reinitializeInternalExtraFS()) return false;
cleanupAtomicTempFiles(_fsExtra);
// The fallback primary remains intact until each destination file has been
// copied, read back byte-for-byte, and committed by migrateToSecondaryFS().
return migrateToSecondaryFS();
#else
return false;
#endif
}
bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) {
return identity_store.load("_main", identity);
}
@@ -528,6 +629,9 @@ static bool deserializeContactRecord(
#if defined(NRF52_PLATFORM)
static const char* CONTACT_MIGRATION_MARKER = "/contacts4.mig";
static const char* SECONDARY_MIGRATION_JOURNAL = "/.extrafs.mig";
static const uint8_t SECONDARY_MIGRATION_PENDING = 1;
static const uint8_t SECONDARY_MIGRATION_COMMITTED = 2;
static void makeContactPagePath(uint8_t page, char path[24]) {
snprintf(path, 24, "/contacts4_%02u", (unsigned)page);
@@ -1025,19 +1129,19 @@ void DataStore::loadChannels(DataStoreHost* host) {
}
}
void DataStore::saveChannels(DataStoreHost* host) {
bool DataStore::saveChannels(DataStoreHost* host) {
#if defined(NRF52_PLATFORM)
mesh::AtomicFileWriter file(_getContactsChannelsFS(), "/channels2");
#else
File file = openWrite(_getContactsChannelsFS(), "/channels2");
#endif
bool success = (bool)file;
if (file) {
uint8_t channel_idx = 0;
ChannelDetails ch;
uint8_t unused[4];
memset(unused, 0, 4);
bool success = true;
while (success && host->getChannelForSave(channel_idx, ch)) {
success = (file.write(unused, 4) == 4);
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
@@ -1047,11 +1151,13 @@ void DataStore::saveChannels(DataStoreHost* host) {
channel_idx++;
}
#if defined(NRF52_PLATFORM)
if (!file.commit(success)) MESH_DEBUG_PRINTLN("DataStore: atomic channels write failed");
success = file.commit(success);
if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic channels write failed");
#else
file.close();
#endif
}
return success;
}
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -1103,11 +1209,13 @@ void DataStore::checkAdvBlobFile() {
#endif
}
void DataStore::migrateToSecondaryFS() {
if (_fsExtra == nullptr) return;
bool DataStore::migrateToSecondaryFS() {
if (_fsExtra == nullptr) return false;
// Implemented below through verified copy transactions. Source files are
// removed only after the destination has been read back successfully.
// Implemented below through verified copy transactions. On nRF52, all
// primary contact/channel files are copied before an atomic journal commit;
// no source is removed before that commit makes the complete secondary
// snapshot authoritative across a reset.
#if defined(NRF52_PLATFORM)
// /adv_blobs is a reconstructable cache retired by checkAdvBlobFile(); do
// not spend time and temporary space atomically copying it first.
@@ -1122,13 +1230,15 @@ void DataStore::migrateToSecondaryFS() {
const char* path) -> bool {
File left = openRead(left_fs, path);
File right = openRead(right_fs, path);
if (!left || !right || left.size() != right.size()) {
const uint32_t expected_size = left ? left.size() : 0;
if (!left || !right || expected_size != right.size()) {
if (left) left.close();
if (right) right.close();
return false;
}
uint8_t left_buf[64], right_buf[64];
bool equal = true;
uint32_t compared = 0;
while (equal) {
int left_count = left.read(left_buf, sizeof(left_buf));
int right_count = right.read(right_buf, sizeof(right_buf));
@@ -1140,19 +1250,21 @@ void DataStore::migrateToSecondaryFS() {
break;
} else if (memcmp(left_buf, right_buf, left_count) != 0) {
equal = false;
} else {
compared += (uint32_t)left_count;
}
}
left.close();
right.close();
return equal;
return equal && compared == expected_size;
};
auto migrate = [this, &filesEqual](FILESYSTEM* source_fs, FILESYSTEM* dest_fs,
const char* path) -> bool {
auto copy = [this, &filesEqual](FILESYSTEM* source_fs, FILESYSTEM* dest_fs,
const char* path) -> bool {
if (!source_fs->exists(path)) return true;
if (dest_fs->exists(path)) {
if (filesEqual(source_fs, dest_fs, path)) {
return source_fs->remove(path);
return true;
}
MESH_DEBUG_PRINTLN("DataStore: migration conflict for %s; preserving both copies", path);
return false;
@@ -1212,30 +1324,208 @@ void DataStore::migrateToSecondaryFS() {
MESH_DEBUG_PRINTLN("DataStore: verified migration failed for %s", path);
return false;
}
return source_fs->remove(path);
return true;
};
auto move = [&copy](FILESYSTEM* source_fs, FILESYSTEM* dest_fs,
const char* path) -> bool {
if (!source_fs->exists(path)) return true;
return copy(source_fs, dest_fs, path) && source_fs->remove(path);
};
auto migratePrimarySources = [this, &copy, &filesEqual]() -> bool {
bool success = true;
for (size_t i = 0; i < sizeof(to_primary) / sizeof(to_primary[0]); i++) {
const char* path = to_primary[i];
if (!_fsExtra->exists(path)) continue;
if (_fs->exists(path)) {
if (!filesEqual(_fsExtra, _fs, path)) {
// Identity/preferences are canonical on primary. A differing legacy
// secondary copy is useful forensic data, but it must not prevent
// contact/channel migration or make secondary storage disappear.
MESH_DEBUG_PRINTLN("DataStore: preserving conflicting legacy secondary %s; primary remains authoritative", path);
continue;
}
} else if (!copy(_fsExtra, _fs, path)) {
// Without any primary copy MyMesh would generate a replacement
// identity or persist default preferences, so this is the only
// primary-source condition which must block the storage handoff.
success = false;
continue;
}
// At this point primary has an exact verified copy. Failure to retire a
// duplicate is harmless and can be retried on a later boot.
if (!_fsExtra->remove(path)) {
MESH_DEBUG_PRINTLN("DataStore: could not retire verified legacy secondary %s", path);
}
}
return success;
};
for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) {
migrate(_fs, _fsExtra, to_secondary[i]);
}
// Also move the bounded nRF v4 page/bucket files. This matters after a boot
// where external QSPI was unavailable and the store deliberately fell back
// to internal flash.
#if defined(NRF52_PLATFORM)
for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) {
char path[24];
makeContactPagePath(page, path);
migrate(_fs, _fsExtra, path);
// Identity and preferences are always read from the primary filesystem.
// Recover legacy/test-layout copies before the contact/channel transaction
// can take an early fallback return; otherwise MyMesh may generate a new
// identity or persist default preferences while the real copy is stranded
// on the now-disabled secondary filesystem.
if (!migratePrimarySources()) {
MESH_DEBUG_PRINTLN("DataStore: primary identity/preferences migration failed; using primary storage");
_fsExtra = nullptr;
return false;
}
for (uint8_t bucket = 0; bucket < 10; bucket++) {
char path[20];
snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket);
migrate(_fs, _fsExtra, path);
enum class MigrationJournalState : uint8_t {
None,
Pending,
Committed,
Invalid,
};
static const uint8_t journal_magic[] = {'M', 'C', 'X', 'F', 1};
auto readJournal = [this]() -> MigrationJournalState {
if (!_fsExtra->exists(SECONDARY_MIGRATION_JOURNAL)) {
return MigrationJournalState::None;
}
File journal = openRead(_fsExtra, SECONDARY_MIGRATION_JOURNAL);
uint8_t payload[sizeof(journal_magic) + 1];
const bool valid_size = journal && journal.size() == sizeof(payload);
const int count = valid_size ? journal.read(payload, sizeof(payload)) : 0;
if (journal) journal.close();
if (count != (int)sizeof(payload)
|| memcmp(payload, journal_magic, sizeof(journal_magic)) != 0) {
return MigrationJournalState::Invalid;
}
if (payload[sizeof(journal_magic)] == SECONDARY_MIGRATION_PENDING) {
return MigrationJournalState::Pending;
}
if (payload[sizeof(journal_magic)] == SECONDARY_MIGRATION_COMMITTED) {
return MigrationJournalState::Committed;
}
return MigrationJournalState::Invalid;
};
auto writeJournal = [this](uint8_t state) -> bool {
uint8_t payload[sizeof(journal_magic) + 1];
memcpy(payload, journal_magic, sizeof(journal_magic));
payload[sizeof(journal_magic)] = state;
mesh::AtomicFileWriter journal(_fsExtra, SECONDARY_MIGRATION_JOURNAL);
return journal
&& journal.write(payload, sizeof(payload)) == sizeof(payload)
&& journal.commit();
};
auto hasSecondarySources = [this]() -> bool {
for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) {
if (_fs->exists(to_secondary[i])) return true;
}
for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) {
char path[24];
makeContactPagePath(page, path);
if (_fs->exists(path)) return true;
}
for (uint8_t bucket = 0; bucket < 10; bucket++) {
char path[20];
snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket);
if (_fs->exists(path)) return true;
}
return false;
};
auto copySecondarySources = [this, &copy]() -> bool {
bool success = true;
for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) {
if (!copy(_fs, _fsExtra, to_secondary[i])) success = false;
}
for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) {
char path[24];
makeContactPagePath(page, path);
if (!copy(_fs, _fsExtra, path)) success = false;
}
for (uint8_t bucket = 0; bucket < 10; bucket++) {
char path[20];
snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket);
if (!copy(_fs, _fsExtra, path)) success = false;
}
return success;
};
auto retireSecondarySources = [this]() -> bool {
bool success = true;
auto retire = [this, &success](const char* path) {
if (!_fs->exists(path)) return;
// The committed journal is the authority switch: every destination was
// already verified before it was written. Do not compare again here,
// because the secondary may legitimately advance while cleanup retries.
if (!_fs->remove(path)) success = false;
};
for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) {
retire(to_secondary[i]);
}
for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) {
char path[24];
makeContactPagePath(page, path);
retire(path);
}
for (uint8_t bucket = 0; bucket < 10; bucket++) {
char path[20];
snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket);
retire(path);
}
return success;
};
MigrationJournalState journal_state = readJournal();
if (journal_state == MigrationJournalState::Invalid) {
MESH_DEBUG_PRINTLN("DataStore: invalid ExtraFS migration journal; using primary storage");
_fsExtra = nullptr;
return false;
}
if (journal_state != MigrationJournalState::Committed
&& (journal_state != MigrationJournalState::None || hasSecondarySources())) {
if (journal_state == MigrationJournalState::None
&& !writeJournal(SECONDARY_MIGRATION_PENDING)) {
MESH_DEBUG_PRINTLN("DataStore: could not start ExtraFS migration transaction");
_fsExtra = nullptr;
return false;
}
if (!copySecondarySources()
|| !writeJournal(SECONDARY_MIGRATION_COMMITTED)) {
// Pending means no source has been retired, so primary remains a complete
// fallback even if the destination contains harmless partial copies.
MESH_DEBUG_PRINTLN("DataStore: ExtraFS migration copy incomplete; using primary storage");
_fsExtra = nullptr;
return false;
}
journal_state = MigrationJournalState::Committed;
}
if (journal_state == MigrationJournalState::Committed) {
if (!retireSecondarySources()) {
// The committed secondary contains the full verified snapshot. Keep it
// active and keep the journal so cleanup can resume without copying a
// stale primary source over newer secondary data.
MESH_DEBUG_PRINTLN("DataStore: ExtraFS migration committed; source cleanup remains pending");
return false;
}
if (_fsExtra->exists(SECONDARY_MIGRATION_JOURNAL)
&& !_fsExtra->remove(SECONDARY_MIGRATION_JOURNAL)) {
MESH_DEBUG_PRINTLN("DataStore: ExtraFS migration journal cleanup failed");
return false;
}
}
#else
bool success = true;
for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) {
if (!move(_fs, _fsExtra, to_secondary[i])) success = false;
}
if (!success) return false;
return migratePrimarySources();
#endif
for (size_t i = 0; i < sizeof(to_primary) / sizeof(to_primary[0]); i++) {
migrate(_fsExtra, _fs, to_primary[i]);
}
return true;
}
#if defined(NRF52_PLATFORM)
+10 -2
View File
@@ -18,6 +18,10 @@ public:
class DataStore {
FILESYSTEM* _fs;
FILESYSTEM* _fsExtra;
// Keep the configured secondary even when normal I/O falls back to the
// primary filesystem. An explicit local repair/factory reset must still be
// able to address the inactive on-chip ExtraFS.
FILESYSTEM* _configuredFsExtra;
mesh::RTCClock* _clock;
IdentityStore identity_store;
@@ -35,6 +39,9 @@ class DataStore {
bool (*filter)(const ContactInfo& c));
bool truncateLegacyContacts(uint16_t remaining_contacts);
void resetContactPageState();
#if defined(EXTRAFS) && !defined(QSPIFLASH)
bool reinitializeInternalExtraFS();
#endif
#endif
void loadPrefsInt(const char *filename, CompanionNodePrefs& prefs, double& node_lat, double& node_lon);
@@ -47,6 +54,7 @@ public:
DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock);
void begin();
bool formatFileSystem();
bool repairInternalExtraFS();
FILESYSTEM* getPrimaryFS() const { return _fs; }
FILESYSTEM* getSecondaryFS() const { return _fsExtra; }
void disableSecondaryFS() { _fsExtra = nullptr; }
@@ -62,8 +70,8 @@ public:
bool flushContactWrites(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
bool hasPendingContactWrites() const;
void loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
void migrateToSecondaryFS();
bool saveChannels(DataStoreHost* host);
bool migrateToSecondaryFS();
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);
bool deleteBlobByKey(const uint8_t key[], int key_len);
+223 -53
View File
@@ -5,6 +5,7 @@
#include <helpers/CompanionHardwareCommandCompat.h>
#include <helpers/CompanionStatusResponse.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/LazyPersistence.h>
#include <helpers/StorageLayout.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
@@ -1508,6 +1509,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
sign_data_reply_route = NULL;
sign_data_deadline = 0;
dirty_contacts_expiry = 0;
dirty_contacts_failures = 0;
memset(advert_paths, 0, sizeof(advert_paths));
memset(send_scope.key, 0, sizeof(send_scope.key));
send_unscoped = false;
@@ -1574,6 +1576,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_webconfig = nullptr;
_wc_mqtt_dirty = false;
#endif
if (_ui != nullptr) _ui->setShutdownGuard(this);
}
void MyMesh::begin(bool has_display, bool radio_available) {
@@ -1724,7 +1727,8 @@ void MyMesh::begin(bool has_display, bool radio_available) {
resetContacts();
_store->loadContacts(this);
if (_store->hasPendingContactWrites()) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
updateGpsTelemetryPolicy();
bootstrapRTCfromContacts();
@@ -2876,7 +2880,11 @@ bool MyMesh::isWebConfigWiFiRecoveryActive() const {
}
void MyMesh::rebootNow() {
board.reboot();
if (flushContactsBeforeReboot()) {
board.reboot();
} else {
MESH_DEBUG_PRINTLN("Reboot refused: pending contacts could not be saved");
}
}
void MyMesh::onConfigBatchStart() {
@@ -4008,14 +4016,9 @@ void MyMesh::handleCmdFrame(size_t len) {
writeOKFrame();
}
} else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) {
// Non-nRF stores use the legacy monolithic file and therefore do not
// report dirty pages. The lazy-write timer is still proof that RAM holds
// newer contact data which must be persisted before rebooting.
if (dirty_contacts_expiry || _store->hasPendingContactWrites()) {
if (!_store->flushContactWrites(this, save_filter)) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
return;
}
if (!flushContactsBeforeReboot()) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
return;
}
board.reboot();
} else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) {
@@ -4042,6 +4045,10 @@ void MyMesh::handleCmdFrame(size_t len) {
#if ENABLE_PRIVATE_KEY_IMPORT
if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key
} else if (!flushContactsBeforeReboot()) {
// loadContacts() resets the dirty-page map, so never replace the
// identity and reload while newer contact state exists only in RAM.
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
mesh::LocalIdentity identity;
identity.readFrom(&cmd_frame[1], 64);
@@ -4051,7 +4058,14 @@ void MyMesh::handleCmdFrame(size_t len) {
// re-load contacts, to invalidate ecdh shared_secrets
stopContactsIterator();
resetContacts();
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
_store->loadContacts(this);
if (_store->hasPendingContactWrites()) {
mesh::scheduleLazyPersistenceMutation(
dirty_contacts_expiry, dirty_contacts_failures,
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
updateGpsTelemetryPolicy();
} else {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
@@ -4819,22 +4833,111 @@ static bool save_filter(const ContactInfo& c) {
void MyMesh::saveContacts() {
const bool success = _store->saveContacts(this, save_filter);
dirty_contacts_expiry = (!success || _store->hasPendingContactWrites())
? futureMillis(1000) : 0;
if (!success) {
scheduleContactWriteRetry();
} else if (_store->hasPendingContactWrites()) {
dirty_contacts_failures = 0;
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(CONTACT_PAGE_WRITE_GAP));
} else {
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
}
}
void MyMesh::scheduleContactWriteRetry() {
const uint32_t retry_delay = mesh::recordLazyPersistenceSaveFailure(
dirty_contacts_failures, LAZY_CONTACTS_WRITE_DELAY,
mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
mesh::completeLazyPersistenceSave(
dirty_contacts_expiry, false, futureMillis(retry_delay));
MESH_DEBUG_PRINTLN("Contact persistence failed; retry in %lu ms",
(unsigned long)retry_delay);
}
bool MyMesh::isContactWriteDue() const {
// Read the clock once. Two separate reads can straddle the exact deadline
// and make both equality and the library's strict-after test return false.
const uint32_t now = _ms->getMillis();
return dirty_contacts_expiry != 0
&& (int32_t)(now - dirty_contacts_expiry) >= 0;
}
bool MyMesh::flushContactsBeforeReboot() {
// Non-nRF stores use the legacy monolithic file and therefore do not report
// dirty pages. The lazy-write deadline still proves that RAM may be newer.
if (!dirty_contacts_expiry && !_store->hasPendingContactWrites()) return true;
if (!_store->flushContactWrites(this, save_filter)) {
scheduleContactWriteRetry();
return false;
}
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
return true;
}
bool MyMesh::prepareForOtaReboot() {
// Once a write has failed, honor its bounded retry deadline instead of
// letting the armed OTA apply hammer the same filesystem every loop.
if (dirty_contacts_failures != 0 && dirty_contacts_expiry != 0
&& !isContactWriteDue()) {
return false;
}
return flushContactsBeforeReboot();
}
bool MyMesh::prepareForUiShutdown() {
return flushContactsBeforeReboot();
}
void MyMesh::scheduleContactWrite(const ContactInfo& contact) {
if (_store->markContactDirty(contact)) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
mesh::scheduleLazyPersistenceMutation(
dirty_contacts_expiry, dirty_contacts_failures,
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
}
void MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact) {
if (_store->releaseContact(contact)) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
mesh::scheduleLazyPersistenceMutation(
dirty_contacts_expiry, dirty_contacts_failures,
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
}
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
void MyMesh::repairInternalExtraFS(Stream& output) {
// Always run the store-level operation. When the 100 KiB ExtraFS is already
// active this is a non-destructive retry of any incomplete migration, not a
// format. Do not overwrite it from RAM until every authoritative source file
// has been copied, verified, and retired from primary storage.
if (!_store->repairInternalExtraFS()) {
output.println(" Error: internal ExtraFS repair/migration failed; retry repair extrafs");
return;
}
const bool contacts_saved = _store->saveContacts(this, save_filter);
const bool channels_saved = saveChannels();
const bool capacity_ready = _store->getStorageTotalKb() == 100;
if (contacts_saved && channels_saved && capacity_ready
&& !_store->hasPendingContactWrites()) {
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
output.printf(" > internal ExtraFS repaired (%lu KiB); contacts and channels rebuilt\n",
(unsigned long)_store->getStorageTotalKb());
} else {
if (!contacts_saved || _store->hasPendingContactWrites()) {
scheduleContactWriteRetry();
} else {
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
}
output.println(" Error: ExtraFS is active, but data rebuild/verification failed; retry repair extrafs");
}
}
#endif
bool MyMesh::applyAndSaveFemRxGain(bool enabled) {
if (!board.canControlLoRaFemLna()) return false;
@@ -6347,6 +6450,10 @@ void MyMesh::handleTerminalCommand(char* command) {
} else if (strcmp(command, "reboot") == 0) {
terminalOutput().print(" OK - rebooting in 1 second\r\n");
_scheduled_reboot_at = futureMillis(1000);
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
} else if (strcmp(command, "repair extrafs") == 0) {
repairInternalExtraFS(terminalOutput());
#endif
} else if (strcmp(command, "ver") == 0) {
terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n",
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
@@ -6421,6 +6528,9 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" public <text>\r\n");
terminalOutput().print(" channels\r\n");
terminalOutput().print(" channel <name-or-slot> <text>\r\n");
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
terminalOutput().print(" repair extrafs (erases/rebuilds internal ExtraFS)\r\n");
#endif
#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI
#if COMPANION_FEATURE_TEMP_RADIO
terminalOutput().print(" tempradio [freq,bw,sf,cr,minutes]\r\n");
@@ -6631,14 +6741,33 @@ void MyMesh::checkCLIRescueCmd() {
if (handleCommand(cli_command, 0, reply_buf)) {
// command was handled, print reply output
output.print(" "); output.print(reply_buf); output.println();
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
} else if (strcmp(cli_command, "repair extrafs") == 0) {
repairInternalExtraFS(output);
#endif
} else if (strcmp(cli_command, "rebuild") == 0) {
bool success = _store->formatFileSystem();
if (success) {
_store->saveMainIdentity(self_id);
savePrefs();
saveContacts();
saveChannels();
output.println(" > erase and rebuild done");
const bool identity_saved = _store->saveMainIdentity(self_id);
const bool prefs_saved = savePrefs();
const bool contacts_saved = _store->saveContacts(this, save_filter);
const bool channels_saved = saveChannels();
const bool contacts_complete =
contacts_saved && !_store->hasPendingContactWrites();
if (contacts_complete) {
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
} else {
scheduleContactWriteRetry();
}
if (identity_saved && prefs_saved && contacts_complete
&& channels_saved) {
output.println(" > erase and rebuild done");
} else {
output.println(" Error: erase succeeded, but data rebuild failed; retry rebuild");
}
} else {
output.println(" Error: erase failed");
}
@@ -6717,24 +6846,33 @@ void MyMesh::checkCLIRescueCmd() {
return;
}
// log file content as hex
File file = _store->openRead(path);
if (is_fs2 == true) {
file = _store->openRead(_store->getSecondaryFS(), path);
}
if(file){
// A corrupt internal ExtraFS is deliberately quarantined until the
// explicit repair command runs. Never pass that null secondary pointer
// into the raw filesystem helpers while it is unavailable.
FILESYSTEM* selected_fs = is_fs2
? _store->getSecondaryFS()
: _store->getPrimaryFS();
if (selected_fs == nullptr) {
output.println("ExtraFS is unavailable; run repair extrafs first");
} else {
// log file content as hex
File file = _store->openRead(selected_fs, path);
if(file){
// get file content
int file_size = file.available();
uint8_t buffer[file_size];
file.read(buffer, file_size);
// Stream the dump in bounded chunks. An ExtraFS file can be much
// larger than the nRF52 task stack, so never allocate file.size()
// bytes on the stack here.
uint8_t buffer[64];
while (file.available() > 0) {
const int count = file.read(buffer, sizeof(buffer));
if (count <= 0) break;
mesh::Utils::printHex(output, buffer, count);
}
output.print("\n");
// print hex
mesh::Utils::printHex(output, buffer, file_size);
output.print("\n");
file.close();
file.close();
}
}
} else if (memcmp(cli_command, "rm ", 3) == 0) {
@@ -6754,24 +6892,32 @@ void MyMesh::checkCLIRescueCmd() {
}
// remove file
bool removed;
if (is_fs2) {
MESH_DEBUG_PRINTLN("Removing file from ExtraFS: %s", path);
removed = _store->removeFile(_store->getSecondaryFS(), path);
FILESYSTEM* selected_fs = is_fs2
? _store->getSecondaryFS()
: _store->getPrimaryFS();
if (selected_fs == nullptr) {
output.println("ExtraFS is unavailable; run repair extrafs first");
} else {
MESH_DEBUG_PRINTLN("Removing file from UserData: %s", path);
removed = _store->removeFile(path);
}
if(removed){
output.println("File removed");
} else {
output.println("Failed to remove file");
if (is_fs2) {
MESH_DEBUG_PRINTLN("Removing file from ExtraFS: %s", path);
} else {
MESH_DEBUG_PRINTLN("Removing file from UserData: %s", path);
}
if (_store->removeFile(selected_fs, path)) {
output.println("File removed");
} else {
output.println("Failed to remove file");
}
}
}
} else if (strcmp(cli_command, "reboot") == 0) {
board.reboot(); // doesn't return
if (flushContactsBeforeReboot()) {
board.reboot(); // doesn't return
} else {
output.println(" Error: pending contacts could not be saved; reboot cancelled");
}
} else {
output.println(" Error: unknown command");
}
@@ -6817,9 +6963,14 @@ void MyMesh::loop() {
if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection();
#endif
if (_scheduled_reboot_at != 0
&& millisHasNowPassed(_scheduled_reboot_at)) {
&& (_scheduled_reboot_at == _ms->getMillis()
|| millisHasNowPassed(_scheduled_reboot_at))) {
_scheduled_reboot_at = 0;
board.reboot();
if (flushContactsBeforeReboot()) {
board.reboot();
} else {
MESH_DEBUG_PRINTLN("Scheduled reboot cancelled: pending contacts could not be saved");
}
return;
}
#if COMPANION_FEATURE_TEMP_RADIO
@@ -6874,12 +7025,17 @@ void MyMesh::loop() {
servicePendingRadioParamApply();
// is there are pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
if (isContactWriteDue()) {
const bool success = _store->serviceContactWrites(this, save_filter);
if (!success || _store->hasPendingContactWrites()) {
dirty_contacts_expiry = futureMillis(success ? CONTACT_PAGE_WRITE_GAP : 1000);
if (!success) {
scheduleContactWriteRetry();
} else if (_store->hasPendingContactWrites()) {
dirty_contacts_failures = 0;
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(CONTACT_PAGE_WRITE_GAP));
} else {
dirty_contacts_expiry = 0;
mesh::resetLazyPersistenceAfterSuccess(
dirty_contacts_expiry, dirty_contacts_failures);
}
}
@@ -6908,16 +7064,30 @@ bool MyMesh::hasPendingWork() const {
if (_radio_available
&& (radio_driver.isWatchdogObserving()
|| radio_driver.isCalibratingNoiseFloor())) return true;
#if defined(NRF52_PLATFORM)
// nRF52 sleep is event-driven, so a future lazy-write deadline has no
// guaranteed interrupt to wake the loop when it becomes due. Keep polling
// through the initial write. After an actual storage failure, allow sleep
// during the bounded backoff; the next BLE/radio/GPIO event services an
// overdue retry without losing the dirty-page state.
const bool contact_write_needs_polling = dirty_contacts_expiry != 0
&& (dirty_contacts_failures == 0
|| isContactWriteDue());
#else
const bool contact_write_needs_polling = isContactWriteDue();
#endif
return (_serial != NULL && _serial->hasPendingIO())
|| (_iter_started && _serial != NULL && _serial->isConnected())
|| hasPendingOtaApply()
|| command_radio_apply_pending
|| _scheduled_reboot_at != 0
|| pending_serial_reply_route != NULL
|| binary_trace_pending
|| sign_data != NULL
|| hasQueuedWorkDue() || hasRetryWorkDue()
|| (saved_radio_apply_pending
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at)))
|| (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry))
|| contact_write_needs_polling
|| (emergency_client_repeat_packet != NULL
&& millisHasNowPassed(emergency_client_repeat_send_at))
#if COMPANION_FEATURE_TEMP_RADIO
+11 -2
View File
@@ -117,7 +117,7 @@ struct AdvertPath {
uint8_t path[MAX_PATH_SIZE];
};
class MyMesh : public BaseChatMesh, public DataStoreHost
class MyMesh : public BaseChatMesh, public DataStoreHost, public UIShutdownGuard
#ifdef ENABLE_USB_INTERFACE
, public ContactVisitor
#endif
@@ -414,10 +414,18 @@ private:
#endif
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
bool saveChannels() { return _store->saveChannels(this); }
void saveContacts();
void scheduleContactWriteRetry();
bool isContactWriteDue() const;
bool flushContactsBeforeReboot();
bool prepareForOtaReboot() override;
bool prepareForUiShutdown() override;
void scheduleContactWrite(const ContactInfo& contact);
void scheduleContactWriteAfterRelease(const ContactInfo& contact);
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
void repairInternalExtraFS(Stream& output);
#endif
DataStore* _store;
CompanionNodePrefs _prefs;
@@ -513,6 +521,7 @@ private:
BaseSerialInterface* sign_data_reply_route;
unsigned long sign_data_deadline;
unsigned long dirty_contacts_expiry;
uint8_t dirty_contacts_failures;
TransportKey send_scope;
+46 -1
View File
@@ -162,6 +162,8 @@ static bool isNetworkTerminalActive();
#else
#if defined(EXTRAFS)
#include <CustomLFS.h>
#include <helpers/nrf52/InternalSecondaryFsRepair.h>
extern "C" uint32_t __flash_arduino_end[];
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
DataStore store(InternalFS, ExtraFS, rtc_clock);
#else
@@ -2090,7 +2092,50 @@ void setup() {
}
#else
#if defined(EXTRAFS)
ExtraFS.begin();
const bool extra_fs_geometry_valid =
mesh::storage::isExpectedInternalExtraFsGeometry(
ExtraFS.getFlashAddr(), ExtraFS.getFlashSize(),
ExtraFS.getBlockSize())
&& mesh::storage::isInternalExtraFsReservedByApplication(
(uint32_t)(uintptr_t)__flash_arduino_end);
mesh::storage::InternalSecondaryFsBootResult extra_fs_boot =
mesh::storage::InternalSecondaryFsBootResult::InitializationFailed;
if (extra_fs_geometry_valid) {
extra_fs_boot = mesh::storage::prepareInternalSecondaryFilesystem(
[]() -> bool {
// Bypass CustomLFS::begin(), whose mount-failure path erases
// nonblank media before DataStore can quarantine it.
return ExtraFS.Adafruit_LittleFS::begin();
},
[]() -> bool {
const volatile uint32_t* word =
reinterpret_cast<const volatile uint32_t*>(
mesh::storage::INTERNAL_EXTRAFS_START);
for (uint32_t offset = 0;
offset < mesh::storage::INTERNAL_EXTRAFS_SIZE;
offset += sizeof(uint32_t), word++) {
if (*word != 0xFFFFFFFFUL) return false;
}
return true;
},
[]() -> bool {
ExtraFS.end();
return ExtraFS.format();
});
}
if (!extra_fs_geometry_valid
|| extra_fs_boot == mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank
|| extra_fs_boot == mesh::storage::InternalSecondaryFsBootResult::InitializationFailed) {
if (!extra_fs_geometry_valid) {
MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS geometry is not 100 KiB; refusing to mount or erase it");
} else if (extra_fs_boot
== mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank) {
MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS mount failed; preserving nonblank media and using primary storage");
} else {
MESH_DEBUG_PRINTLN("CustomLFS: blank internal ExtraFS initialization failed; using primary storage");
}
store.disableSecondaryFS();
}
#endif
#endif
store.begin();
@@ -1706,6 +1706,7 @@ void UITask::servicePairingState() {
hardware-agnostic pre-shutdown activity should be done here
*/
void UITask::shutdown(bool restart){
if (!prepareForShutdown()) return;
#ifdef PIN_BUZZER
/* note: we have a choice here -
@@ -440,6 +440,7 @@ void UITask::userLedHandler() {
hardware-agnostic pre-shutdown activity should be done here
*/
void UITask::shutdown(bool restart){
if (!prepareForShutdown()) return;
#ifdef PIN_BUZZER
/* note: we have a choice here -
@@ -618,6 +618,7 @@ void UITask::servicePairingState() {
hardware-agnostic pre-shutdown activity should be done here
*/
void UITask::shutdown(bool restart){
if (!prepareForShutdown()) return;
#ifdef PIN_BUZZER
/* note: we have a choice here -
+1
View File
@@ -12566,6 +12566,7 @@ bool MyMesh::startNeighborDiscover(char* reply) {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (hasPendingOtaApply()) return true;
if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return true;
#if defined(WITH_BRIDGE)
const AbstractBridge* active_bridge = activeBridge();
+1
View File
@@ -2678,6 +2678,7 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (hasPendingOtaApply()) return true;
#if defined(WITH_BRIDGE)
if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep
#endif
+1
View File
@@ -1358,6 +1358,7 @@ uint32_t SensorMesh::limitSleepToMillisTimer(unsigned long timestamp,
}
bool SensorMesh::hasPendingWork() const {
if (hasPendingOtaApply()) return true;
if (_cli.hasActiveUserGpioTimer()) return true;
if (radio_driver.isWatchdogObserving()) return true;
if (radio_driver.isCalibratingNoiseFloor()) return true;
+19 -2
View File
@@ -277,6 +277,14 @@ void Mesh::loop() {
#endif
}
bool Mesh::hasPendingOtaApply() const {
#if defined(ENABLE_OTA) && !defined(OTA_SEEDER_ONLY)
return ota::ota_ctx().apply_pending;
#else
return false;
#endif
}
void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
if (_waiting_direct_retry_count != 0
&& millisHasNowPassed(_next_direct_retry_timeout)) {
@@ -335,8 +343,17 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
oc.apply_hard = futureMillis(15000);
} else if (millisHasNowPassed(oc.apply_at) &&
(_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) {
if (oc.bootloader_apply_pending) ota::ota_reboot_to_bootloader_update();
else ota::ota_reboot_to_apply();
if (!prepareForOtaReboot()) {
// A role-specific persistence flush failed or is in bounded
// backoff. Keep the verified apply armed without hammering storage
// on every pass through the event loop.
oc.apply_at = futureMillis(1000);
oc.apply_hard = futureMillis(15000);
} else if (oc.bootloader_apply_pending) {
ota::ota_reboot_to_bootloader_update();
} else {
ota::ota_reboot_to_apply();
}
}
}
}
+7
View File
@@ -190,6 +190,13 @@ class Mesh : public Dispatcher {
DispatcherAction forwardMultipartDirect(Packet* pkt);
protected:
// Roles with lazily persisted state can veto an OTA reset until that state
// is durable. The OTA apply remains armed and is retried later.
virtual bool prepareForOtaReboot() { return true; }
// Power-saving roles must stay runnable while the deferred OTA reboot grace
// period (or a persistence retry) is pending. On nRF52, board.sleep() has no
// timer wake and could otherwise strand an already-approved update.
bool hasPendingOtaApply() const;
DispatcherAction onRecvPacket(Packet* pkt) override;
void onTracePacketQueuedForSend(Packet* packet) override;
void onSendComplete(Packet* packet) override;
+5 -1
View File
@@ -1143,10 +1143,14 @@ void WebConfigServer::tick(uint32_t now) {
}
if (WebConfigBatch::rebootDue(_reboot_at, now)) {
// rebootNow() can return when its pre-reboot persistence flush fails.
// Consume this request first so a full filesystem cannot turn tick() into
// a tight loop of repeated flush attempts.
_reboot_at = 0;
mesh::usbLoggingPort().printf(
"WC: rebooting now (%s)\n",
_batch_reboot_armed ? "confirmed" : "fallback");
_cb->rebootNow(); // does not return
_cb->rebootNow();
}
if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) {
@@ -0,0 +1,64 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace storage {
static const uint32_t INTERNAL_EXTRAFS_START = 0xD4000UL;
static const uint32_t INTERNAL_EXTRAFS_SIZE = 0x19000UL;
static const uint32_t INTERNAL_EXTRAFS_BLOCK_SIZE = 128UL;
inline bool isExpectedInternalExtraFsGeometry(uint32_t start, uint32_t size,
uint32_t block_size) {
return start == INTERNAL_EXTRAFS_START
&& size == INTERNAL_EXTRAFS_SIZE
&& block_size == INTERNAL_EXTRAFS_BLOCK_SIZE;
}
inline bool isInternalExtraFsReservedByApplication(uint32_t application_end) {
return application_end <= INTERNAL_EXTRAFS_START;
}
enum class InternalSecondaryFsBootResult {
Mounted,
InitializedBlank,
PreservedNonBlank,
InitializationFailed,
};
// CustomLFS::begin() automatically formats after a failed mount. Use the base
// non-formatting mount through this policy instead: an erased factory region
// may be initialized, while nonblank media is preserved for explicit repair.
template <typename Mount, typename IsBlank, typename Format>
InternalSecondaryFsBootResult prepareInternalSecondaryFilesystem(
Mount mount, IsBlank is_blank, Format format) {
if (mount()) return InternalSecondaryFsBootResult::Mounted;
if (!is_blank()) return InternalSecondaryFsBootResult::PreservedNonBlank;
if (!format() || !mount()) {
return InternalSecondaryFsBootResult::InitializationFailed;
}
return InternalSecondaryFsBootResult::InitializedBlank;
}
enum class InternalSecondaryFsRepairResult {
Repaired,
FormatFailed,
MountFailed,
ValidationFailed,
};
// Repair is deliberately separate from the normal boot path. A filesystem
// which mounts but fails a full metadata traversal may still be forensically
// recoverable, so erasing it requires an explicit local operator action.
template <typename Format, typename Mount, typename Validate>
InternalSecondaryFsRepairResult repairInternalSecondaryFilesystem(
Format format, Mount mount, Validate validate) {
if (!format()) return InternalSecondaryFsRepairResult::FormatFailed;
if (!mount()) return InternalSecondaryFsRepairResult::MountFailed;
if (!validate()) return InternalSecondaryFsRepairResult::ValidationFailed;
return InternalSecondaryFsRepairResult::Repaired;
}
} // namespace storage
} // namespace mesh
+4 -6
View File
@@ -87,12 +87,10 @@ bool ota_apply_verify_slot(ApplyState& st) {
return st.slot_ok;
}
bool ota_apply_commit() {
bool ota_apply_arm() {
const esp_partition_t* p = esp_ota_get_next_update_partition(nullptr);
if (!p) return false;
if (esp_ota_set_boot_partition(p) != ESP_OK) return false;
esp_restart(); // does not return
return true;
return esp_ota_set_boot_partition(p) == ESP_OK;
}
// --- detools callback context -----------------------------------------------------------------
@@ -457,7 +455,7 @@ static bool parse_inplace_patch_dims(const uint8_t* payload, uint32_t payload_le
bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; }
bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; }
bool ota_apply_verify_slot(ApplyState&) { return false; }
bool ota_apply_commit() { return false; }
bool ota_apply_arm() { return false; }
bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "use ota_apply_mota_nrf52"); return false; }
void ota_reboot_to_apply() { // public: set the apply magic + reset (does not return)
@@ -1092,7 +1090,7 @@ bool ota_prepare_bootloader_update_nrf52(OtaStoreFlashNrf52& store,
bool ota_apply_slot_info(uint32_t*, uint32_t*) { return false; }
bool ota_apply_set_manifest(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st) { st = ApplyState(); return false; }
bool ota_apply_verify_slot(ApplyState&) { return false; }
bool ota_apply_commit() { return false; }
bool ota_apply_arm() { return false; }
bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; }
bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; }
bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, const uint8_t*,
+4 -1
View File
@@ -54,7 +54,10 @@ bool ota_apply_slot_info(uint32_t* addr, uint32_t* size); // the
bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len,
const SignerAllowlist& allow, ApplyState& st); // parse + verify signature
bool ota_apply_verify_slot(ApplyState& st); // hash the slot vs image_hash
bool ota_apply_commit(); // set-boot + reboot (no return)
// Select the verified A/B slot for the next boot without rebooting inline. The
// caller must arm OtaContext::apply_pending so Mesh can drain the command reply
// and run its role-specific persistence guard before ota_reboot_to_apply().
bool ota_apply_arm();
// Apply an ESP32 A/B `.mota` (CODEC_DETOOLS_SEQUENTIAL delta or CODEC_FULL image) and arm the inactive
// slot; the caller reboots after the confirmation reply. `msg` (>=80 bytes) receives a human-readable
+9 -2
View File
@@ -1160,8 +1160,15 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
sprintf(reply, "slot image_hash %s (size=%u)", ok ? "MATCH" : "MISMATCH", (unsigned)c.apply_st.image_size);
} else if (strncmp(sub, "commit", 6) == 0) {
if (!c.apply_st.slot_ok) { strcpy(reply, "ERR run 'ota dev apply verify' first (slot must match)"); return true; }
ota_apply_commit(); // set boot partition + reboot; no return
strcpy(reply, "ERR commit failed (no A/B slot?)");
if (c.apply_pending) { strcpy(reply, "ERR update is already armed; reboot is pending"); return true; }
if (!ota_apply_arm()) { strcpy(reply, "ERR commit failed (no A/B slot?)"); return true; }
// Use the same deferred path as ordinary installs: it lets this reply
// drain, then Mesh invokes prepareForOtaReboot() before the reset.
c.bootloader_apply_pending = false;
c.apply_at = 0;
c.apply_hard = 0;
c.apply_pending = true;
strcpy(reply, "OK slot armed; guarded reboot pending");
} else {
strcpy(reply, "ERR ota dev apply (slot|manifest|verify|commit)");
}
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Integration guards for Companion contact writes across event-only sleep."""
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
def function_body(source: str, signature: str) -> str:
start = source.index(signature)
opening_brace = source.index("{", start)
depth = 0
for position in range(opening_brace, len(source)):
if source[position] == "{":
depth += 1
elif source[position] == "}":
depth -= 1
if depth == 0:
return source[opening_brace + 1 : position]
raise AssertionError(f"unterminated function: {signature}")
class CompanionContactSleepContractTest(unittest.TestCase):
def test_initial_write_polls_but_failed_backoff_can_sleep(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
body = function_body(mesh, "bool MyMesh::hasPendingWork() const")
nrf_start = body.index("#if defined(NRF52_PLATFORM)")
other_start = body.index("#else", nrf_start)
conditional_end = body.index("#endif", other_start)
nrf = body[nrf_start:other_start]
other = body[other_start:conditional_end]
self.assertRegex(
nrf,
r"contact_write_needs_polling\s*=\s*"
r"dirty_contacts_expiry\s*!=\s*0\s*"
r"&&\s*\(dirty_contacts_failures\s*==\s*0\s*"
r"\|\|\s*isContactWriteDue\s*\(\s*\)\)",
)
self.assertRegex(
other,
r"contact_write_needs_polling\s*=\s*isContactWriteDue\s*\(\s*\)",
)
self.assertRegex(body, r"\|\|\s*contact_write_needs_polling")
def test_contact_write_is_serviced_only_after_its_deadline(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
body = function_body(mesh, "void MyMesh::loop()")
self.assertIn("if (isContactWriteDue())", body)
self.assertIn("_store->serviceContactWrites(this, save_filter)", body)
due = function_body(mesh, "bool MyMesh::isContactWriteDue() const")
self.assertEqual(due.count("_ms->getMillis()"), 1)
self.assertIn("(int32_t)(now - dirty_contacts_expiry) >= 0", due)
def test_repeated_mutations_do_not_postpone_first_write(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
for signature in (
"void MyMesh::scheduleContactWrite(const ContactInfo& contact)",
"void MyMesh::scheduleContactWriteAfterRelease("
"const ContactInfo& contact)",
):
body = function_body(mesh, signature)
self.assertIn("mesh::scheduleLazyPersistenceMutation(", body)
self.assertIn("dirty_contacts_failures", body)
self.assertIn("futureMillis(LAZY_CONTACTS_WRITE_DELAY)", body)
self.assertNotRegex(
body,
r"dirty_contacts_expiry\s*=\s*"
r"futureMillis\s*\(\s*LAZY_CONTACTS_WRITE_DELAY\s*\)",
)
def test_every_direct_contact_rearm_preserves_zero_sentinel(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
begin = function_body(mesh, "void MyMesh::begin(")
save = function_body(mesh, "void MyMesh::saveContacts()")
loop = function_body(mesh, "void MyMesh::loop()")
for body in (begin, save, loop):
self.assertIn("mesh::nonzeroLazyPersistenceDeadline(", body)
self.assertIn("futureMillis(CONTACT_PAGE_WRITE_GAP)", loop)
def test_failed_writes_back_off_until_a_success_resets_state(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
save = function_body(mesh, "void MyMesh::saveContacts()")
loop = function_body(mesh, "void MyMesh::loop()")
retry = function_body(mesh, "void MyMesh::scheduleContactWriteRetry()")
self.assertIn("uint8_t dirty_contacts_failures;", header)
self.assertIn("mesh::recordLazyPersistenceSaveFailure(", retry)
self.assertIn(
"mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS", retry
)
self.assertIn("LAZY_CONTACTS_WRITE_DELAY", retry)
self.assertIn("mesh::completeLazyPersistenceSave(", retry)
self.assertIn("retry in %lu ms", retry)
for body in (save, loop):
self.assertIn("scheduleContactWriteRetry();", body)
self.assertIn("mesh::resetLazyPersistenceAfterSuccess(", body)
def test_main_loop_gates_sleep_on_pending_work(self):
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
sleep_start = main.index("bool can_sleep =")
sleep_end = main.index("if (can_sleep)", sleep_start)
sleep_gate = main[sleep_start:sleep_end]
self.assertIn("!the_mesh.hasPendingWork()", sleep_gate)
nrf52 = (ROOT / "src/helpers/NRF52Board.cpp").read_text()
body = function_body(nrf52, "void NRF52Board::sleep(uint32_t secs)")
self.assertIn("event-driven sleep instead of timed sleep", body)
self.assertIn("sd_app_evt_wait();", body)
def test_all_runtime_reboots_flush_contacts_first(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
self.assertIn("bool flushContactsBeforeReboot();", header)
flush = function_body(mesh, "bool MyMesh::flushContactsBeforeReboot()")
self.assertIn("_store->flushContactWrites(this, save_filter)", flush)
self.assertIn("scheduleContactWriteRetry();", flush)
self.assertIn("mesh::resetLazyPersistenceAfterSuccess(", flush)
framed = function_body(mesh, "void MyMesh::handleCmdFrame(")
reboot_at = framed.index("cmd_frame[0] == CMD_REBOOT")
framed_reboot = framed[reboot_at : framed.index(
"cmd_frame[0] == CMD_GET_BATT_AND_STORAGE", reboot_at
)]
self.assertIn("flushContactsBeforeReboot()", framed_reboot)
rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()")
rescue_reboot = rescue[rescue.index('strcmp(cli_command, "reboot")') :]
self.assertIn("flushContactsBeforeReboot()", rescue_reboot)
callback = function_body(mesh, "void MyMesh::rebootNow()")
self.assertIn("flushContactsBeforeReboot()", callback)
loop = function_body(mesh, "void MyMesh::loop()")
scheduled = loop[: loop.index("#if COMPANION_FEATURE_TEMP_RADIO")]
self.assertIn("flushContactsBeforeReboot()", scheduled)
pending = function_body(mesh, "bool MyMesh::hasPendingWork() const")
self.assertIn("|| _scheduled_reboot_at != 0", pending)
def test_ota_apply_reboot_uses_companion_persistence_hook(self):
mesh_base_header = (ROOT / "src/Mesh.h").read_text()
mesh_base = (ROOT / "src/Mesh.cpp").read_text()
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
self.assertIn(
"virtual bool prepareForOtaReboot() { return true; }",
mesh_base_header,
)
maintenance = function_body(
mesh_base, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()"
)
guard_at = maintenance.index("prepareForOtaReboot()")
apply_at = maintenance.index("ota::ota_reboot_to_apply()")
self.assertLess(guard_at, apply_at)
self.assertIn("oc.apply_at = futureMillis(1000)", maintenance)
self.assertIn("bool prepareForOtaReboot() override;", header)
guard = function_body(mesh, "bool MyMesh::prepareForOtaReboot()")
self.assertIn("dirty_contacts_failures != 0", guard)
self.assertIn("!isContactWriteDue()", guard)
self.assertIn("flushContactsBeforeReboot()", guard)
def test_deferred_ota_apply_prevents_event_only_sleep(self):
mesh_base_header = (ROOT / "src/Mesh.h").read_text()
mesh_base = (ROOT / "src/Mesh.cpp").read_text()
self.assertIn("bool hasPendingOtaApply() const;", mesh_base_header)
pending = function_body(mesh_base, "bool Mesh::hasPendingOtaApply() const")
self.assertIn("defined(ENABLE_OTA)", pending)
self.assertIn("!defined(OTA_SEEDER_ONLY)", pending)
self.assertIn("ota::ota_ctx().apply_pending", pending)
role_sources = (
ROOT / "examples/companion_radio/MyMesh.cpp",
ROOT / "examples/simple_sensor/SensorMesh.cpp",
ROOT / "examples/simple_room_server/MyMesh.cpp",
ROOT / "examples/simple_repeater/MyMesh.cpp",
)
for role_source in role_sources:
role = role_source.read_text()
signature = (
"bool SensorMesh::hasPendingWork() const"
if role_source.name == "SensorMesh.cpp"
else "bool MyMesh::hasPendingWork() const"
)
has_pending_work = function_body(role, signature)
self.assertIn("hasPendingOtaApply()", has_pending_work, role_source)
def test_every_companion_ui_shutdown_uses_persistence_guard(self):
abstract = (
ROOT / "examples/companion_radio/AbstractUITask.h"
).read_text()
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
self.assertIn("public UIShutdownGuard", header)
self.assertIn("virtual bool prepareForUiShutdown() = 0;", abstract)
self.assertIn("_ui->setShutdownGuard(this)", mesh)
callback = function_body(mesh, "bool MyMesh::prepareForUiShutdown()")
self.assertIn("flushContactsBeforeReboot()", callback)
for ui_name in ("ui-orig", "ui-new", "ui-tiny"):
ui = (
ROOT / "examples/companion_radio" / ui_name / "UITask.cpp"
).read_text()
shutdown = function_body(ui, "void UITask::shutdown(bool restart)")
guard_at = shutdown.index("if (!prepareForShutdown()) return;")
reboot_at = shutdown.index("_board->reboot()")
poweroff_at = shutdown.index("_board->powerOff()")
self.assertLess(guard_at, reboot_at, ui_name)
self.assertLess(guard_at, poweroff_at, ui_name)
def test_identity_import_rearms_page_repairs(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
framed = function_body(mesh, "void MyMesh::handleCmdFrame(")
import_at = framed.index("cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY")
import_end = framed.index("cmd_frame[0] == CMD_SEND_RAW_DATA", import_at)
identity_import = framed[import_at:import_end]
flush_at = identity_import.index("flushContactsBeforeReboot()")
save_identity_at = identity_import.index("_store->saveMainIdentity(")
load_at = identity_import.index("_store->loadContacts(this)")
reset_at = identity_import.index(
"mesh::resetLazyPersistenceAfterSuccess(", save_identity_at
)
rearm_at = identity_import.index(
"mesh::scheduleLazyPersistenceMutation(", load_at
)
self.assertLess(flush_at, save_identity_at)
self.assertLess(reset_at, load_at)
self.assertLess(load_at, rearm_at)
self.assertIn("_store->hasPendingContactWrites()", identity_import)
def test_webconfig_reboot_request_is_one_shot_if_flush_fails(self):
webconfig = (
ROOT / "src/helpers/esp32/WebConfigServer.cpp"
).read_text()
tick = function_body(webconfig, "void WebConfigServer::tick(")
due_at = tick.index("WebConfigBatch::rebootDue(")
callback_at = tick.index("_cb->rebootNow()", due_at)
clear_at = tick.index("_reboot_at = 0", due_at)
self.assertLess(clear_at, callback_at)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,146 @@
#include <gtest/gtest.h>
#include <helpers/nrf52/InternalSecondaryFsRepair.h>
using mesh::storage::InternalSecondaryFsRepairResult;
using mesh::storage::InternalSecondaryFsBootResult;
using mesh::storage::isInternalExtraFsReservedByApplication;
using mesh::storage::isExpectedInternalExtraFsGeometry;
using mesh::storage::prepareInternalSecondaryFilesystem;
using mesh::storage::repairInternalSecondaryFilesystem;
TEST(InternalSecondaryFsBoot, RequiresExact100KiBGeometry) {
EXPECT_TRUE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19000, 128));
EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xED000, 0x7000, 128));
EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x7000, 128));
EXPECT_FALSE(isExpectedInternalExtraFsGeometry(0xD4000, 0x19000, 4096));
EXPECT_TRUE(isInternalExtraFsReservedByApplication(0xD4000));
EXPECT_TRUE(isInternalExtraFsReservedByApplication(0xC0000));
EXPECT_FALSE(isInternalExtraFsReservedByApplication(0xD4001));
EXPECT_FALSE(isInternalExtraFsReservedByApplication(0xED000));
}
TEST(InternalSecondaryFsBoot, HealthyFilesystemMountsWithoutOtherAccess) {
int blank_calls = 0;
int format_calls = 0;
int mount_calls = 0;
const auto result = prepareInternalSecondaryFilesystem(
[&]() { mount_calls++; return true; },
[&]() { blank_calls++; return false; },
[&]() { format_calls++; return true; });
EXPECT_EQ(result, InternalSecondaryFsBootResult::Mounted);
EXPECT_EQ(mount_calls, 1);
EXPECT_EQ(blank_calls, 0);
EXPECT_EQ(format_calls, 0);
}
TEST(InternalSecondaryFsBoot, FailedMountPreservesNonblankMedia) {
int format_calls = 0;
const auto result = prepareInternalSecondaryFilesystem(
[]() { return false; },
[]() { return false; },
[&]() { format_calls++; return true; });
EXPECT_EQ(result, InternalSecondaryFsBootResult::PreservedNonBlank);
EXPECT_EQ(format_calls, 0);
}
TEST(InternalSecondaryFsBoot, BlankMediaIsFormattedThenMounted) {
int mount_calls = 0;
int format_calls = 0;
const auto result = prepareInternalSecondaryFilesystem(
[&]() { return ++mount_calls == 2; },
[]() { return true; },
[&]() { format_calls++; return true; });
EXPECT_EQ(result, InternalSecondaryFsBootResult::InitializedBlank);
EXPECT_EQ(mount_calls, 2);
EXPECT_EQ(format_calls, 1);
}
TEST(InternalSecondaryFsBoot, BlankMediaFormatFailureStaysDisabled) {
int mount_calls = 0;
const auto result = prepareInternalSecondaryFilesystem(
[&]() { mount_calls++; return false; },
[]() { return true; },
[]() { return false; });
EXPECT_EQ(result, InternalSecondaryFsBootResult::InitializationFailed);
EXPECT_EQ(mount_calls, 1);
}
TEST(InternalSecondaryFsBoot, BlankMediaRemountFailureStaysDisabled) {
int mount_calls = 0;
const auto result = prepareInternalSecondaryFilesystem(
[&]() { mount_calls++; return false; },
[]() { return true; },
[]() { return true; });
EXPECT_EQ(result, InternalSecondaryFsBootResult::InitializationFailed);
EXPECT_EQ(mount_calls, 2);
}
TEST(InternalSecondaryFsRepair, RunsFormatMountAndValidationInOrder) {
int sequence = 0;
int formatted_at = 0;
int mounted_at = 0;
int validated_at = 0;
const auto result = repairInternalSecondaryFilesystem(
[&]() {
formatted_at = ++sequence;
return true;
},
[&]() {
mounted_at = ++sequence;
return true;
},
[&]() {
validated_at = ++sequence;
return true;
});
EXPECT_EQ(result, InternalSecondaryFsRepairResult::Repaired);
EXPECT_EQ(formatted_at, 1);
EXPECT_EQ(mounted_at, 2);
EXPECT_EQ(validated_at, 3);
}
TEST(InternalSecondaryFsRepair, FormatFailureStopsBeforeMount) {
int mount_calls = 0;
int validation_calls = 0;
const auto result = repairInternalSecondaryFilesystem(
[]() { return false; },
[&]() { mount_calls++; return true; },
[&]() { validation_calls++; return true; });
EXPECT_EQ(result, InternalSecondaryFsRepairResult::FormatFailed);
EXPECT_EQ(mount_calls, 0);
EXPECT_EQ(validation_calls, 0);
}
TEST(InternalSecondaryFsRepair, MountFailureStopsBeforeValidation) {
int validation_calls = 0;
const auto result = repairInternalSecondaryFilesystem(
[]() { return true; },
[]() { return false; },
[&]() { validation_calls++; return true; });
EXPECT_EQ(result, InternalSecondaryFsRepairResult::MountFailed);
EXPECT_EQ(validation_calls, 0);
}
TEST(InternalSecondaryFsRepair, RejectsFilesystemWhichRemainsInvalid) {
const auto result = repairInternalSecondaryFilesystem(
[]() { return true; },
[]() { return true; },
[]() { return false; });
EXPECT_EQ(result, InternalSecondaryFsRepairResult::ValidationFailed);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Contracts for the nRF52 Companion 100 KiB internal ExtraFS path."""
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
PROFILE = ROOT / "variants" / "t1000-e" / "platformio.ini"
LINKER = ROOT / "boards" / "nrf52840_s140_v7_extrafs.ld"
MAIN = ROOT / "examples" / "companion_radio" / "main.cpp"
STORE = ROOT / "examples" / "companion_radio" / "DataStore.cpp"
STORE_HEADER = ROOT / "examples" / "companion_radio" / "DataStore.h"
MESH = ROOT / "examples" / "companion_radio" / "MyMesh.cpp"
def section(source: str, name: str) -> str:
start = source.index(f"[env:{name}]")
end = source.find("\n[", start + 1)
return source[start:] if end < 0 else source[start:end]
def function_body(source: str, signature: str) -> str:
start = source.index(signature)
opening = source.index("{", start)
depth = 0
for position in range(opening, len(source)):
if source[position] == "{":
depth += 1
elif source[position] == "}":
depth -= 1
if depth == 0:
return source[opening + 1 : position]
raise AssertionError(f"unterminated function: {signature}")
class Nrf52ExtraFsContractTest(unittest.TestCase):
def test_t1000_usb_and_ble_reserve_exact_100k_region(self):
profile = PROFILE.read_text(encoding="utf-8")
for environment in (
"t1000e_companion_radio_usb",
"t1000e_companion_radio_ble",
):
body = section(profile, environment)
self.assertIn(
"board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld",
body,
)
self.assertIn("board_upload.maximum_size = 708608", body)
linker = LINKER.read_text(encoding="utf-8")
self.assertRegex(
linker,
r"FLASH\s*\(rx\)\s*:\s*ORIGIN\s*=\s*0x27000,\s*"
r"LENGTH\s*=\s*0xD4000\s*-\s*0x27000",
)
main = MAIN.read_text(encoding="utf-8")
geometry = re.search(
r"CustomLFS\s+ExtraFS\s*\(\s*(0x[0-9A-Fa-f]+)\s*,\s*"
r"(0x[0-9A-Fa-f]+)\s*,\s*128\s*\)",
main,
)
self.assertIsNotNone(geometry)
start = int(geometry.group(1), 16)
size = int(geometry.group(2), 16)
self.assertEqual(start, 0xD4000)
self.assertEqual(size, 100 * 1024)
self.assertEqual(start + size, 0xED000)
def test_boot_mount_is_non_destructive_and_checks_runtime_geometry(self):
main = MAIN.read_text(encoding="utf-8")
setup = function_body(main, "void setup()")
self.assertIn("isExpectedInternalExtraFsGeometry(", setup)
self.assertIn("isInternalExtraFsReservedByApplication(", setup)
self.assertIn("uint32_t __flash_arduino_end[]", main)
self.assertIn("(uintptr_t)__flash_arduino_end", setup)
self.assertIn("prepareInternalSecondaryFilesystem(", setup)
self.assertIn(
"ExtraFS.Adafruit_LittleFS::begin()", setup
)
self.assertNotIn("ExtraFS.begin()", setup)
self.assertIn("PreservedNonBlank", setup)
self.assertIn("store.disableSecondaryFS();", setup)
self.assertLess(
setup.index("prepareInternalSecondaryFilesystem("),
setup.index("store.begin()"),
)
def test_corrupt_secondary_is_preserved_until_explicit_repair(self):
store = STORE.read_text(encoding="utf-8")
begin = function_body(store, "void DataStore::begin()")
invalid = re.search(
r"if\s*\(_fsExtra\s*!=\s*nullptr\s*&&\s*"
r"!validateLfsFilesystem\(_fsExtra\)\)\s*\{(.*?)\}",
begin,
re.DOTALL,
)
self.assertIsNotNone(invalid)
self.assertIn("_fsExtra = nullptr;", invalid.group(1))
self.assertNotIn("format", invalid.group(1))
self.assertNotIn("reinitializeInternalExtraFS", invalid.group(1))
header = STORE_HEADER.read_text(encoding="utf-8")
self.assertIn("FILESYSTEM* _configuredFsExtra;", header)
extra_constructor = re.search(
r"DataStore::DataStore\(FILESYSTEM& fs, FILESYSTEM& fsExtra,.*?\{",
store,
re.DOTALL,
)
self.assertIsNotNone(extra_constructor)
self.assertIn("_configuredFsExtra(&fsExtra)", extra_constructor.group(0))
def test_repair_is_internal_only_and_revalidates_before_activation(self):
store = STORE.read_text(encoding="utf-8")
repair = function_body(store, "bool DataStore::repairInternalExtraFS()")
self.assertRegex(
repair,
r"#if defined\(NRF52_PLATFORM\) && defined\(EXTRAFS\) "
r"&& !defined\(QSPIFLASH\)",
)
self.assertIn("reinitializeInternalExtraFS()", repair)
self.assertIn("return migrateToSecondaryFS();", repair)
active_retry = repair[
repair.index("if (_fsExtra != nullptr)") :
repair.index("if (!reinitializeInternalExtraFS())")
]
self.assertIn("return migrateToSecondaryFS();", active_retry)
self.assertNotIn("format()", active_retry)
reinitialize = function_body(
store, "bool DataStore::reinitializeInternalExtraFS()"
)
geometry_at = reinitialize.index("isExpectedInternalExtraFsGeometry(")
self.assertIn("isInternalExtraFsReservedByApplication(", reinitialize)
self.assertIn("uint32_t __flash_arduino_end[]", store)
self.assertIn("(uintptr_t)__flash_arduino_end", reinitialize)
unmount_at = reinitialize.index("extra->end()")
format_at = reinitialize.index("extra->format()")
mount_at = reinitialize.index(
"extra->Adafruit_LittleFS::begin()"
)
validate_at = reinitialize.index(
"validateLfsFilesystem(_configuredFsExtra)"
)
activate_at = reinitialize.index("_fsExtra = _configuredFsExtra;")
self.assertLess(geometry_at, unmount_at)
self.assertLess(unmount_at, format_at)
self.assertLess(format_at, mount_at)
self.assertLess(mount_at, validate_at)
self.assertLess(validate_at, activate_at)
def test_migration_failures_block_rebuild_and_can_be_retried(self):
store = STORE.read_text(encoding="utf-8")
header = STORE_HEADER.read_text(encoding="utf-8")
migration = function_body(store, "bool DataStore::migrateToSecondaryFS()")
self.assertIn("bool migrateToSecondaryFS();", header)
self.assertIn('SECONDARY_MIGRATION_JOURNAL = "/.extrafs.mig"', store)
pending_at = migration.index(
"writeJournal(SECONDARY_MIGRATION_PENDING)"
)
copy_at = migration.index("copySecondarySources()", pending_at)
committed_at = migration.index(
"writeJournal(SECONDARY_MIGRATION_COMMITTED)", copy_at
)
retire_at = migration.index("retireSecondarySources()", committed_at)
journal_remove_at = migration.index(
"_fsExtra->remove(SECONDARY_MIGRATION_JOURNAL)", retire_at
)
self.assertLess(pending_at, copy_at)
self.assertLess(copy_at, committed_at)
self.assertLess(committed_at, retire_at)
self.assertLess(retire_at, journal_remove_at)
copy_failure_end = migration.index(
"journal_state = MigrationJournalState::Committed", committed_at
)
self.assertIn(
"_fsExtra = nullptr;", migration[copy_at:copy_failure_end]
)
copy_helper = function_body(migration, "auto copy =")
self.assertNotIn("source_fs->remove(path)", copy_helper)
self.assertIn("return success;", migration)
self.assertIn("compared == expected_size", migration)
mesh = MESH.read_text(encoding="utf-8")
repair = function_body(mesh, "void MyMesh::repairInternalExtraFS(")
migrate_at = repair.index("_store->repairInternalExtraFS()")
return_at = repair.index("return;", migrate_at)
contacts_at = repair.index("_store->saveContacts(this, save_filter)")
self.assertLess(migrate_at, return_at)
self.assertLess(return_at, contacts_at)
self.assertNotIn("already_ready", repair)
def test_legacy_primary_files_move_before_contact_journal_can_fall_back(self):
store = STORE.read_text(encoding="utf-8")
migration = function_body(store, "bool DataStore::migrateToSecondaryFS()")
# Older ExtraFS test builds could leave identity and preferences only
# on the secondary filesystem. Recover those first: every journal
# failure below disables/falls back from that secondary filesystem.
self.assertIn(
'static const char* to_primary[] = {"/_main.id", "/new_prefs"};',
migration,
)
primary_move_at = migration.index("if (!migratePrimarySources())")
journal_flow_at = migration.index(
"MigrationJournalState journal_state = readJournal()"
)
self.assertLess(primary_move_at, journal_flow_at)
# A primary-present conflict is nonfatal: primary is canonical and the
# differing secondary copy is preserved for recovery. Only failure to
# create a missing primary copy may block the handoff.
copy_helper = function_body(migration, "auto copy =")
primary_helper = function_body(
migration, "auto migratePrimarySources ="
)
self.assertNotIn("source_fs->remove(path)", copy_helper)
self.assertLess(
copy_helper.rindex("filesEqual(source_fs, dest_fs, path)"),
copy_helper.rindex("return true;"),
)
conflict_at = primary_helper.index(
"if (!filesEqual(_fsExtra, _fs, path))"
)
missing_copy_at = primary_helper.index(
"else if (!copy(_fsExtra, _fs, path))"
)
remove_at = primary_helper.index("_fsExtra->remove(path)")
self.assertLess(conflict_at, missing_copy_at)
self.assertLess(missing_copy_at, remove_at)
conflict_branch = primary_helper[
conflict_at:missing_copy_at
]
self.assertIn("continue;", conflict_branch)
self.assertNotIn("success = false", conflict_branch)
missing_copy_branch = primary_helper[
missing_copy_at:remove_at
]
self.assertIn("success = false;", missing_copy_branch)
retire_branch = primary_helper[remove_at:]
self.assertNotIn("success = false;", retire_branch)
self.assertIn(
"preserving conflicting legacy secondary", primary_helper
)
def test_local_rescue_repair_rebuilds_live_contacts_and_channels(self):
mesh = MESH.read_text(encoding="utf-8")
repair = function_body(mesh, "void MyMesh::repairInternalExtraFS(")
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("getStorageTotalKb()", repair)
rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()")
command_at = rescue.index('strcmp(cli_command, "repair extrafs")')
rebuild_at = rescue.index('strcmp(cli_command, "rebuild")')
repair_path = rescue[command_at:rebuild_at]
self.assertIn("repairInternalExtraFS(output);", repair_path)
terminal = function_body(mesh, "void MyMesh::handleTerminalCommand(")
self.assertIn('strcmp(command, "repair extrafs")', terminal)
self.assertIn("repairInternalExtraFS(terminalOutput());", terminal)
self.assertIn(
"repair extrafs (erases/rebuilds internal ExtraFS)", terminal
)
def test_rescue_rebuild_reports_success_only_after_every_save_succeeds(self):
mesh = MESH.read_text(encoding="utf-8")
rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()")
rebuild_at = rescue.index('strcmp(cli_command, "rebuild")')
erase_at = rescue.index('strcmp(cli_command, "erase")', rebuild_at)
rebuild = rescue[rebuild_at:erase_at]
expected_saves = (
("identity_saved", "_store->saveMainIdentity(self_id)"),
("prefs_saved", "savePrefs()"),
("contacts_saved", "_store->saveContacts(this, save_filter)"),
("channels_saved", "saveChannels()"),
)
for result, call in expected_saves:
self.assertRegex(
rebuild,
rf"(?:const\s+)?bool\s+{result}\s*=\s*"
rf"{re.escape(call)}\s*;",
)
# A failed or still-pending contact rebuild must retain the lazy-write
# retry path instead of claiming that the destructive rebuild is done.
self.assertRegex(
rebuild,
r"(?:const\s+)?bool\s+contacts_complete\s*=\s*"
r"contacts_saved\s*&&\s*"
r"!_store->hasPendingContactWrites\(\)\s*;",
)
self.assertIsNotNone(
re.search(
r"if\s*\(\s*contacts_complete\s*\)\s*\{.*?\}\s*"
r"else\s*\{\s*scheduleContactWriteRetry\(\)\s*;",
rebuild,
re.DOTALL,
)
)
success_text = 'output.println(" > erase and rebuild done")'
success_at = rebuild.index(success_text)
success_prefix = rebuild[:success_at]
guard_at = success_prefix.rfind("if (")
brace_at = success_prefix.rfind("{")
self.assertGreaterEqual(guard_at, 0)
self.assertGreater(brace_at, guard_at)
success_guard = success_prefix[guard_at:brace_at]
for result in ("identity_saved", "prefs_saved", "channels_saved"):
self.assertIn(result, success_guard)
self.assertIn("contacts_complete", success_guard)
self.assertNotIn("||", success_guard)
# The positive message must follow all save attempts; the branch must
# also retain an explicit error response for a partial rebuild.
for _, call in expected_saves:
self.assertLess(rebuild.index(call), success_at)
self.assertIn("Error:", rebuild[success_at:])
def test_quarantined_extrafs_rescue_file_commands_are_null_safe(self):
mesh = MESH.read_text(encoding="utf-8")
rescue = function_body(mesh, "void MyMesh::checkCLIRescueCmd()")
unavailable = (
'ExtraFS is unavailable; run repair extrafs first'
)
cat_start = rescue.index('memcmp(cli_command, "cat", 3)')
rm_start = rescue.index('memcmp(cli_command, "rm ", 3)', cat_start)
reboot_start = rescue.index(
'strcmp(cli_command, "reboot")', rm_start
)
cat_path = rescue[cat_start:rm_start]
rm_path = rescue[rm_start:reboot_start]
for command_path in (cat_path, rm_path):
secondary_at = command_path.index(
"_store->getSecondaryFS()"
)
null_guard_at = command_path.index(
"if (selected_fs == nullptr)", secondary_at
)
access_at = min(
position
for operation in (
"_store->openRead(selected_fs, path)",
"_store->removeFile(selected_fs, path)",
)
if (position := command_path.find(operation)) >= 0
)
self.assertLess(null_guard_at, access_at)
self.assertIn(unavailable, command_path)
self.assertIn("uint8_t buffer[64];", cat_path)
self.assertNotRegex(cat_path, r"uint8_t\s+buffer\s*\[\s*file_size\s*\]")
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Static contract for the low-level ESP32 OTA commit reboot handoff."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
def function_body(source: str, signature: str) -> str:
start = source.index(signature)
opening_brace = source.index("{", start)
depth = 0
for position in range(opening_brace, len(source)):
if source[position] == "{":
depth += 1
elif source[position] == "}":
depth -= 1
if depth == 0:
return source[opening_brace + 1 : position]
raise AssertionError(f"unterminated function: {signature}")
class OtaDevRebootGuardContractTest(unittest.TestCase):
def test_slot_arm_never_reboots_inline(self):
header = (ROOT / "src/helpers/ota/OtaApply.h").read_text()
apply = (ROOT / "src/helpers/ota/OtaApply.cpp").read_text()
self.assertIn("bool ota_apply_arm();", header)
arm = function_body(apply, "bool ota_apply_arm()")
self.assertIn("esp_ota_set_boot_partition", arm)
self.assertNotIn("esp_restart", arm)
self.assertNotIn("ota_apply_commit", apply)
def test_dev_commit_arms_the_guarded_mesh_reboot(self):
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text()
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text()
mesh = (ROOT / "src/Mesh.cpp").read_text()
dev = function_body(
cli,
"static bool handle_dev(const char* d, char* reply, OtaContext& c) {",
)
commit_at = dev.index('strncmp(sub, "commit", 6)')
commit = dev[commit_at : dev.index("} else {", commit_at)]
arm_at = commit.index("ota_apply_arm()")
pending_at = commit.index("c.apply_pending = true")
self.assertLess(arm_at, pending_at)
self.assertIn("c.bootloader_apply_pending = false", commit)
self.assertIn("c.apply_at = 0", commit)
self.assertIn("c.apply_hard = 0", commit)
self.assertNotIn("ota_reboot_to_apply", commit)
self.assertNotIn("esp_restart", commit)
self.assertIn("bool apply_pending = false;", context)
maintenance = function_body(
mesh, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()"
)
guard_at = maintenance.index("prepareForOtaReboot()")
reboot_at = maintenance.index("ota::ota_reboot_to_apply()")
self.assertLess(guard_at, reboot_at)
if __name__ == "__main__":
unittest.main()