Fix nRF52 contact persistence and ExtraFS recovery
This commit is contained in:
1 parent
9e874917b4
commit
610a88e8d5
23 files changed
+1591
-113
No files matched your search
@@ -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(); }
|
||||
|
||||
@@ -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 = [©](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, ©, &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, ©]() -> 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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*,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user