feat: preserve normal radio profiles across event firmware (#11110)

* feat: isolate event radio profiles

* fix: preserve identity on degraded config boot

* fix: guard event profile storage

* style: align event profile storage names

* fix: discard event profile on normal boot

* refactor: simplify event profile cleanup

* fix: limit inactive profile migration to event firmware

* fix(event): make event-profile capacity check portable across filesystems

The USERPREFS_EVENT_MODE storage preflight called FSCom.totalBytes() and
FSCom.usedBytes(), which only exist on ESP32's LittleFS wrapper. Every other
backend failed to compile once event mode was enabled:

  error: 'class InternalFileSystem' has no member named 'totalBytes'   (nRF52)
  error: no member named 'totalBytes' in 'fs::FS'                      (Portduino)

This was not caught earlier because the event block is behind
#if USERPREFS_EVENT_MODE, and the existing unit tests only cover the pure
helpers, which compile identically either way.

Add fsTotalBytes()/fsUsedBytes() to FSCommon and implement them per backend:
littlefs v1 traversal for nRF52 (Adafruit_LittleFS) and STM32
(STM32_LittleFS), FSInfo for RP2040, statvfs for Portduino, and the native
methods for ESP32 and nRF54L15. The nRF52/STM32 path reports "full" if the
traversal errors so the capacity check fails safe.

Verified with USERPREFS_EVENT_MODE=1: native-macos test_event_profile_storage
passes 5/5, and heltec-v3, rak4631, rak11310 and rak3172 all build clean.

* fix(event): use std::filesystem for Portduino capacity, bump native-suite-count

Two CI fixes:

- native-windows has no <sys/statvfs.h>, so the Portduino branch of
  fsTotalBytes()/fsUsedBytes() broke the Windows build. Switch to
  std::filesystem::space(), which is already used under ARCH_PORTDUINO in
  HostMetrics.cpp and works on both POSIX and MinGW. Errors report "full"
  so the capacity check still fails safe.

- This PR adds test/test_event_profile_storage, taking test/ from 40 to 41
  suite directories, which trips the native-suite-count reconciliation check.

* fix(event): scope boot-write deferral to the radio profile, address review

Review feedback:

- Copilot: saveProto()'s boot-write deferral applied to every file, so
  loadFromDisk()'s recovery writes (e.g. restoring owner fields into
  devicestate) were silently dropped and never retried, because the ctor CRC
  baselines are computed after loadFromDisk(). Deferral now applies only to
  the radio-profile files, via isRadioProfileFile().

- jp-bennett: eventConfigFromStandard() wrapped a struct copy plus one field
  assignment in a header helper with its own unit test. Inlined at its single
  call site and removed; the behaviour is covered end-to-end by hardware
  validation instead (RAK4631 normal -> event -> normal preserves NodeNum and
  public key while swapping LoRa, and restores the original channel
  byte-identically).

- jp-bennett: the active-backup encrypted-storage migration ran for normal
  builds too, which changed non-event behaviour and contradicted the PR's
  stated event-only scope. Scoped to USERPREFS_EVENT_MODE; the adjacent block
  already covers the inactive standard backup in event builds.

New tests (all verified to fail under mutation, not vacuous):

- test_event_paths_never_collide_with_standard_or_shared_files: the core
  safety property. A path-table slip would make event firmware overwrite the
  user's real config, channels or backup, or capture a shared file like
  devicestate/nodedb. Nothing asserted this before.

- test_only_radio_profile_files_defer_boot_writes: guards the deferral fix
  above so re-widening it fails loudly.

- test_event_reservation_fits_smallest_supported_filesystem: the reservation
  is compile-time but must fit filesystems as small as 14 KiB (STM32WL) and
  28 KiB (nRF52840). Protobuf growth pushing it past those would silently
  stop event profiles persisting - the exact failure mode confirmed by
  fault injection on a RAK4631.

Verified with USERPREFS_EVENT_MODE both on and off: 7/7 tests pass, and
rak4631, heltec-v3, rak11310 and rak3172 all build clean.

* fix: preserve event profile storage recovery

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Benjamin Faershtein <benjaminfaershtein@Benjamins-MacBook-Pro-2.local>
This commit is contained in:
Benjamin Faershtein
2026-07-26 22:58:23 +00:00
committed by GitHub
co-authored by GitHub Jonathan Bennett Ben Meadors Benjamin Faershtein
parent 5c5cb094e6
commit 45cb8e7500
6 changed files with 401 additions and 7 deletions
+134 -3
View File
@@ -638,6 +638,7 @@ NodeDB::NodeDB()
#endif
sortMeshDB();
saveToDisk(saveWhat);
bootInitializationInProgress = false;
}
/**
@@ -2193,6 +2194,41 @@ void NodeDB::loadFromDisk()
migrationSavePending = false;
configDecodeFailed = false;
configLoadComplete = false;
#if !USERPREFS_EVENT_MODE
#ifdef FSCom
const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME};
spiLock->lock();
for (const char *filename : eventProfileFiles) {
if (FSCom.exists(filename) && !FSCom.remove(filename))
LOG_WARN("Unable to remove stale event profile file %s", filename);
}
spiLock->unlock();
#endif
#endif
#if USERPREFS_EVENT_MODE
// Seed only a missing event config; never overwrite normal files after a corrupt event config.
bool eventConfigMissing = false;
eventProfileStorageUnavailable = false;
#ifdef FSCom
spiLock->lock();
eventConfigMissing = !FSCom.exists(configFileName);
if (eventConfigMissing) {
const size_t totalBytes = fsTotalBytes();
const size_t usedBytes = fsUsedBytes();
eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes);
if (eventProfileStorageUnavailable) {
LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.",
static_cast<unsigned>(EVENT_PROFILE_STORAGE_RESERVATION_BYTES),
static_cast<unsigned>(totalBytes >= usedBytes ? totalBytes - usedBytes : 0));
}
}
spiLock->unlock();
#endif
bool initializedEventConfig = false;
#endif
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
@@ -2382,6 +2418,31 @@ void NodeDB::loadFromDisk()
state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
&config);
#if USERPREFS_EVENT_MODE
if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) {
const LoadFileResult eventConfigState = state;
const LoadFileResult standardConfigState =
loadProto(STANDARD_CONFIG_FILE_NAME, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig),
&meshtastic_LocalConfig_msg, &config);
if (standardConfigState == LoadFileResult::LOAD_SUCCESS) {
// Preserve the user's identity and non-radio preferences, then
// replace only LoRa with this event build's compiled defaults.
const meshtastic_LocalConfig standardConfig = config;
installDefaultConfig(true);
const meshtastic_Config_LoRaConfig eventLora = config.lora;
config = standardConfig;
config.has_lora = true;
config.lora = eventLora;
state = LoadFileResult::LOAD_SUCCESS;
initializedEventConfig = true;
LOG_INFO("Initialized event config without modifying %s", STANDARD_CONFIG_FILE_NAME);
} else {
// Keep the event load outcome because loadProto() clears config before decoding.
// A normal decode failure must not create a replacement identity.
state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState;
}
}
#endif
if (state == LoadFileResult::DECODE_FAILED) {
// Config file present but undecodable this boot (corruption / torn write / transient decrypt fail).
// loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change
@@ -2403,6 +2464,7 @@ void NodeDB::loadFromDisk()
} else {
LOG_INFO("Loaded saved config version %d", config.version);
}
configLoadComplete = true;
// Coerce LoRa config fields derived from presets while bootstrapping.
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
@@ -2443,6 +2505,15 @@ void NodeDB::loadFromDisk()
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
#endif
#if USERPREFS_EVENT_MODE
if (initializedEventConfig) {
// This is the first durable event-profile write. A failed write is
// safe: normal files remain untouched and the next event boot retries.
if (!saveToDisk(SEGMENT_CONFIG))
LOG_ERROR("Unable to persist initial event config");
}
#endif
if (backupSecurity.private_key.size > 0) {
LOG_DEBUG("Restoring backup of security config");
config.security = backupSecurity;
@@ -2557,7 +2628,7 @@ void NodeDB::loadFromDisk()
const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE,
SEGMENT_NODEDATABASE};
int toSave = 0;
for (int i = 0; i < 5; i++) {
for (size_t i = 0; i < sizeof(segments) / sizeof(segments[0]); i++) {
if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
toSave |= segments[i];
}
@@ -2574,6 +2645,43 @@ void NodeDB::loadFromDisk()
EncryptedStorage::migrateFile(fn);
}
}
// Backups are outside saveToDisk(), but can contain radio profile PSKs. Only event builds
// introduce a second backup file, so leave normal-firmware backup handling unchanged.
#if USERPREFS_EVENT_MODE
#ifdef FSCom
spiLock->lock();
const bool activeBackupExists = FSCom.exists(backupFileName);
spiLock->unlock();
if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) {
LOG_INFO("Migrating %s to encrypted storage", backupFileName);
if (!EncryptedStorage::migrateFile(backupFileName)) {
LOG_ERROR("Unable to migrate %s to encrypted storage", backupFileName);
storageCorruptThisLoad = true;
}
}
#endif
#endif
// Event firmware keeps the normal radio profile inactive, so migrate it separately.
#if USERPREFS_EVENT_MODE
#ifdef FSCom
const char *inactiveRadioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME,
STANDARD_BACKUP_FILE_NAME};
for (const char *fn : inactiveRadioProfileFiles) {
spiLock->lock();
const bool exists = FSCom.exists(fn);
spiLock->unlock();
if (exists && !EncryptedStorage::isEncrypted(fn)) {
LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn);
if (!EncryptedStorage::migrateFile(fn)) {
LOG_ERROR("Unable to migrate %s to encrypted storage", fn);
storageCorruptThisLoad = true;
}
}
}
#endif
#endif
}
#endif
@@ -2708,8 +2816,9 @@ bool NodeDB::disableLockdownToPlaintext()
// plaintext->encrypted migrate loop above. Order does not matter here;
// EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
// the commit point) only runs after every file is confirmed plaintext.
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
nodeDatabaseFileName};
const char *filesToCheck[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME,
EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME,
moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName};
for (const char *fn : filesToCheck) {
if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn);
@@ -2729,6 +2838,15 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_
bool fullAtomic)
{
// Only the radio profile is at risk from an unverified config load, so only defer those writes.
// Devicestate/nodedb/module writes must still land, otherwise boot-time recovery (e.g. loadFromDisk()
// restoring owner fields) is dropped and never retried.
if (isRadioProfileFile(filename) &&
shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) {
LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename);
return true;
}
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
// and all writes will fail anyway. Device should be sleeping at this point anyway.
if (!powerHAL_isPowerLevelSafe()) {
@@ -2981,6 +3099,19 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
spiLock->unlock();
#endif
#if USERPREFS_EVENT_MODE
if (eventProfileStorageUnavailable) {
if (saveWhat & SEGMENT_CONFIG) {
LOG_WARN("Skipping event config write: insufficient profile storage at boot");
saveWhat &= ~SEGMENT_CONFIG;
}
if (saveWhat & SEGMENT_CHANNELS) {
LOG_WARN("Skipping event channel write: insufficient profile storage at boot");
saveWhat &= ~SEGMENT_CHANNELS;
}
}
#endif
if (saveWhat & SEGMENT_CONFIG) {
config.has_device = true;
config.has_display = true;