diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 31e53ea2..c79593e1 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -86,6 +86,10 @@ jobs: python3 -B test/test_companion_prefs_transactions.py -v python3 -B test/test_companion_primary_radio_persistence.py -v python3 -B test/test_companion_response_bounds.py -v + python3 -B test/test_companion_uncached_storage.py -v + python3 -B test/test_companion_ota_config.py -v + python3 -B test/test_common_radio_persistence.py -v + python3 -B test/test_common_prefs_commit.py -v python3 -B test/test_contact_cache.py -v - name: Verify receive recovery and pinned radio status transport diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index b7e699b8..e49c1dcd 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -6,6 +6,10 @@ #if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) #include #endif +#if defined(ESP32_PLATFORM) +#include +#include +#endif #if COMPANION_FEATURE_JOHN #include #endif @@ -57,6 +61,37 @@ DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) } #endif +#if !defined(NRF52_PLATFORM) +// Unlike ESP32 FS::exists(), a metadata probe must not treat failure to open +// an existing file as evidence that its durable contents are absent. +static bool companionPathPresence(FILESYSTEM* fs, const char* path, + bool& present) { +#if defined(ESP32_PLATFORM) + (void)fs; // Companion storage is mounted at the default SPIFFS VFS path. + char vfs_path[96]; + const int length = snprintf(vfs_path, sizeof(vfs_path), "/spiffs%s", path); + if (length < 0 || static_cast(length) >= sizeof(vfs_path)) return false; + struct stat info; + const int result = ::stat(vfs_path, &info); + if (result != 0 && errno != ENOENT) return false; + present = result == 0; +#elif defined(STM32_PLATFORM) + struct lfs_info info; + fs->_lockFS(); + const int result = lfs_stat(fs->_getFS(), path, &info); + fs->_unlockFS(); + if (result != 0 && result != LFS_ERR_NOENT) return false; + present = result == 0; +#else + // Arduino-Pico's exists() uses lfs_stat(), not an open operation. + // Its public API hides metadata errors; existing-file open/read failures + // are distinguishable below, but metadata I/O failure is not. + present = fs->exists(path); +#endif + return true; +} +#endif + static File openWrite(FILESYSTEM* fs, const char* filename) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) fs->remove(filename); @@ -524,13 +559,39 @@ bool DataStore::formatFileSystem() { _identity_creation_blocked = false; _prefs_load_incomplete = false; } +#else + if (success) { + _identity_creation_blocked = false; + _prefs_load_incomplete = false; + _channel_load_incomplete = false; +#if !MESH_CONTACT_CACHE + _uncached_contact_load_incomplete = false; +#endif + } #endif return success; #elif defined(RP2040_PLATFORM) - return LittleFS.format(); + const bool success = LittleFS.format(); + if (success) { + _identity_creation_blocked = false; + _prefs_load_incomplete = false; + _channel_load_incomplete = false; + _uncached_contact_load_incomplete = false; + } + return success; #elif defined(ESP32) bool fs_success = ((fs::SPIFFSFS *)_fs)->format(); esp_err_t nvs_err = nvs_flash_erase(); // no need to reinit, will be done by reboot + if (fs_success && nvs_err == ESP_OK) { + _identity_creation_blocked = false; + _prefs_load_incomplete = false; + _channel_load_incomplete = false; +#if MESH_CONTACT_CACHE + _cache_load_incomplete = false; +#else + _uncached_contact_load_incomplete = false; +#endif + } return fs_success && (nvs_err == ESP_OK); #else #error "need to implement format()" @@ -588,22 +649,32 @@ bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) { _identity_creation_blocked = false; return true; #else - return identity_store.load("_main", identity); -#endif -} - -bool DataStore::canCreateMainIdentity() const { -#if defined(NRF52_PLATFORM) - return !_identity_creation_blocked; + bool identity_exists = false; +#if defined(STM32_PLATFORM) + const char* path = "/_main.id"; #else + const char* path = "/identity/_main.id"; +#endif + if (!companionPathPresence(_fs, path, identity_exists)) { + _identity_creation_blocked = true; + return false; + } + if (!identity_exists) return false; + if (!identity_store.load("_main", identity)) { + _identity_creation_blocked = true; + return false; + } + _identity_creation_blocked = false; return true; #endif } +bool DataStore::canCreateMainIdentity() const { + return !_identity_creation_blocked; +} + bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) { -#if defined(NRF52_PLATFORM) if (_identity_creation_blocked) return false; -#endif return identity_store.save("_main", identity); } @@ -1399,6 +1470,9 @@ bool DataStore::writeContactPage(DataStoreHost* host, uint8_t page, #endif void DataStore::loadContacts(DataStoreHost* host) { +#if !defined(NRF52_PLATFORM) && !MESH_CONTACT_CACHE + if (_uncached_contact_load_incomplete) return; +#endif #if MESH_CONTACT_CACHE _cache_host = host; mesh::contactPathStorage().attach(this); @@ -1408,13 +1482,34 @@ void DataStore::loadContacts(DataStoreHost* host) { if (_cache_load_incomplete) return; #if defined(ESP32_PLATFORM) _contact_path_reader.close(); - if (!mesh::ContactFileTransaction::recover(_getContactsChannelsFS(), "/contacts3")) { +#endif +#endif +#if !defined(NRF52_PLATFORM) + bool contacts_exist = false; + bool contacts_metadata_ready = companionPathPresence( + _getContactsChannelsFS(), "/contacts3", contacts_exist); +#endif +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + bool backup_exists = false; + contacts_metadata_ready = contacts_metadata_ready && companionPathPresence( + _getContactsChannelsFS(), "/contacts3.bak", backup_exists); + const bool contacts_required = contacts_exist || backup_exists; + contacts_metadata_ready = contacts_metadata_ready + && mesh::ContactFileTransaction::recover(_getContactsChannelsFS(), "/contacts3") + && companionPathPresence(_getContactsChannelsFS(), "/contacts3", contacts_exist) + && (!contacts_required || contacts_exist); +#endif +#if !defined(NRF52_PLATFORM) + if (!contacts_metadata_ready) { MESH_DEBUG_PRINTLN("DataStore: contact transaction recovery failed"); +#if MESH_CONTACT_CACHE _cache_load_incomplete = true; +#else + _uncached_contact_load_incomplete = true; +#endif return; } #endif -#endif #if defined(NRF52_PLATFORM) // loadContacts() is also used after an identity import. Rebuild runtime // slot ownership from disk so stale pointers/slots from the previous in-RAM @@ -1524,9 +1619,13 @@ void DataStore::loadContacts(DataStoreHost* host) { #endif File file = openRead(_getContactsChannelsFS(), "/contacts3"); -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) - if (!file && _getContactsChannelsFS()->exists("/contacts3")) { +#if !defined(NRF52_PLATFORM) + if (!file && contacts_exist) { +#if MESH_CONTACT_CACHE _cache_load_incomplete = true; +#else + _uncached_contact_load_incomplete = true; +#endif return; } #endif @@ -1553,9 +1652,13 @@ void DataStore::loadContacts(DataStoreHost* host) { if (file) { bool full = false; uint16_t record_index = 0; -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if !defined(NRF52_PLATFORM) if (file.size() % mesh::storage::CONTACT_RECORD_SIZE != 0) { +#if MESH_CONTACT_CACHE _cache_load_incomplete = true; +#else + _uncached_contact_load_incomplete = true; +#endif file.close(); return; } @@ -1567,7 +1670,7 @@ void DataStore::loadContacts(DataStoreHost* host) { bool legacy_host_refused = false; #endif while (!full -#if MESH_CONTACT_CACHE && defined(ESP32_PLATFORM) +#if !defined(NRF52_PLATFORM) && record_index < file.size() / mesh::storage::CONTACT_RECORD_SIZE #endif #if defined(NRF52_PLATFORM) @@ -1578,6 +1681,8 @@ void DataStore::loadContacts(DataStoreHost* host) { if (file.read(record, sizeof(record)) != sizeof(record)) { #if MESH_CONTACT_CACHE _cache_load_incomplete = true; +#elif !defined(NRF52_PLATFORM) + _uncached_contact_load_incomplete = true; #endif #if defined(NRF52_PLATFORM) legacy_read_failed = true; @@ -1591,6 +1696,8 @@ void DataStore::loadContacts(DataStoreHost* host) { if (path_unavailable) { #if MESH_CONTACT_CACHE _cache_load_incomplete = true; +#elif !defined(NRF52_PLATFORM) + _uncached_contact_load_incomplete = true; #endif #if defined(NRF52_PLATFORM) legacy_read_failed = true; @@ -1613,6 +1720,8 @@ void DataStore::loadContacts(DataStoreHost* host) { full = true; #if MESH_CONTACT_CACHE _cache_load_incomplete = true; +#elif !defined(NRF52_PLATFORM) + _uncached_contact_load_incomplete = true; #endif #if defined(NRF52_PLATFORM) _contact_slots.release(slot); @@ -1717,7 +1826,11 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn paths.endCommit(success); return success; #else - File file = openWrite(_getContactsChannelsFS(), "/contacts3"); +#if defined(STM32_PLATFORM) + mesh::AtomicFileWriter file(_getContactsChannelsFS(), "/contacts3"); +#else + mesh::ContactFileTransaction file(_getContactsChannelsFS(), "/contacts3"); +#endif bool success = (bool)file; if (file) { uint32_t idx = 0; @@ -1748,7 +1861,7 @@ bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn idx++; // advance to next contact } - file.close(); + success = file.commit(success); } return success; #endif @@ -1929,6 +2042,9 @@ bool DataStore::hasPendingContactWrites() const { bool DataStore::hasIncompleteContactLoad() const { #if !defined(NRF52_PLATFORM) if (_channel_load_incomplete) return true; +#if !MESH_CONTACT_CACHE + if (_uncached_contact_load_incomplete) return true; +#endif #endif #if MESH_CONTACT_CACHE if (_cache_load_incomplete) return true; diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index e03169e5..9857d11a 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -39,9 +39,13 @@ class DataStore FILESYSTEM* _configuredFsExtra; mesh::RTCClock* _clock; IdentityStore identity_store; + bool _identity_creation_blocked = false; bool _prefs_load_incomplete = false; #if !defined(NRF52_PLATFORM) bool _channel_load_incomplete = false; +#if !MESH_CONTACT_CACHE + bool _uncached_contact_load_incomplete = false; +#endif #endif #if MESH_CONTACT_CACHE DataStoreHost* _cache_host = nullptr; @@ -66,7 +70,6 @@ class DataStore mesh::storage::DirtyPageSet _dirty_contact_pages; mesh::storage::DirtyPageSet _unread_contact_pages; bool _contact_load_incomplete = false; - bool _identity_creation_blocked = false; bool _primary_storage_unavailable = false; bool _secondary_authority_unknown = false; uint32_t _contact_page_generations[mesh::storage::CONTACT_PAGE_COUNT]; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f3a8ccd7..4d8dc0b5 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -62,6 +62,8 @@ #if COMPANION_FEATURE_OTA_CLI #include +#include +#include #endif #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) @@ -1714,6 +1716,10 @@ void MyMesh::begin(bool has_display, bool radio_available) { #if defined(ENABLE_OTA) mesh::ota::beginSpeedConfig(_store->getPrimaryFS()); #endif +#if COMPANION_FEATURE_OTA_CLI + mesh::ota::beginCompanionOtaConfig(_store->getPrimaryFS()); + mesh::ota::ota_set_context_config_loader(mesh::ota::loadCompanionOtaConfig); +#endif const bool identity_loaded = _store->loadMainIdentity(self_id); const bool is_new_install = !identity_loaded @@ -3267,7 +3273,20 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (strncmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) { char ota_reply[160] = {0}; + if (!mesh::ota::ota_acquire_context(ota_reply, sizeof(ota_reply))) { + snprintf(reply, reply_size, "%s", ota_reply); + return true; + } + auto& context = mesh::ota::ota_ctx(); + const auto previous = mesh::ota::OtaConfigState::capture(context); if (!mesh::ota::handle_ota_command(command, ota_reply, board)) return false; + if (context.config_dirty) { + if (!mesh::ota::saveCompanionOtaConfig(mesh::ota::OtaConfigState::capture(context))) { + previous.apply(context); + snprintf(ota_reply, sizeof(ota_reply), "ERR OTA settings save failed; settings unchanged"); + } + context.config_dirty = false; + } snprintf(reply, reply_size, "%s", ota_reply); return true; } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8f8d124e..df0837e6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3800,10 +3800,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif saved_radio_apply_pending = !applySavedRadioParams(); - if (!saved_radio_apply_pending) { - radio_driver.setTxPower(_prefs.tx_power_dbm); - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - } MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); const bool fem_gain_changed = board.canControlLoRaFemLna() @@ -4176,13 +4172,16 @@ bool MyMesh::applySavedRadioParams() { } #endif + // Each setter may independently defer while a packet is being received. + // Only complete recovery when the whole saved configuration was accepted. + if (radio_driver.supportsRxBoostedGainMode() + && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; if (!applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _cli.radioProfiles().primaryPreamble())) return false; #if defined(USE_LR2021) - return radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw); -#else - return true; + if (!radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false; #endif + return radio_driver.setTxPower(_prefs.tx_power_dbm); } void MyMesh::queueSavedRadioApply() { @@ -4724,28 +4723,21 @@ void MyMesh::processScheduledRadioSettings() { } ScheduledRadioSetting& setting = scheduled_radio_settings[due_idx]; - if (!_cli.radioProfiles().savePrimaryPreamble(setting.preamble)) { + if (!_cli.savePrimaryRadioParams(setting.freq, setting.bw, setting.sf, + setting.cr, setting.preamble)) { scheduled_radio_save_retry_at = futureMillis(60000); if (!scheduled_radio_save_retry_at) scheduled_radio_save_retry_at = 1; break; } scheduled_radio_save_retry_at = 0; - _prefs.freq = setting.freq; - _prefs.bw = setting.bw; - _prefs.sf = setting.sf; - _prefs.cr = setting.cr; setting.active = false; setting.started = false; saved_params_changed = true; } if (saved_params_changed) { - // Keep level-derived RX duty-cycle windows synchronized with the newly - // persisted SF/BW. Manual RX/sleep timings intentionally remain fixed. - CommonCLI::recalculateRxPowerSavingFromLevel(&_prefs); - _prefs.tx_power_dbm = mesh::clampLoRaTxPower( - _prefs.tx_power_dbm, _prefs.freq); - savePrefs(); + // Only move the live radio after the complete tuple has committed. Failed + // entries remain queued and retain their previous durable configuration. queueSavedRadioApply(); } @@ -4790,12 +4782,7 @@ void MyMesh::processScheduledRadioSettings() { refreshScheduledRadioState(); if (saved_radio_apply_pending && !temp_radio_handoff_pending && !scheduled_temp_radio_started && !apply_failed) { - // If begin() deferred the saved params to preserve a wake packet, its gain - // update was deferred for the same reason. Retry both at the first safe - // handoff; unsupported boosted-gain modes remain harmless here. - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); if (applySavedRadioParams()) { - radio_driver.setTxPower(_prefs.tx_power_dbm); saved_radio_apply_pending = false; temp_radio_applied = false; if (radio_timing.isTemporary()) setTempRadioTiming(0); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 323523c7..0e9d8c35 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -10,8 +10,10 @@ #include "AdvertDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch() #include "sensors/EnvironmentI2CConfig.h" -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include "AtomicFileWriter.h" +#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) +#include "ContactFileTransaction.h" #endif #include #include @@ -25,6 +27,7 @@ #if defined(ENABLE_OTA) #include "ota/OtaCli.h" #include "ota/OtaContext.h" // persist/sync OTA policy + signer allowlist with NodePrefs + #include "ota/OtaConfigState.h" #include "ota/OtaSpeedConfig.h" #endif @@ -735,6 +738,10 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { void CommonCLI::loadPrefs(FILESYSTEM* fs) { _radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true); + _prefs->primary_radio_preamble = _radio_profiles.primaryPreamble(); +#if !defined(WITH_MQTT_BRIDGE) && (defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)) + mesh::ContactFileTransaction::recover(fs, "/com_prefs"); +#endif #if defined(ENABLE_OTA) mesh::ota::beginSpeedConfig(fs); #endif @@ -863,6 +870,8 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #if MESH_USB_LOGGING_AVAILABLE mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0); #endif + _radio_profiles.adoptPrimaryPreamble(_prefs->primary_radio_preamble); + _radio_profiles.stagePrimary(_prefs->primary_radio_preamble, false); } #if defined(ENABLE_OTA) @@ -1261,6 +1270,14 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { if (file.available() >= (int)sizeof(_prefs->bridge_format)) { file.read((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); + if (file.available() >= (int)sizeof(_prefs->primary_radio_preamble)) { + uint16_t preamble = 0; + if (file.read((uint8_t *)&preamble, sizeof(preamble)) == sizeof(preamble) + && (preamble == 0 || (preamble >= 8 + && preamble <= mesh::RadioProfiles::MaxPreamble))) { + _prefs->primary_radio_preamble = preamble; + } + } } } } @@ -1580,6 +1597,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { WRITE_COMMON_PREFS(&prefs->usb_logging_enabled); // 861 WRITE_COMMON_PREFS(&prefs->bridge_uart); // 862 WRITE_COMMON_PREFS(&prefs->bridge_format); // 863 + WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -1589,10 +1607,14 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { const PrefsSaveRouting::Plan plan = PrefsSaveRouting::planFor(scope); + if (plan.common) { + _common_save_result_known = true; + _common_save_succeeded = false; + } #ifdef WITH_MQTT_BRIDGE // Observer builds use a verified temp/backup transaction for common prefs. // Radio and bridge changes must never leave a truncated boot-time image. - if (plan.common) saveCommonPrefsImageAtomically(fs); + if (plan.common) _common_save_succeeded = saveCommonPrefsImageAtomically(fs); if (plan.observer) { _observer_save_result_known = true; _observer_save_succeeded = saveMQTTPrefs(fs); @@ -1601,15 +1623,10 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { #else // Observer-only saves are a no-op on roles with no observer preference image. if (!plan.common) return; -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) mesh::AtomicFileWriter file(fs, "/com_prefs"); -#elif defined(STM32_PLATFORM) - fs->remove("/com_prefs"); - File file = fs->open("/com_prefs", FILE_O_WRITE); -#elif defined(RP2040_PLATFORM) - File file = fs->open("/com_prefs", "w"); #else - File file = fs->open("/com_prefs", "w", true); + mesh::ContactFileTransaction file(fs, "/com_prefs"); #endif if (file) { uint8_t pad[8]; @@ -1737,15 +1754,12 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { file.write((uint8_t *)&_prefs->usb_logging_enabled, sizeof(_prefs->usb_logging_enabled)); // 861 file.write((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); // 862 file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863 - // next: 864 + file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended -#if defined(NRF52_PLATFORM) - if (!file.commit()) { + _common_save_succeeded = file.commit(); + if (!_common_save_succeeded) { MESH_DEBUG_PRINTLN("ERROR: savePrefs atomic commit failed"); } -#else - file.close(); -#endif } #endif } @@ -2028,7 +2042,14 @@ public: && !_fs->rename("/com_prefs", "/com_prefs.bak")) { return false; } - if (!_fs->rename("/com_prefs.tmp", "/com_prefs")) return false; + if (!_fs->rename("/com_prefs.tmp", "/com_prefs")) { + // An explicit failure must retain the previous durable configuration. + // If restoration also fails, recovery keeps the backup authoritative. + if (_fs->exists("/com_prefs.bak")) { + _fs->rename("/com_prefs.bak", "/com_prefs"); + } + return false; + } if (_fs->exists("/com_prefs.bak")) _fs->remove("/com_prefs.bak"); return true; } @@ -2036,7 +2057,9 @@ public: void abort() { if (_open) _file.close(); _open = false; - if (_owns_temp && !_finished && _fs->exists("/com_prefs.tmp")) { + // abort() is an explicit rejected write, unlike a power loss. Never leave + // its candidate eligible to become the committed primary on a later boot. + if (_owns_temp && _fs->exists("/com_prefs.tmp")) { _fs->remove("/com_prefs.tmp"); } _finished = false; @@ -2076,21 +2099,12 @@ bool CommonCLI::recoverCommonPrefsFiles(FILESYSTEM* fs) { if (fs->exists("/com_prefs.bak")) fs->remove("/com_prefs.bak"); return !fs->exists("/com_prefs.tmp") && !fs->exists("/com_prefs.bak"); - case Action::PromoteTemp: - if (fs->rename("/com_prefs.tmp", "/com_prefs")) { - if (fs->exists("/com_prefs.bak")) fs->remove("/com_prefs.bak"); - return fs->exists("/com_prefs"); - } - // The verified new image could not be published. Restore the previous - // image so boot can continue with the last committed radio settings. - if (fs->rename("/com_prefs.bak", "/com_prefs")) { - if (fs->exists("/com_prefs.tmp")) fs->remove("/com_prefs.tmp"); - return true; - } - return false; - case Action::PromoteBackup: - return fs->rename("/com_prefs.bak", "/com_prefs"); + // A complete temp was not necessarily accepted by the caller. Roll back + // an interrupted or rejected commit to the last published image. + if (!fs->rename("/com_prefs.bak", "/com_prefs")) return false; + if (fs->exists("/com_prefs.tmp")) fs->remove("/com_prefs.tmp"); + return !fs->exists("/com_prefs.tmp"); case Action::DiscardTemp: // With no backup, a reset may have interrupted the very first write @@ -2455,6 +2469,39 @@ bool CommonCLI::saveObserverPrefs() { #endif } +bool CommonCLI::saveCommonPrefs() { + _common_save_result_known = false; + _common_save_succeeded = false; + _callbacks->savePrefs(PrefsSaveRouting::Scope::Common); + return _common_save_result_known && _common_save_succeeded; +} + +bool CommonCLI::savePrimaryRadioParams(float freq, float bw, uint8_t sf, + uint8_t cr, uint16_t preamble) { + if (!isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f + || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw) + || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) return false; + const float old_freq = _prefs->freq, old_bw = _prefs->bw; + const uint8_t old_sf = _prefs->sf, old_cr = _prefs->cr; + const int8_t old_power = _prefs->tx_power_dbm; + const uint32_t old_rx = _prefs->rx_ps_rx_us, old_sleep = _prefs->rx_ps_sleep_us; + const uint16_t old_preamble = _prefs->primary_radio_preamble; + _prefs->freq = freq; _prefs->bw = bw; _prefs->sf = sf; _prefs->cr = cr; + _prefs->primary_radio_preamble = preamble; + _prefs->tx_power_dbm = mesh::clampLoRaTxPower(old_power, freq); + recalculateRxPowerSavingFromLevel(_prefs); + if (!saveCommonPrefs()) { + _prefs->freq = old_freq; _prefs->bw = old_bw; + _prefs->sf = old_sf; _prefs->cr = old_cr; + _prefs->tx_power_dbm = old_power; + _prefs->rx_ps_rx_us = old_rx; _prefs->rx_ps_sleep_us = old_sleep; + _prefs->primary_radio_preamble = old_preamble; + return false; + } + _radio_profiles.adoptPrimaryPreamble(preamble); + return true; +} + uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) { AdvertDataBuilder builder(node_type, _prefs->node_name); @@ -2695,20 +2742,31 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re && otaCommandNeedsTempRadio(command)) { strcpy(reply, "LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120"); } else { + if (!mesh::ota::ota_acquire_context(reply, 160)) return; + const auto previous = mesh::ota::OtaConfigState::capture(mesh::ota::ota_ctx()); mesh::ota::handle_ota_command(command, reply, *_board); if (mesh::ota::ota_context_if_active() && mesh::ota::ota_ctx().config_dirty) { // a policy/key changed via the CLI -> persist it mesh::ota::OtaContext& c = mesh::ota::ota_ctx(); - _prefs->ota_autofetch = c.manager.autofetch(); - _prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks(); - _prefs->ota_advert_interval = c.manager.advert_mins(); - _prefs->ota_max_hops = c.manager.max_hops(); - _prefs->ota_autoinstall = c.autoinstall; - _prefs->ota_signer_count = c.allow.count(); - for (uint8_t i = 0; i < c.allow.count() && i < MAX_OTA_SIGNERS; i++) - memcpy(_prefs->ota_signers[i], c.allow.get(i), 32); - _callbacks->savePrefs(); - c.config_dirty = false; + const auto copy_policy = [this, &c]() { + _prefs->ota_autofetch = c.manager.autofetch(); + _prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks(); + _prefs->ota_advert_interval = c.manager.advert_mins(); + _prefs->ota_max_hops = c.manager.max_hops(); + _prefs->ota_autoinstall = c.autoinstall; + _prefs->ota_signer_count = c.allow.count(); + memset(_prefs->ota_signers, 0, sizeof(_prefs->ota_signers)); + for (uint8_t i = 0; i < c.allow.count() && i < MAX_OTA_SIGNERS; i++) + memcpy(_prefs->ota_signers[i], c.allow.get(i), 32); + }; + copy_policy(); + if (saveCommonPrefs()) { + c.config_dirty = false; + } else { + previous.apply(c); + copy_policy(); + strcpy(reply, "ERR OTA configuration could not be saved"); + } } } #else @@ -3657,22 +3715,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw) && _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) { - if (!_radio_profiles.savePrimaryPreamble(preamble)) { - strcpy(reply, "Error: preamble could not be saved"); return; - } - _prefs->sf = sf; - _prefs->cr = cr; - _prefs->freq = freq; - _prefs->bw = bw; const int8_t previous_power = _prefs->tx_power_dbm; - _prefs->tx_power_dbm = mesh::clampLoRaTxPower( - _prefs->tx_power_dbm, _prefs->freq); - // Retune level-based RX powersaving to the new SF/BW. Persist only; the - // radio itself is "reboot to apply", and begin() re-arms the timings then. - recalcRxPowerSavingFromLevel( - _prefs->rx_ps_level, _prefs->sf, _prefs->bw, _prefs->rx_ps_preamble, &_prefs->rx_ps_rx_us, - &_prefs->rx_ps_sleep_us); // retune level-based timings to the loaded SF/BW - _callbacks->savePrefs(); + if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) { + strcpy(reply, "Error: radio settings could not be saved"); return; + } if (_prefs->tx_power_dbm != previous_power) { sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm", (int)_prefs->tx_power_dbm); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index d42e892e..7db07973 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -188,6 +188,9 @@ public: // ESP-NOW bridge wire format. Appended at /com_prefs offset 863; zero keeps // existing installations on the original wrapped/checksummed/XOR format. uint8_t bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED; + // Appended after the existing /com_prefs tail. Keep the preamble in the same + // transaction as frequency/modulation; old images adopt /radio_profiles. + uint16_t primary_radio_preamble = 0; uint8_t retry_preset = 0; uint8_t direct_retry_attempts = 0; uint16_t direct_retry_base_ms = 0; @@ -756,6 +759,8 @@ class CommonCLI { bool _observer_save_succeeded = false; #endif bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load + bool _common_save_result_known = false; + bool _common_save_succeeded = false; mesh::RadioProfileCLI _radio_profiles; mesh::RTCClock* getRTCClock() { return _rtc; } @@ -793,6 +798,9 @@ class CommonCLI { bool handleObserverCommand(uint32_t sender_timestamp, char* command, char* reply); public: + bool saveCommonPrefs(); + bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, + uint16_t preamble); mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; } const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; } static bool calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, diff --git a/src/helpers/CommonPrefsRecovery.h b/src/helpers/CommonPrefsRecovery.h index 885e77ac..066d784f 100644 --- a/src/helpers/CommonPrefsRecovery.h +++ b/src/helpers/CommonPrefsRecovery.h @@ -4,19 +4,18 @@ // Recovery policy for a verified temp/backup preference transaction. // The writer moves the old primary to backup only after temp is complete. +// Until the new primary is published, the backup remains authoritative. namespace CommonPrefsRecovery { enum class Action : uint8_t { None, KeepPrimary, - PromoteTemp, PromoteBackup, DiscardTemp, }; inline Action select(bool primary_exists, bool temp_exists, bool backup_exists) { if (primary_exists) return Action::KeepPrimary; - if (backup_exists && temp_exists) return Action::PromoteTemp; if (backup_exists) return Action::PromoteBackup; if (temp_exists) return Action::DiscardTemp; return Action::None; diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index af7cce57..16b28fa4 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -69,6 +69,9 @@ class RadioProfileCLI { uint32_t replyMutationGeneration() const { return remote_generation_; } bool finishReplyMutation(bool delivered); uint16_t primaryPreamble() const { return primary_preamble_; } + // Infrastructure owns a newer, atomic primary tuple in /com_prefs. Adopting + // its committed value changes saved intent only, never the running radio. + void adoptPrimaryPreamble(uint16_t symbols) { primary_preamble_ = symbols; } bool savePrimaryPreamble(uint16_t symbols); bool acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const; RadioParamApplyResult applyPrimary(float freq, float bw, uint8_t sf, uint8_t cr, diff --git a/src/helpers/ota/CompanionOtaConfig.cpp b/src/helpers/ota/CompanionOtaConfig.cpp new file mode 100644 index 00000000..8d2107e5 --- /dev/null +++ b/src/helpers/ota/CompanionOtaConfig.cpp @@ -0,0 +1,160 @@ +#if defined(ENABLE_OTA) && (defined(COMPANION_RADIO_FULL) || COMPANION_FEATURE_OTA_CLI) +#include "CompanionOtaConfig.h" +#include +#include +#if defined(ESP32_PLATFORM) +#include +#include +#endif + +namespace mesh { namespace ota { +namespace { +FILESYSTEM* settings_fs = nullptr; +bool held = false; +constexpr size_t ImageSize = 16 + MAX_OTA_SIGNERS * 32; +constexpr size_t CrcOffset = ImageSize - 4; +const char* const Path = "/ota_config"; +const char* const Temp = "/ota_config.tmp"; +const char* const Backup = "/ota_config.bak"; + +// Some FS::exists implementations open the file and report an unreadable +// existing image as absent. Metadata errors must not authorize replacement. +bool pathPresence(const char* path, bool& present) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + struct lfs_info info; + settings_fs->_lockFS(); + const int result = lfs_stat(settings_fs->_getFS(), path, &info); + settings_fs->_unlockFS(); + if (result != 0 && result != LFS_ERR_NOENT) return false; + present = result == 0; +#elif defined(ESP32_PLATFORM) + char vfs_path[48]; + const int length = snprintf(vfs_path, sizeof(vfs_path), "/spiffs%s", path); + if (length < 0 || static_cast(length) >= sizeof(vfs_path)) return false; + struct stat info; + const int result = ::stat(vfs_path, &info); + if (result != 0 && errno != ENOENT) return false; + present = result == 0; +#else + // Arduino-Pico exposes only a boolean metadata result. It can distinguish + // file-open failures below, but not missing metadata from metadata I/O. + present = settings_fs->exists(path); +#endif + return true; +} + +bool removeIfPresent(const char* path) { + bool present; + return pathPresence(path, present) && (!present || settings_fs->remove(path)); +} + +bool validState(const OtaConfigState& state) { + return state.autofetch <= 2 && state.autoinstall <= 1 && state.hops <= 8 + && state.checkpoint <= 4096 && state.advert <= 10080; +} + +bool decode(const uint8_t* image, OtaConfigState& state) { + if (memcmp(image, "OC\1\0", 4) || image[7] > MAX_OTA_SIGNERS + || storage::readLE32(image + CrcOffset) + != storage::updateCRC32(0xffffffffU, image, CrcOffset)) return false; + OtaConfigState candidate; + candidate.autofetch = image[4]; candidate.autoinstall = image[5]; + candidate.hops = image[6]; + candidate.checkpoint = storage::readLE16(image + 8); + candidate.advert = storage::readLE16(image + 10); + if (!validState(candidate)) return false; + for (uint8_t i = 0; i < image[7]; ++i) candidate.allow.add(image + 12 + i * 32); + if (candidate.allow.count() != image[7]) return false; + state = candidate; + return true; +} + +bool readImage(const char* path, uint8_t* image, OtaConfigState& state) { +#if defined(NRF52_PLATFORM) + File file(*settings_fs); + if (!file.open(path, FILE_O_READ)) return false; +#elif defined(STM32_PLATFORM) + File file = settings_fs->open(path, FILE_O_READ); +#else + File file = settings_fs->open(path, "r"); +#endif + if (!file) return false; + const bool complete = file.size() == ImageSize + && file.read(image, ImageSize) == (int)ImageSize; + file.close(); + return complete && decode(image, state); +} +} + +void beginCompanionOtaConfig(FILESYSTEM* fs) { + settings_fs = fs; + held = false; +} + +bool loadCompanionOtaConfig(OtaConfigState& state) { + if (!settings_fs) return false; + uint8_t image[ImageSize]; + bool primary = false, backup = false; + if (!pathPresence(Path, primary)) { held = true; return false; } + if (primary && readImage(Path, image, state)) return true; + if (primary) held = true; + if (!pathPresence(Backup, backup)) { held = true; return false; } + if (backup && readImage(Backup, image, state)) { + // Keep the verified previous settings even if recovery cannot rename them. + // An unreadable primary may only have suffered a transient read failure; + // do not remove it to promote the fallback. + if (primary || !settings_fs->rename(Backup, Path)) held = true; + return true; + } + if (primary || backup) { + held = true; // unreadable is not permission to overwrite with defaults + return false; + } + state = OtaConfigState(); + return true; +} + +bool saveCompanionOtaConfig(const OtaConfigState& state) { + if (!settings_fs || held || !validState(state)) return false; + uint8_t image[ImageSize] = {'O', 'C', 1, 0}, verify[ImageSize]; + OtaConfigState prior; + bool had_primary = false; + if (!pathPresence(Path, had_primary)) { held = true; return false; } + if (had_primary && !readImage(Path, verify, prior)) { + held = true; + return false; + } + image[4] = state.autofetch; image[5] = state.autoinstall; image[6] = state.hops; + image[7] = state.allow.count(); + storage::writeLE16(image + 8, state.checkpoint); + storage::writeLE16(image + 10, state.advert); + for (uint8_t i = 0; i < state.allow.count(); ++i) + memcpy(image + 12 + i * 32, state.allow.get(i), 32); + storage::writeLE32(image + CrcOffset, + storage::updateCRC32(0xffffffffU, image, CrcOffset)); + if (!removeIfPresent(Temp)) return false; +#if defined(NRF52_PLATFORM) + File file(*settings_fs); + if (!file.open(Temp, FILE_O_WRITE)) return false; +#elif defined(STM32_PLATFORM) + File file = settings_fs->open(Temp, FILE_O_WRITE); +#else + File file = settings_fs->open(Temp, "w"); +#endif + if (!file) return false; + const bool wrote = file.write(image, sizeof(image)) == sizeof(image); + file.flush(); file.close(); + if (!wrote || !readImage(Temp, verify, prior) + || memcmp(image, verify, sizeof(image))) return false; + if (!removeIfPresent(Backup)) return false; + if (had_primary && !settings_fs->rename(Path, Backup)) return false; + if (!settings_fs->rename(Temp, Path)) { + if (had_primary && !settings_fs->rename(Backup, Path)) held = true; + return false; + } + if (had_primary) settings_fs->remove(Backup); + return true; +} + +} } +#endif diff --git a/src/helpers/ota/CompanionOtaConfig.h b/src/helpers/ota/CompanionOtaConfig.h new file mode 100644 index 00000000..3a70f384 --- /dev/null +++ b/src/helpers/ota/CompanionOtaConfig.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include "OtaConfigState.h" + +namespace mesh { namespace ota { + +// Separate from /new_prefs: preserve compatibility with existing Companions. +// Initializing does not allocate an OTA context. Dynamic contexts load on use. +void beginCompanionOtaConfig(FILESYSTEM* fs); +bool loadCompanionOtaConfig(OtaConfigState& state); +bool saveCompanionOtaConfig(const OtaConfigState& state); + +} } diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 16ec98fd..bcce3995 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -950,9 +950,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "ERR this target has no SD-backed OTA archive"); #endif - // ---- policy config (persisted via NodePrefs). conservative defaults: autofetch/autoinstall off ---- + // ---- policy config (persisted by the caller). conservative defaults: autofetch/autoinstall off ---- } else if (is_cmd(a, "config|cfg|set", &rest)) { const char* p = rest; + const char* value = nullptr; #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) if (strncmp(p, "cache ", 6) == 0 || strncmp(p, "sdseed ", 7) == 0) { const char* v = p + (p[0] == 'c' ? 6 : 7); @@ -967,38 +968,38 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board } else #endif #if defined(OTA_SEEDER_ONLY) - if (strncmp(p, "autofetch ", 10) == 0 || strncmp(p, "autoinstall ", 12) == 0) { + if (is_cmd(p, "autofetch|autoinstall", &value)) { strcpy(reply, "ERR seeder-only build keeps autofetch and autoinstall off"); } else #endif - if (strncmp(p, "autofetch ", 10) == 0) { - const char* v = p + 10; - uint8_t pol = strncmp(v, "any", 3) == 0 ? OtaManager::AUTOFETCH_ANY - : strncmp(v, "signed", 6) == 0 ? OtaManager::AUTOFETCH_SIGNED - : strncmp(v, "off", 3) == 0 ? OtaManager::AUTOFETCH_OFF : 0xFF; + if (is_cmd(p, "autofetch", &value)) { + uint8_t pol = strcmp(value, "any") == 0 ? OtaManager::AUTOFETCH_ANY + : strcmp(value, "signed") == 0 ? OtaManager::AUTOFETCH_SIGNED + : strcmp(value, "off") == 0 ? OtaManager::AUTOFETCH_OFF : 0xFF; if (pol == 0xFF) { strcpy(reply, "ERR usage: ota config autofetch "); return true; } c.manager.set_autofetch(pol); c.config_dirty = true; strcpy(reply, "OK autofetch updated (saved)"); - } else if (strncmp(p, "autoinstall ", 12) == 0) { - const char* v = p + 12; - uint8_t pol = strncmp(v, "trusted", 7) == 0 ? OtaContext::AUTOINSTALL_TRUSTED - : strncmp(v, "off", 3) == 0 ? OtaContext::AUTOINSTALL_OFF : 0xFF; + } else if (is_cmd(p, "autoinstall", &value)) { + uint8_t pol = strcmp(value, "trusted") == 0 ? OtaContext::AUTOINSTALL_TRUSTED + : strcmp(value, "off") == 0 ? OtaContext::AUTOINSTALL_OFF : 0xFF; if (pol == 0xFF) { strcpy(reply, "ERR usage: ota config autoinstall "); return true; } c.autoinstall = pol; c.config_dirty = true; strcpy(reply, "OK autoinstall updated (saved)"); - } else if (strncmp(p, "checkpoint ", 11) == 0) { // resume checkpoint cadence (blocks; 0=never) - long n = atol(p + 11); - if (n < 0 || n > 4096) { strcpy(reply, "ERR usage: ota config checkpoint <0..4096> (blocks; 0=never)"); return true; } + } else if (is_cmd(p, "checkpoint", &value)) { // resume checkpoint cadence (blocks; 0=never) + uint32_t n; + if (!mesh::cli::parseUnsignedIntegerStrict(value, n) || n > 4096) { strcpy(reply, "ERR usage: ota config checkpoint <0..4096> (blocks; 0=never)"); return true; } c.manager.set_checkpoint_blocks((uint16_t)n); c.config_dirty = true; - sprintf(reply, "OK checkpoint every %ld blocks (saved)%s", n, n == 0 ? " - periodic resume disabled" : ""); - } else if (strncmp(p, "advert ", 7) == 0) { // beacon re-advertise cadence (minutes; 0=disable) - long m = atol(p + 7); - if (m < 0 || m > 10080) { strcpy(reply, "ERR usage: ota config advert <0..10080> (minutes; 0=disable)"); return true; } + sprintf(reply, "OK checkpoint every %lu blocks (saved)%s", (unsigned long)n, n == 0 ? " - periodic resume disabled" : ""); + } else if (is_cmd(p, "advert", &value)) { // beacon re-advertise cadence (minutes; 0=disable) + uint32_t m; + if (!mesh::cli::parseUnsignedIntegerStrict(value, m) || m > 10080) { strcpy(reply, "ERR usage: ota config advert <0..10080> (minutes; 0=disable)"); return true; } c.manager.set_advert_mins((uint16_t)m); c.config_dirty = true; - sprintf(reply, "OK re-advertise every %ld min (saved)%s", m, m == 0 ? " - periodic advert disabled" : ""); - } else if (strncmp(p, "hops ", 5) == 0) { // OTA flood reach in hops (0 = direct only) - long h = atol(p + 5); - if (h < 0 || h > 8) { strcpy(reply, "ERR usage: ota config hops <0..8> (hops; 0 = direct only)"); return true; } + sprintf(reply, "OK re-advertise every %lu min (saved)%s", (unsigned long)m, m == 0 ? " - periodic advert disabled" : ""); + } else if (is_cmd(p, "hops", &value)) { // OTA flood reach in hops (0 = direct only) + uint32_t h; + if (!mesh::cli::parseUnsignedIntegerStrict(value, h) || h > 8) { strcpy(reply, "ERR usage: ota config hops <0..8> (hops; 0 = direct only)"); return true; } c.manager.set_max_hops((uint8_t)h); c.config_dirty = true; - sprintf(reply, "OK OTA reach = %ld hop%s (saved)%s", h, h == 1 ? "" : "s", h == 0 ? " - direct only" : ""); + sprintf(reply, "OK OTA reach = %lu hop%s (saved)%s", (unsigned long)h, h == 1 ? "" : "s", h == 0 ? " - direct only" : ""); + } else if (*p) { + strcpy(reply, "ERR unknown OTA config setting"); } else { // show current policy uint8_t af = c.manager.autofetch(); char speed[16]; formatSpeed(speed, sizeof(speed)); diff --git a/src/helpers/ota/OtaConfigState.h b/src/helpers/ota/OtaConfigState.h new file mode 100644 index 00000000..020e3b64 --- /dev/null +++ b/src/helpers/ota/OtaConfigState.h @@ -0,0 +1,41 @@ +#pragma once + +#include "OtaManager.h" +#include "SignerAllowlist.h" + +namespace mesh { namespace ota { + +// Policy only: copying this must never copy an active transfer/workspace. +struct OtaConfigState { + uint8_t autofetch = 0, autoinstall = 0, hops = OTA_HOP_LIMIT_DEFAULT; + uint16_t checkpoint = OTA_CHECKPOINT_BLOCKS, advert = OTA_ADVERT_INTERVAL_MINS; + SignerAllowlist allow; + + template static OtaConfigState capture(const Context& context) { + OtaConfigState state; + state.autofetch = context.manager.autofetch(); + state.autoinstall = context.autoinstall; + state.hops = context.manager.max_hops(); + state.checkpoint = context.manager.checkpoint_blocks(); + state.advert = context.manager.advert_mins(); + state.allow = context.allow; + return state; + } + + template void apply(Context& context) const { +#if defined(OTA_SEEDER_ONLY) + context.manager.set_autofetch(0); + context.autoinstall = 0; +#else + context.manager.set_autofetch(autofetch); + context.autoinstall = autoinstall; +#endif + context.manager.set_max_hops(hops); + context.manager.set_checkpoint_blocks(checkpoint); + context.manager.set_advert_mins(advert); + context.allow = allow; + context.config_dirty = false; + } +}; + +} } diff --git a/src/helpers/ota/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp index 3de636e0..aca43c48 100644 --- a/src/helpers/ota/OtaContext.cpp +++ b/src/helpers/ota/OtaContext.cpp @@ -7,6 +7,18 @@ namespace mesh { namespace ota { +namespace { +bool (*context_config_loader)(OtaConfigState&) = nullptr; +} + +void ota_set_context_config_loader(bool (*load)(OtaConfigState&)) { + context_config_loader = load; + if (auto* context = ota_context_if_active()) { + OtaConfigState restored; + if (load && load(restored)) restored.apply(*context); + } +} + #if OTA_DYNAMIC_CONTEXT namespace { OtaContext* active_context = nullptr; @@ -101,6 +113,8 @@ bool ota_acquire_context(char* reply, size_t cap) { c.manager.set_max_hops(saved_hops); c.autoinstall = saved_autoinstall; c.allow = saved_allow; + OtaConfigState restored; + if (context_config_loader && context_config_loader(restored)) restored.apply(c); return true; } diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 72a1af15..3dc4e315 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -7,6 +7,7 @@ #include "OtaDeflate.h" #include "OtaStore.h" #include "SignerAllowlist.h" +#include "OtaConfigState.h" #include "OtaApply.h" #include "OtaFormat.h" #include "OtaByteIO.h" @@ -705,6 +706,8 @@ OtaContext& ota_ctx(); // process-wide context // Dynamic builds return null outside an acquired workspace. Static builds // always return their process-wide context. OtaContext* ota_context_if_active(); +// Optional role-specific policy reload; registration never allocates a context. +void ota_set_context_config_loader(bool (*load)(OtaConfigState&)); bool ota_acquire_context(char* reply, size_t cap); void ota_begin_context(uint32_t target, OtaSend send, void* ctx, const char* hw, const uint8_t* seeder_id); diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index e615d7dd..3aac99ba 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -75,6 +75,11 @@ public: Callbacks* _callbacks=&callbacks; Board* _board=&board; void savePrefs() { callbacks.savePrefs(); } + bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t) { + prefs.freq=freq; prefs.bw=bw; prefs.sf=sf; prefs.cr=cr; + prefs.tx_power_dbm=mesh::clampLoRaTxPower(prefs.tx_power_dbm,freq); + callbacks.savePrefs(); return true; + } void handleSetCmd(uint32_t, char* command, char* reply) { const char* config=command+4; @SET@ diff --git a/test/fixtures/companion_uncached_storage/test.cpp b/test/fixtures/companion_uncached_storage/test.cpp new file mode 100644 index 00000000..43b3519b --- /dev/null +++ b/test/fixtures/companion_uncached_storage/test.cpp @@ -0,0 +1,318 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { FILE_O_READ = 0, FILE_O_WRITE = 1, LFS_ERR_NOENT = -2 }; +class FakeFilesystem; +class File { + FakeFilesystem* fs = nullptr; + std::string path; + size_t position = 0; + bool writing = false; +public: + File() = default; + explicit File(FakeFilesystem& filesystem) : fs(&filesystem) {} + File(FakeFilesystem* filesystem, const char* name, bool write) + : fs(filesystem), path(name), writing(write) {} + explicit operator bool() const { return fs != nullptr; } + bool open(const char* name, uint8_t mode); + size_t read(uint8_t* bytes, size_t length); + size_t write(const uint8_t* bytes, size_t length); + size_t size() const; + void flush() {} + void close() { fs = nullptr; } +}; + +class FakeFilesystem { +public: + using Files = std::map>; + Files files; + size_t max_write = std::numeric_limits::max(); + size_t max_read = std::numeric_limits::max(); + std::string fail_open; + int stat_error = 0; + unsigned fail_rename = 0, renames = 0, writes = 0; + std::vector snapshots; + bool exists(const char* path) const { +#if defined(ESP32_PLATFORM) + if (fail_open == path) return false; // ESP32 VFS exists opens the file. +#endif + return files.count(path) != 0; + } + bool mkdir(const char*) { return true; } + bool remove(const char* path) { return files.erase(path) != 0; } + void _lockFS() {} + void _unlockFS() {} + FakeFilesystem* _getFS() { return this; } + File open(const char* path, const char* mode = "r", bool = false) { + if (fail_open == path) return File(); + if (*mode != 'r') { + files[path].clear(); + return File(this, path, true); + } + return exists(path) ? File(this, path, false) : File(); + } + bool rename(const char* from, const char* to) { + ++renames; + if (renames == fail_rename || !exists(from)) return false; +#if defined(ESP32_PLATFORM) + if (exists(to)) return false; +#endif + files[to] = files.at(from); + files.erase(from); + snapshots.push_back(files); + return true; + } +} filesystem; + +bool File::open(const char* name, uint8_t mode) { + if (!fs) return false; + *this = fs->open(name, mode == FILE_O_WRITE ? "w" : "r"); + return static_cast(*this); +} +size_t File::read(uint8_t* bytes, size_t length) { + if (!fs || writing) return 0; + const auto& data = fs->files.at(path); + const size_t count = std::min({length, fs->max_read, + data.size() - std::min(position, data.size())}); + if (count) memcpy(bytes, data.data() + position, count); + position += count; + return count; +} +size_t File::write(const uint8_t* bytes, size_t length) { + if (!fs || !writing) return 0; + ++fs->writes; + const size_t count = std::min(length, fs->max_write); + auto& data = fs->files[path]; + data.resize(position + count); + if (count) memcpy(data.data() + position, bytes, count); + position += count; + fs->snapshots.push_back(fs->files); + return count; +} +size_t File::size() const { return fs ? fs->files.at(path).size() : 0; } + +#if defined(RP2040_PLATFORM) +#include +#define FILESYSTEM fs::FS +#else +#define FILESYSTEM FakeFilesystem +#endif +#include +#include +#include +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include + +struct stat {}; +int stat(const char* path, struct stat*) { + assert(strncmp(path, "/spiffs", 7) == 0); + errno = filesystem.stat_error; + if (errno) return -1; + if (filesystem.files.count(path + 7) == 0) { errno = ENOENT; return -1; } + return 0; +} +struct lfs_info {}; +int lfs_stat(FakeFilesystem* fs, const char* path, lfs_info*) { + if (fs->stat_error) return -5; + return fs->files.count(path) != 0 ? 0 : LFS_ERR_NOENT; +} + +#if defined(STM32_PLATFORM) +static const char* identity_path = "/_main.id"; +#else +static const char* identity_path = "/identity/_main.id"; +#endif + +struct Host { + size_t capacity = 100; + std::vector contacts; + bool getContactForSave(uint32_t index, ContactInfo& c) { + if (index >= contacts.size()) return false; + c = contacts[index]; + return true; + } + bool onContactLoaded(const ContactInfo& c) { + if (contacts.size() >= capacity) return false; + contacts.push_back(c); + return true; + } +}; +using DataStoreHost = Host; + +class DataStore { + FakeFilesystem* _fs = &filesystem; + bool _identity_creation_blocked = false; + bool _channel_load_incomplete = false; + bool _uncached_contact_load_incomplete = false; + struct IdentityAdapter { + bool load(const char*, mesh::LocalIdentity&) { + File file = filesystem.open(identity_path); + uint8_t data[96]; + return file && file.read(data, sizeof(data)) == sizeof(data); + } + bool save(const char*, const mesh::LocalIdentity&) { + ++filesystem.writes; + return true; + } + } identity_store; +public: + FakeFilesystem* _getContactsChannelsFS() { return _fs; } + File openRead(FakeFilesystem* fs, const char* path) { return fs->open(path); } + bool loadMainIdentity(mesh::LocalIdentity&); + bool canCreateMainIdentity() const; + bool saveMainIdentity(const mesh::LocalIdentity&); + void loadContacts(DataStoreHost*); + bool saveContacts(DataStoreHost*, bool (*filter)(const ContactInfo&) = nullptr); + bool hasIncompleteContactLoad() const; +}; +#include "store_under_test.h" + +static ContactInfo contact(uint8_t value) { + ContactInfo c{}; + memset(c.id.pub_key, value, sizeof(c.id.pub_key)); + memset(c.name, 0, sizeof(c.name)); + c.name[0] = 'A' + value; + c.type = 1; + c.out_path_len = OUT_PATH_UNKNOWN; + uint8_t path[64] = {}; + assert(c.setRawPath(path)); + return c; +} + +static void identity_checks() { + filesystem = FakeFilesystem(); + DataStore fresh; + mesh::LocalIdentity identity; + assert(!fresh.loadMainIdentity(identity)); + assert(fresh.canCreateMainIdentity()); + assert(fresh.saveMainIdentity(identity)); + for (unsigned fault = 0; fault < 4; ++fault) { + filesystem = FakeFilesystem(); + filesystem.files[identity_path] = std::vector(96, 7); + if (fault == 0) filesystem.fail_open = identity_path; + if (fault == 1) filesystem.max_read = 32; + if (fault == 2) filesystem.files[identity_path].resize(32); + if (fault == 3) filesystem.stat_error = EIO; +#if defined(RP2040_PLATFORM) + if (fault == 3) continue; // this backend exposes only bool stat/exists +#endif + const auto original = filesystem.files; + DataStore store; + assert(!store.loadMainIdentity(identity)); + assert(!store.canCreateMainIdentity()); + assert(!store.saveMainIdentity(identity)); + assert(filesystem.writes == 0 && filesystem.files == original); + filesystem.fail_open.clear(); + filesystem.max_read = std::numeric_limits::max(); + filesystem.stat_error = 0; + filesystem.files[identity_path].resize(96); + DataStore rebooted; + assert(rebooted.loadMainIdentity(identity)); + assert(rebooted.canCreateMainIdentity()); + } +} + +static std::vector make_original() { + filesystem = FakeFilesystem(); + DataStore store; + Host host; + host.contacts = {contact(1), contact(2)}; + assert(store.saveContacts(&host)); + return filesystem.files.at("/contacts3"); +} + +static void contact_checks() { + const auto original = make_original(); + { + filesystem = FakeFilesystem(); + filesystem.files["/contacts3"] = original; + DataStore store; + Host empty; + assert(store.saveContacts(&empty)); + assert(filesystem.files.at("/contacts3").empty()); + store.loadContacts(&empty); + assert(!store.hasIncompleteContactLoad() && empty.contacts.empty()); + } + Host replacement; + replacement.contacts = {contact(3), contact(4), contact(5)}; + for (unsigned fault = 0; fault < 5; ++fault) { + filesystem = FakeFilesystem(); + filesystem.files["/contacts3"] = original; + if (fault == 0) filesystem.max_write = 2; + if (fault == 1) filesystem.fail_open = "/contacts3.tmp"; + if (fault == 2) filesystem.max_read = 2; + if (fault == 3) filesystem.fail_rename = 1; + if (fault == 4) filesystem.fail_rename = 2; +#if defined(STM32_PLATFORM) + if (fault == 4) continue; // LittleFS atomically replaces in one rename +#endif + DataStore store; + assert(!store.saveContacts(&replacement)); + assert(filesystem.files.at("/contacts3") == original); + } + + filesystem = FakeFilesystem(); + filesystem.files["/contacts3"] = original; + DataStore store; + assert(store.saveContacts(&replacement)); + const auto updated = filesystem.files.at("/contacts3"); + assert(updated.size() == 3 * mesh::storage::CONTACT_RECORD_SIZE); + // Reboot from every write/rename boundary of the actual transaction. + const auto snapshots = filesystem.snapshots; + for (const auto& snapshot : snapshots) { + filesystem = FakeFilesystem(); + filesystem.files = snapshot; + DataStore rebooted; + Host loaded; + rebooted.loadContacts(&loaded); + assert(!rebooted.hasIncompleteContactLoad()); + assert(loaded.contacts.size() == 2 || loaded.contacts.size() == 3); + assert(filesystem.files.at("/contacts3") == original + || filesystem.files.at("/contacts3") == updated); + } + for (unsigned fault = 0; fault < 4; ++fault) { + filesystem = FakeFilesystem(); + filesystem.files["/contacts3"] = original; + if (fault == 0) filesystem.fail_open = "/contacts3"; + if (fault == 1) filesystem.max_read = 16; + if (fault == 2) filesystem.files["/contacts3"].pop_back(); + DataStore rebooted; + Host loaded; + if (fault == 3) loaded.capacity = 1; + rebooted.loadContacts(&loaded); + assert(rebooted.hasIncompleteContactLoad()); + const auto unchanged = filesystem.files; + assert(!rebooted.saveContacts(&loaded)); + assert(filesystem.files == unchanged); + } +#if defined(ESP32_PLATFORM) + filesystem = FakeFilesystem(); + filesystem.files["/contacts3.bak"] = original; + filesystem.fail_open = "/contacts3.bak"; + DataStore recovering; + Host unavailable; + recovering.loadContacts(&unavailable); + assert(recovering.hasIncompleteContactLoad()); + assert(!recovering.saveContacts(&unavailable)); + assert(filesystem.files.at("/contacts3.bak") == original); +#endif + filesystem = FakeFilesystem(); + filesystem.files["/contacts3"] = original; + DataStore filtered; + assert(filtered.saveContacts(&replacement, + [](const ContactInfo&) -> bool { return false; })); + assert(filesystem.files.at("/contacts3").empty()); +} + +int main() { + identity_checks(); + contact_checks(); +} diff --git a/test/fixtures/contact_cache/test_contact_cache.cpp b/test/fixtures/contact_cache/test_contact_cache.cpp index 854a4615..f9aadd2e 100644 --- a/test/fixtures/contact_cache/test_contact_cache.cpp +++ b/test/fixtures/contact_cache/test_contact_cache.cpp @@ -146,6 +146,11 @@ bool contactPathPresence(FakeFilesystem* fs, const char* path, bool& present) { present = fs->exists(path); return true; } +#else +bool companionPathPresence(FakeFilesystem* fs, const char* path, bool& present) { + present = fs->exists(path); + return true; +} #endif // The narrow hardware adapter below supplies the same filesystem/host seams. diff --git a/test/test_common_prefs_commit.py b/test/test_common_prefs_commit.py new file mode 100644 index 00000000..35266af6 --- /dev/null +++ b/test/test_common_prefs_commit.py @@ -0,0 +1,170 @@ +"""Exercise the actual common preference store and recovery after failed commits.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +class MemoryFS; +class File { + MemoryFS* fs = nullptr; + std::shared_ptr> bytes; + bool valid = false; +public: + File() = default; + File(MemoryFS* owner, std::shared_ptr> contents) + : fs(owner), bytes(contents), valid(true) {} + operator bool() const { return valid; } + size_t write(const uint8_t*, size_t); + size_t size() const { return valid ? bytes->size() : 0; } + void close() { valid = false; } +}; +class MemoryFS { +public: + std::map>> files; + std::set faults; + bool short_write = false; + bool exists(const char* path) const { return files.count(path) != 0; } + bool remove(const char* path) { + if (faults.count(std::string("remove:") + path)) return false; + return files.erase(path) != 0; + } + bool rename(const char* from, const char* to) { + if (faults.count(std::string("rename:") + from + ":" + to) + || !exists(from) || exists(to)) return false; + files[to] = files[from]; files.erase(from); return true; + } + File open(const char* path, const char* mode = "r", bool = false) { + if (faults.count(std::string("open:") + mode + ":" + path)) return {}; + if (mode[0] == 'w') files[path] = std::make_shared>(); + if (!exists(path)) return {}; + return File(this, files[path]); + } + void put(const char* path, const std::vector& image) { + files[path] = std::make_shared>(image); + } + const std::vector& get(const char* path) const { return *files.at(path); } +}; +size_t File::write(const uint8_t* data, size_t count) { + if (!valid) return 0; + if (fs->short_write && count) --count; + bytes->insert(bytes->end(), data, data + count); + return count; +} +using FILESYSTEM = MemoryFS; +@STORE@; +class CommonCLI { +public: + bool recoverCommonPrefsFiles(FILESYSTEM*); +}; +@RECOVERY@ +const std::vector previous_image = {1, 2, 3, 4}; +const std::vector candidate_image = {9, 8, 7, 6, 5}; +bool save(MemoryFS& fs) { + CommonPrefsFileStore store(&fs); + return MQTTPrefsAtomicStore::imageCommitted(MQTTPrefsAtomicStore::writeImage( + store, [](CommonPrefsFileStore& out) { + return out.write(candidate_image.data(), candidate_image.size()) == candidate_image.size(); + })); +} +void assertRecoveredPrevious(MemoryFS& fs) { + CommonCLI cli; + fs.faults.clear(); fs.short_write = false; + assert(cli.recoverCommonPrefsFiles(&fs)); + assert(fs.get("/com_prefs") == previous_image); + assert(!fs.exists("/com_prefs.tmp") && !fs.exists("/com_prefs.bak")); + assert(save(fs)); + assert(cli.recoverCommonPrefsFiles(&fs)); + assert(fs.get("/com_prefs") == candidate_image); +} +int main() { + unsigned scenarios = 0; + for (const char* fault : {"open:w:/com_prefs.tmp", "open:r:/com_prefs.tmp", + "rename:/com_prefs:/com_prefs.bak", "rename:/com_prefs.tmp:/com_prefs"}) { + MemoryFS fs; fs.put("/com_prefs", previous_image); fs.faults.insert(fault); + assert(!save(fs)); assertRecoveredPrevious(fs); ++scenarios; + } + { MemoryFS fs; fs.put("/com_prefs", previous_image); fs.short_write = true; + assert(!save(fs)); assertRecoveredPrevious(fs); ++scenarios; } + for (bool restore_fails : {false, true}) { + for (bool discard_fails : {false, true}) { + MemoryFS fs; fs.put("/com_prefs", previous_image); + fs.faults.insert("rename:/com_prefs.tmp:/com_prefs"); + if (restore_fails) fs.faults.insert("rename:/com_prefs.bak:/com_prefs"); + if (discard_fails) fs.faults.insert("remove:/com_prefs.tmp"); + assert(!save(fs)); + if (restore_fails) { + CommonCLI cli; + assert(!cli.recoverCommonPrefsFiles(&fs)); + assert(!fs.exists("/com_prefs")); + assert(fs.get("/com_prefs.bak") == previous_image); + } + assertRecoveredPrevious(fs); ++scenarios; + } + } + { // Before a power cut publishes the candidate, backup remains authoritative. + MemoryFS fs; fs.put("/com_prefs.bak", previous_image); + fs.put("/com_prefs.tmp", candidate_image); + assertRecoveredPrevious(fs); ++scenarios; + } + { // After publication, cleanup failure cannot roll back the accepted image. + MemoryFS fs; fs.put("/com_prefs", previous_image); + fs.faults.insert("remove:/com_prefs.bak"); + assert(save(fs)); assert(fs.get("/com_prefs") == candidate_image); + fs.faults.clear(); CommonCLI cli; + assert(cli.recoverCommonPrefsFiles(&fs)); + assert(fs.get("/com_prefs") == candidate_image); + assert(!fs.exists("/com_prefs.bak")); ++scenarios; + } + { // A failed first save must not become a successful save on reboot. + MemoryFS fs; fs.faults.insert("rename:/com_prefs.tmp:/com_prefs"); + fs.faults.insert("remove:/com_prefs.tmp"); + assert(!save(fs)); fs.faults.clear(); CommonCLI cli; + assert(cli.recoverCommonPrefsFiles(&fs)); + assert(!fs.exists("/com_prefs") && !fs.exists("/com_prefs.tmp")); ++scenarios; + } + { // Recovery cleanup can fail without losing the previous image. + MemoryFS fs; fs.put("/com_prefs.bak", previous_image); + fs.put("/com_prefs.tmp", candidate_image); + fs.faults.insert("remove:/com_prefs.tmp"); CommonCLI cli; + assert(!cli.recoverCommonPrefsFiles(&fs)); + assert(fs.get("/com_prefs") == previous_image); + assertRecoveredPrevious(fs); ++scenarios; + } + printf("%u actual common preference commit/recovery scenarios passed\n", scenarios); +} +''' + + +class CommonPrefsCommitTest(unittest.TestCase): + def test_actual_store_commit_and_recovery(self): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8") + harness = HARNESS.replace("@STORE@", extract_braced(source, "class CommonPrefsFileStore")) + harness = harness.replace("@RECOVERY@", extract_braced( + source, "bool CommonCLI::recoverCommonPrefsFiles(")) + with tempfile.TemporaryDirectory(prefix="meshcore-common-commit-") as temp: + cpp = Path(temp) / "test.cpp" + exe = Path(temp) / ("test.exe" if os.name == "nt" else "test") + cpp.write_text(harness, encoding="utf-8") + subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", + "-Werror", "-I", str(ROOT / "src"), str(cpp), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py new file mode 100644 index 00000000..ed09398d --- /dev/null +++ b/test/test_common_radio_persistence.py @@ -0,0 +1,162 @@ +"""Exercise production infrastructure radio saves with real file transactions.""" +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include "ContactFileTransaction.h" +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#include +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define MIN_LORA_TX_POWER -9 +#define MAX_LORA_TX_POWER 22 +#include +struct NodePrefs { @FIELDS@ }; +void markDirectRetryPrefsValid(NodePrefs*) {} +bool isValidLoRaBandwidth(float bw) { return bw==62.5f || bw==125; } +namespace mesh { struct RadioProfiles { static constexpr unsigned MaxPreamble=65535; }; } +struct Profiles { + bool accepted=true; + uint16_t saved=48; + bool acceptsPrimary(float,float,uint8_t,uint8_t,uint16_t) const { return accepted; } + void adoptPrimaryPreamble(uint16_t value) { saved=value; } +}; +struct CommonCLI; +struct Callbacks { + CommonCLI* cli; + void savePrefs(PrefsSaveRouting::Scope scope); +}; +struct CommonCLI { + MemoryFS fs; + NodePrefs prefs{}; + NodePrefs* _prefs=&prefs; + Profiles _radio_profiles; + Callbacks callbacks{this}; + Callbacks* _callbacks=&callbacks; + bool _common_save_result_known=false, _common_save_succeeded=false; + CommonCLI() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs.rename_replaces=true; +#endif + prefs.freq=910; prefs.bw=62.5f; prefs.sf=7; prefs.cr=5; + prefs.tx_power_dbm=30; prefs.primary_radio_preamble=48; + prefs.rx_ps_rx_us=111; prefs.rx_ps_sleep_us=222; + } + void savePrefs(FILESYSTEM*,PrefsSaveRouting::Scope); + bool saveCommonPrefs(); + bool savePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t); + static void recalculateRxPowerSavingFromLevel(NodePrefs* p) { + p->rx_ps_rx_us=p->sf*100; p->rx_ps_sleep_us=p->cr*200; + } + uint16_t loadTail(std::vector bytes) { + fs.files["/tail"]=bytes; + File file=fs.open("/tail"); + @TAIL@ + return prefs.primary_radio_preamble; + } +}; +void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) { cli->savePrefs(&cli->fs,scope); } +@METHODS@ +@SERIALIZER@ +struct Capture { std::vector bytes; + size_t write(const uint8_t* p,size_t n) { bytes.insert(bytes.end(),p,p+n);return n; } +}; +int main() { + CommonCLI cli; + assert(cli.saveCommonPrefs()); + auto original=cli.fs.files["/com_prefs"]; + const size_t preamble_offset=original.size()-2; + assert(preamble_offset>=864); + assert(original[preamble_offset]==48 && original[preamble_offset+1]==0); + Capture capture; assert(writeCommonPrefsImage(capture,&cli.prefs)); + assert(capture.bytes==original); // Both serializers retain the same layout. + for (int fault : {0,1,2,3,4}) { + const NodePrefs previous=cli.prefs; + if(fault==0)cli.fs.fail_write=true; + if(fault==1)cli.fs.fail_write_after=100; + if(fault==2)cli.fs.fail_read_open=true; + if(fault==3)cli.fs.fail_rename=1; + if(fault==4)cli._radio_profiles.accepted=false; + assert(!cli.savePrimaryRadioParams(920,125,9,6,96)); + assert(cli.prefs.freq==previous.freq && cli.prefs.bw==previous.bw); + assert(cli.prefs.sf==previous.sf && cli.prefs.cr==previous.cr); + assert(cli.prefs.tx_power_dbm==previous.tx_power_dbm); + assert(cli.prefs.rx_ps_rx_us==previous.rx_ps_rx_us); + assert(cli.prefs.rx_ps_sleep_us==previous.rx_ps_sleep_us); + assert(cli.prefs.primary_radio_preamble==48 && cli._radio_profiles.saved==48); + assert(cli.fs.files["/com_prefs"]==original); + cli.fs.fail_write=false;cli.fs.fail_write_after=-1; + cli.fs.fail_read_open=false;cli.fs.fail_rename=0; + cli._radio_profiles.accepted=true; + } + assert(cli.savePrimaryRadioParams(920,125,9,6,96)); + auto committed=cli.fs.files["/com_prefs"]; + float freq=0,bw=0;memcpy(&freq,committed.data()+72,4);memcpy(&bw,committed.data()+116,4); + assert(freq==920 && bw==125 && committed[112]==9 && committed[113]==6); + assert(committed[preamble_offset]==96 && committed[preamble_offset+1]==0); + assert(cli._radio_profiles.saved==96 && cli.prefs.tx_power_dbm==22); + assert(cli.prefs.rx_ps_rx_us==900 && cli.prefs.rx_ps_sleep_us==1200); + // Old and torn tails preserve the imported legacy preamble; valid tails win. + assert(cli.loadTail({0})==96); + assert(cli.loadTail({0,32})==96); + assert(cli.loadTail({0,64,0})==64); + assert(cli.loadTail({0,7,0})==64); + assert(cli.loadTail({0,0,0})==0); + for(float bad : {NAN,INFINITY,149.0f,2501.0f}) + assert(!cli.savePrimaryRadioParams(bad,125,9,6,0)); + assert(cli.fs.files["/com_prefs"]==committed); +} +''' + + +class CommonRadioPersistenceTest(unittest.TestCase): + def test_production_transactions(self): + source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8') + header = (ROOT / 'src/helpers/CommonCLI.h').read_text(encoding='utf-8') + fields = header.split('class NodePrefs :', 1)[1].split('private:', 1)[0] + fields = '\n'.join(re.findall( + r'^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;', fields, re.M)) + fields = re.sub(r'\s*=\s*[^,;]+', '', fields) + for macro in set(re.findall(r'\bFLOOD_\w+', fields)): + value = re.search(r'#define\s+' + macro + r'\s+(\d+)', header).group(1) + fields = re.sub(r'\b'+macro+r'\b', value, fields) + methods = '\n'.join(extract_braced(source, signature) for signature in ( + 'void CommonCLI::savePrefs(FILESYSTEM*', 'bool CommonCLI::saveCommonPrefs(', + 'bool CommonCLI::savePrimaryRadioParams(')) + serializer = 'template\n' + extract_braced(source, 'static bool writeCommonPrefsImage(') + tail = extract_braced(source, 'if (file.available() >= (int)sizeof(_prefs->bridge_format))') + code = HARNESS.replace('@FIELDS@',fields).replace('@METHODS@',methods) + code = code.replace('@SERIALIZER@',serializer).replace('@TAIL@',tail) + with tempfile.TemporaryDirectory(prefix='common-radio-save-') as directory: + work = Path(directory) + transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text() + (work / 'ContactFileTransaction.h').write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) + (work / 'test.cpp').write_text(code, encoding='utf-8') + for platform in ('NRF52_PLATFORM','STM32_PLATFORM','ESP32_PLATFORM','RP2040_PLATFORM'): + with self.subTest(platform=platform): + exe = work / 'test' + built = subprocess.run(['g++','-std=c++17','-DENABLE_OTA=1','-D'+platform+'=1', + '-fsanitize=address,undefined','-fno-sanitize-recover=all','-fno-pie','-no-pie', + '-I'+str(work),'-I'+str(ROOT/'test/fixtures/radio_profiles/mocks'), + '-I'+str(ROOT/'src'),'-I'+str(ROOT/'src/helpers'), + str(work/'test.cpp'),'-o',str(exe)], + capture_output=True,text=True,timeout=60) + self.assertEqual(built.returncode,0,built.stderr) + tested = subprocess.run([str(exe)],capture_output=True,text=True,timeout=10) + self.assertEqual(tested.returncode,0,tested.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_companion_ota_config.py b/test/test_companion_ota_config.py new file mode 100644 index 00000000..cee2d1ad --- /dev/null +++ b/test/test_companion_ota_config.py @@ -0,0 +1,359 @@ +"""Run production OTA policy parsing, Companion commit/rollback, and filesystem storage.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced +import test_ota_heap_context as heap_test + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +static MemoryFS* mounted_fs=nullptr; +#if defined(ESP32_PLATFORM) +extern "C" int stat(const char* path, struct stat*) noexcept { + assert(mounted_fs && !strncmp(path, "/spiffs", 7)); + if (mounted_fs->stat_error) { errno=EIO; return -1; } + if (!mounted_fs->files.count(path+7)) { errno=ENOENT; return -1; } + return 0; +} +#endif +namespace mesh { namespace ota { +struct Manager { + uint8_t af=0, hops=3; uint16_t checkpoint=4, advert=1440; + uint8_t autofetch() const { return af; } + uint8_t max_hops() const { return hops; } + uint16_t checkpoint_blocks() const { return checkpoint; } + uint16_t advert_mins() const { return advert; } + void set_autofetch(uint8_t v) { af=v; } + void set_max_hops(uint8_t v) { hops=v; } + void set_checkpoint_blocks(uint16_t v) { checkpoint=v; } + void set_advert_mins(uint16_t v) { advert=v; } +}; +struct OtaContext { + static constexpr uint8_t AUTOINSTALL_OFF=0, AUTOINSTALL_TRUSTED=1; + Manager manager; SignerAllowlist allow; + uint8_t autoinstall=0; bool config_dirty=false; +}; +static OtaContext context; +static bool acquire_ok=true; +static bool ota_acquire_context(char* reply, size_t cap) { + if (!acquire_ok) snprintf(reply, cap, "ERR unavailable"); + return acquire_ok; +} +static OtaContext& ota_ctx() { return context; } +static OtaContext* ota_context_if_active() { return &context; } +static void formatSpeed(char* text, size_t) { strcpy(text, "1"); } +@IS_CMD@ +static bool config(const char* rest, char* reply, OtaContext& c) { + @CONFIG@ + return true; +} +static bool handle_ota_command(const char* command, char* reply, int) { + if (!strcmp(command, "ota key add test-key")) { + uint8_t key[32]={93}; + context.allow.add(key); context.config_dirty=true; + strcpy(reply, "OK key added (saved)"); + return true; + } + const char* rest; + if (!is_cmd(command + 4, "config|cfg|set", &rest)) return false; + return config(rest, reply, context); +} +} } +static bool companion(const char* command, char* reply, size_t reply_size) { + int board=0; + @WRAPPER@ + return false; +} +using namespace mesh::ota; +struct Common { + struct Prefs { + uint8_t ota_autofetch=0, ota_autoinstall=0, ota_max_hops=3, ota_signer_count=0; + uint16_t ota_checkpoint_blocks=4, ota_advert_interval=1440; + uint8_t ota_signers[MAX_OTA_SIGNERS][32]={}; + } prefs; + Prefs* _prefs=&prefs; + struct Callbacks { bool isTempRadioActive() const { return true; } } callbacks; + Callbacks* _callbacks=&callbacks; + struct Profiles { bool secondaryTemporary() const { return false; } } _radio_profiles; + int board=0; int* _board=&board; + bool save_ok=true; int saves=0; + bool otaCommandNeedsTempRadio(const char*) const { return false; } + bool saveCommonPrefs() { ++saves; return save_ok; } + void run(const char* command, char* reply) { @COMMON@ } +}; +static void reboot(MemoryFS& fs) { + context=OtaContext(); + mounted_fs=&fs; + beginCompanionOtaConfig(&fs); + OtaConfigState loaded; + if (loadCompanionOtaConfig(loaded)) loaded.apply(context); +} +int main() { + MemoryFS fs; + char reply[160]; + auto command = [&](const std::string& text, const char* expected) { + memset(reply, 0, sizeof reply); + assert(companion(text.c_str(), reply, sizeof reply)); + if (strncmp(reply, expected, strlen(expected))) + fprintf(stderr, "%s: expected %s, got %s\n", text.c_str(), expected, reply); + assert(!strncmp(reply, expected, strlen(expected))); + assert(!context.config_dirty); + }; + reboot(fs); + for (const auto* setting : {"hops", "checkpoint", "advert"}) { + const auto before=OtaConfigState::capture(context); + for (const auto* bad : {"", " ", "x", "-1", "1x", "1 2", "1.0", "4294967296", "9999999999999999999999"}) { + command(std::string("ota config ")+setting+" "+bad, "ERR"); + assert(context.manager.hops==before.hops && context.manager.checkpoint==before.checkpoint + && context.manager.advert==before.advert && fs.files.empty()); + } + command(std::string("ota config ")+setting, "ERR"); + } + command("ota config hops 9", "ERR"); + command("ota config advert 10081", "ERR"); + command("ota config checkpoint 4097", "ERR"); + command("ota config unknown 7", "ERR"); + for (const auto* bad : {"", "anymore", "signedx", "off extra"}) + command(std::string("ota config autofetch ")+bad, "ERR"); + for (const auto* bad : {"", "trustedx", "off extra"}) + command(std::string("ota config autoinstall ")+bad, "ERR"); + assert(fs.files.empty()); +#if defined(OTA_SEEDER_ONLY) + command("ota config autofetch any", "ERR"); + command("ota config autoinstall trusted", "ERR"); +#else + command("ota config autofetch signed", "OK"); + command("ota config autoinstall trusted", "OK"); +#endif + command("ota config hops 8", "OK"); + command("ota cfg advert 10080", "OK"); + command("ota set checkpoint 4096", "OK"); + uint8_t key[32]={42}; + context.allow.add(key); + assert(saveCompanionOtaConfig(OtaConfigState::capture(context))); + reboot(fs); + assert(context.manager.hops==8 && context.manager.advert==10080 + && context.manager.checkpoint==4096 && context.allow.contains(key)); +#if defined(OTA_SEEDER_ONLY) + assert(context.manager.af==0 && context.autoinstall==0); +#else + assert(context.manager.af==2 && context.autoinstall==1); +#endif + const auto good=fs.files["/ota_config"]; + fs.fail_write=true; + command("ota config hops 2", "ERR OTA settings save failed; settings unchanged"); + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + fs.fail_write=false; + fs.fail_write_after=10; + command("ota config hops 2", "ERR"); + fs.fail_write_after=-1; + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + fs.fail_rename=2; + command("ota config hops 2", "ERR"); + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + reboot(fs); + assert(context.manager.hops==8 && context.allow.contains(key)); + fs.fail_rename_from={"/ota_config.tmp", "/ota_config.bak"}; + command("ota config hops 2", "ERR"); + assert(context.manager.hops==8 && fs.files["/ota_config.bak"]==good); + command("ota config hops 2", "ERR"); // failed restoration is held + fs.fail_rename_from.clear(); + reboot(fs); + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + fs.files["/ota_config.bak"]=good; + fs.files.erase("/ota_config"); + reboot(fs); // reset between publication renames + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + fs.files["/ota_config.bak"]=good; + fs.files.erase("/ota_config"); + fs.fail_rename=1; + reboot(fs); // backup remains readable if repair is temporarily unavailable + assert(context.manager.hops==8 && fs.files["/ota_config.bak"]==good); + command("ota config hops 2", "ERR"); + reboot(fs); + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + fs.fail_read_open=true; + assert(!fs.exists("/ota_config")); // model ESP32's open-based exists() + command("ota config hops 2", "ERR"); + fs.fail_read_open=false; + command("ota config hops 2", "ERR"); // held until a clean reload + assert(context.manager.hops==8 && fs.files["/ota_config"]==good); + reboot(fs); + fs.stat_error=true; + command("ota config hops 2", "ERR"); + fs.stat_error=false; + command("ota config hops 2", "ERR"); + assert(fs.files["/ota_config"]==good && context.manager.hops==8); + reboot(fs); + command("ota config hops 0", "OK"); + command("ota config advert 0", "OK"); + command("ota config checkpoint 0", "OK"); + reboot(fs); + assert(context.manager.hops==0 && context.manager.advert==0 && context.manager.checkpoint==0); + fs.files["/ota_config"][4]^=1; + const auto damaged=fs.files["/ota_config"]; + reboot(fs); + command("ota config hops 7", "ERR"); + assert(fs.files["/ota_config"]==damaged); + fs.files.clear(); reboot(fs); + acquire_ok=false; + command("ota config hops 7", "ERR unavailable"); + assert(fs.files.empty()); + + acquire_ok=true; context=OtaContext(); + Common common; + context.allow.add(key); + common.run("ota config hops 7", reply); + assert(!strncmp(reply, "OK", 2) && common.saves==1); + assert(common.prefs.ota_max_hops==7 && common.prefs.ota_signer_count==1); + assert(context.manager.hops==7 && !context.config_dirty); + common.save_ok=false; + common.run("ota config hops 2", reply); + assert(!strncmp(reply, "ERR", 3) && common.saves==2); + assert(common.prefs.ota_max_hops==7 && context.manager.hops==7 && !context.config_dirty); + common.run("ota key add test-key", reply); + assert(!strncmp(reply, "ERR", 3) && common.saves==3); + assert(context.allow.count()==1 && common.prefs.ota_signer_count==1); + assert(!memcmp(common.prefs.ota_signers[0], key, 32)); + const uint8_t zero[32]={}; + assert(!memcmp(common.prefs.ota_signers[1], zero, 32)); + common.save_ok=true; + common.run("ota key add test-key", reply); + assert(!strncmp(reply, "OK", 2) && common.saves==4); + assert(context.allow.count()==2 && common.prefs.ota_signer_count==2); + common.run("ota config hops bad", reply); + assert(!strncmp(reply, "ERR", 3) && common.saves==4); +} +''' + + +class CompanionOtaConfigTest(unittest.TestCase): + def test_production_commands_reboot_and_transaction_failures(self): + cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + config = extract_braced(cli, 'if (is_cmd(a, "config|cfg|set", &rest))') + config = config[config.index("{") + 1:-1] + wrapper = extract_braced(companion, 'if (strncmp(command, "ota", 3) == 0') + common_wrapper = extract_braced(common, 'if (memcmp(command, "ota", 3) == 0') + # The closing brace is shared with the ENABLE_OTA fallback branch. + common_wrapper = common_wrapper.replace("#else", "") + source = (HARNESS.replace("@IS_CMD@", extract_braced(cli, "static bool is_cmd(")) + .replace("@CONFIG@", config).replace("@WRAPPER@", wrapper) + .replace("@COMMON@", common_wrapper)) + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory() as directory: + # Exercise production metadata probes independently from the + # deliberately open-based exists() API in this faulting backend. + mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text() + mock = mock.replace("bool fail_write = false;", "bool stat_error=false;\n bool fail_write = false;") + mock = mock.replace("bool exists(const char* path) const { return files.count(path); }", + "bool exists(const char* path) const { return !fail_read_open && files.count(path); }") + mock = mock.replace("bool mkdir(const char*)", "void _lockFS() {}\n void _unlockFS() {}\n" + " MemoryFS* _getFS() { return this; }\n" + " File open(const char* path, uint8_t mode) { return open(path, mode ? \"w\" : \"r\"); }\n" + " bool mkdir(const char*)") + mock += ("\nstruct lfs_info {};\nconstexpr int LFS_ERR_NOENT=-2;\n" + "inline int lfs_stat(MemoryFS* fs, const char* path, lfs_info*) {\n" + " if (fs->stat_error) return -5;\n" + " return fs->files.count(path) ? 0 : LFS_ERR_NOENT;\n}\n") + mock_dir = Path(directory) / "helpers" + mock_dir.mkdir() + (mock_dir / "IdentityStore.h").write_text(mock) + cpp = Path(directory) / "config.cpp" + cpp.write_text(source) + variants = [(platform, seeder, "-DCOMPANION_RADIO_FULL=1") + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM") + for seeder in ([], ["-DOTA_SEEDER_ONLY=1"])] + variants.append(("ESP32_PLATFORM", [], "-DCOMPANION_FEATURE_OTA_CLI=1")) + for platform, seeder, capability in variants: + with self.subTest(platform=platform, seeder=seeder, capability=capability): + binary = Path(directory) / "config" + compiled = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-D"+platform+"=1", *seeder, + "-DENABLE_OTA=1", capability, "-I", directory, + "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"), + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(ROOT / "src/helpers/ota/CompanionOtaConfig.cpp"), + str(cpp), "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + ran = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(ran.returncode, 0, ran.stderr) + + def test_static_and_dynamic_load_wiring_does_not_allocate_on_registration(self): + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + context = (ROOT / "src/helpers/ota/OtaContext.cpp").read_text() + self.assertIn("ota_set_context_config_loader(mesh::ota::loadCompanionOtaConfig)", companion) + loader = extract_braced(context, "void ota_set_context_config_loader(") + self.assertIn("ota_context_if_active()", loader) + self.assertNotIn("ota_acquire_context", loader) + acquire = extract_braced(context, "bool ota_acquire_context(") + self.assertIn("context_config_loader(restored)", acquire) + self.assertIn("restored.apply(c)", acquire) + + def test_executable_dynamic_loader_and_seeder_policy(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + source = path / "loader.cpp" + source.write_text(r''' +#include +#include +using namespace mesh::ota; +namespace mesh { namespace ota { +bool ota_self_firmware(SelfFwInfo& info) { info=SelfFwInfo(); return false; } +} } +static int calls=0; +static bool readable=true; +static OtaConfigState disk; +static bool load(OtaConfigState& restored) { + ++calls; + if (!readable) return false; + restored=disk; + return true; +} +static bool send(void*, const uint8_t*, uint16_t, bool) { return true; } +int main() { + disk.hops=7; disk.advert=17; disk.checkpoint=32; + disk.autofetch=2; disk.autoinstall=1; + uint8_t key[32]={71}; disk.allow.add(key); + ota_set_context_config_loader(load); + assert(calls==0 && !ota_context_if_active()); // no eager heap or queue claim + ota_begin_context(123, send, nullptr, "test", nullptr); + for (int i=0; i<3; ++i) { + assert(ota_acquire_context(nullptr, 0)); + assert(calls==i+1 && ota_ctx().manager.max_hops()==7); + assert(ota_ctx().manager.advert_mins()==17 && ota_ctx().manager.checkpoint_blocks()==32); + assert(ota_ctx().allow.contains(key)); + assert(ota_ctx().manager.autofetch()==0 && ota_ctx().autoinstall==0); + ota_release_context_if_idle(false); + assert(!ota_context_if_active()); + } + assert(ota_acquire_context(nullptr, 0)); + disk.hops=2; + ota_set_context_config_loader(load); // existing/static-context registration path + assert(ota_ctx().manager.max_hops()==2); + readable=false; + ota_set_context_config_loader(load); + assert(ota_ctx().manager.max_hops()==2); // failed read does not partly apply defaults + ota_release_context_if_idle(false); + ota_set_context_config_loader(nullptr); +} +''') + heap_test.OtaHeapTest().compile_and_run(path, source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_tx_routing.py b/test/test_companion_tx_routing.py index b3036143..1b6da24c 100644 --- a/test/test_companion_tx_routing.py +++ b/test/test_companion_tx_routing.py @@ -44,6 +44,7 @@ struct Radio { mesh::RadioProfiles config; mesh::RadioProfiles* profiles() { ret struct DataStore { FILESYSTEM fs; bool _channel_load_incomplete=false; + bool _uncached_contact_load_incomplete=false; bool hasIncompleteContactLoad() const; FILESYSTEM* _getContactsChannelsFS() { return &fs; } File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); } diff --git a/test/test_companion_uncached_storage.py b/test/test_companion_uncached_storage.py new file mode 100644 index 00000000..ee3ebafc --- /dev/null +++ b/test/test_companion_uncached_storage.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Run the uncached Companion persistence paths against faulting filesystems.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class CompanionUncachedStorageTest(unittest.TestCase): + def test_esp32_uncached_storage(self): + self.run_platform("ESP32_PLATFORM") + + def test_stm32_uncached_storage(self): + self.run_platform("STM32_PLATFORM") + + def test_rp2040_uncached_storage(self): + self.run_platform("RP2040_PLATFORM") + + def run_platform(self, platform): + source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + signatures = ( + "static bool companionPathPresence(", + "bool DataStore::loadMainIdentity(", + "bool DataStore::canCreateMainIdentity() const", + "bool DataStore::saveMainIdentity(", + "static bool deserializeContactRecord(", + "void DataStore::loadContacts(", + "bool DataStore::saveContacts(", + "bool DataStore::hasIncompleteContactLoad() const", + ) + packet = (ROOT / "src/Packet.cpp").read_text() + generated = "namespace mesh {\n" + extract_braced( + packet, "bool Packet::isValidPathLen(") + "\n}\n" + generated += "\n".join(extract_braced(source, s) for s in signatures) + with tempfile.TemporaryDirectory(prefix="mesh-uncached-store-") as temp: + temp = Path(temp) + (temp / "store_under_test.h").write_text(generated) + # IdentityStore.h only needs the platform filesystem declaration; + # the concrete mock below implements the same file API. + (temp / "FS.h").write_text( + "#pragma once\nnamespace fs { using FS = FakeFilesystem; }\n" + ) + binary = temp / "test" + compiled = subprocess.run([ + "c++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", + "-Werror", "-Wno-unused-parameter", "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + "-DMESH_CONTACT_CACHE=0", "-D" + platform + "=1", + "-I", str(temp), + "-I", str(ROOT / "test/fixtures/contact_cache/mocks"), + "-I", str(ROOT / "src"), + "-I", str(ROOT / "lib/ed25519"), + str(ROOT / "test/fixtures/companion_uncached_storage/test.cpp"), + "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp index 4c50d84c..f3786975 100644 --- a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp +++ b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp @@ -640,10 +640,10 @@ TEST(MQTTPrefsAtomicStore, RecoveryNeverOverwritesOpaqueNewerLayout) { Recovery::FileState::Preserve)); } -TEST(MQTTPrefsAtomicStore, CommonPrefsRecoveryUsesOnlyVerifiedTempWithBackup) { +TEST(MQTTPrefsAtomicStore, CommonPrefsRecoveryPreservesLastPublishedImage) { EXPECT_EQ(CommonRecovery::Action::KeepPrimary, CommonRecovery::select(true, true, true)); - EXPECT_EQ(CommonRecovery::Action::PromoteTemp, + EXPECT_EQ(CommonRecovery::Action::PromoteBackup, CommonRecovery::select(false, true, true)); EXPECT_EQ(CommonRecovery::Action::PromoteBackup, CommonRecovery::select(false, false, true)); diff --git a/test/test_nrf52_extrafs_contract.py b/test/test_nrf52_extrafs_contract.py index d8d28ec4..a11b8805 100644 --- a/test/test_nrf52_extrafs_contract.py +++ b/test/test_nrf52_extrafs_contract.py @@ -719,9 +719,10 @@ class Nrf52ExtraFsContractTest(unittest.TestCase): save_channels = function_body( store, "bool DataStore::saveChannels(DataStoreHost* host)" ) + self.assertIn("bool& incomplete = _contact_load_incomplete;", load_channels) self.assertLess( - load_channels.index("if (_contact_load_incomplete)"), - load_channels.index('openRead(_getContactsChannelsFS(), "/channels2")'), + load_channels.index("if (incomplete) return;"), + load_channels.index('openRead(contacts_fs, "/channels2")'), ) self.assertLess( save_channels.index("if (_contact_load_incomplete) return false;"), diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 14c1f91e..3819c8b4 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -37,26 +37,67 @@ uint32_t millis() { return now_ms; } namespace mesh { struct Packet {}; int clampLoRaTxPower(int power, float) { return power; } +namespace lr2021 { +bool storedSideDetectorCount(const uint8_t*, uint8_t& count) { count = 0; return true; } +bool validateSideDetectorSFs(const uint8_t*, uint8_t, uint8_t, float) { return true; } +} } struct CommonCLI { template static void recalculateRxPowerSavingFromLevel(T*) {} }; uint32_t nextRadioApplyRetryDelay(uint8_t& failures) { ++failures; return 100; } struct RadioDriver { - void setRxBoostedGainMode(bool) {} - void setTxPower(int) {} + bool gain_supported = true, gain_success = true, power_success = true; + bool side_detector_success = true; + unsigned gain_calls = 0, power_calls = 0; + bool active_gain = false; + int active_power = 0; + bool supportsRxBoostedGainMode() const { return gain_supported; } + bool setRxBoostedGainMode(bool gain) { + ++gain_calls; + if (!gain_success) return false; + active_gain = gain; + return true; + } + bool setTxPower(int power) { + ++power_calls; + if (!power_success) return false; + active_power = power; + return true; + } + bool configSideDetectors(const uint8_t*, uint8_t, float) { return side_detector_success; } } radio_driver; struct RTC { uint32_t now = 100000; uint32_t getCurrentTime() const { return now; } }; +struct RadioPrefs { + bool rx_watchdog_enabled = true, rx_boosted_gain = true; + uint8_t advert_interval = 60, flood_advert_interval = 24, path_hash_mode = 0; + float freq = 909.5f, bw = 62.5f; + uint8_t sf = 7, cr = 5; + uint8_t extra_sf[4] = {}; + int tx_power_dbm = 20; +}; struct Board { int reboots = 0; void reboot() { ++reboots; } }; struct CLI { Board board; Board* getBoard() { return &board; } CLI& radioProfiles() { return *this; } - bool preamble_save_success = true; - unsigned preamble_saves = 0; - bool savePrimaryPreamble(uint16_t) { ++preamble_saves; return preamble_save_success; } + RadioPrefs* prefs = nullptr; + int* saves = nullptr; + bool save_success = true; + unsigned save_attempts = 0; + unsigned fail_on_save = 0; + uint16_t saved_preamble = 0; + bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) { + ++save_attempts; + if (!save_success || save_attempts == fail_on_save) return false; + ++*saves; + prefs->freq = freq; prefs->bw = bw; prefs->sf = sf; prefs->cr = cr; + saved_preamble = preamble; + return true; + } + uint16_t primaryPreamble() const { return saved_preamble; } bool hasReplyMutation() const { return false; } bool finishReplyMutation(bool) { return true; } }; @@ -72,13 +113,7 @@ struct ScheduledRadioSetting { }; class MyMesh { public: - struct Prefs { - bool rx_watchdog_enabled = true, rx_boosted_gain = true; - uint8_t advert_interval = 60, flood_advert_interval = 24, path_hash_mode = 0; - float freq = 909.5f, bw = 62.5f; - uint8_t sf = 7, cr = 5; - int tx_power_dbm = 20; - } _prefs; + RadioPrefs _prefs; CLI _cli; Radio radio; Radio* _radio = &radio; uint32_t last_meshcore_rx = 0; @@ -108,14 +143,20 @@ public: int saves = 0, applies = 0, restores = 0, local_adverts = 0, flood_adverts = 0; int default_scope = 0; mesh::Packet packet; - MyMesh() { now_ms = 1; updateAdvertTimer(); updateFloodAdvertTimer(); } + MyMesh() { + _cli.prefs = &_prefs; _cli.saves = &saves; + radio_driver = {}; now_ms = 1; updateAdvertTimer(); updateFloodAdvertTimer(); + } RTC* getRTCClock() const { return const_cast(&rtc); } uint32_t futureMillis(uint32_t delay) const { return now_ms + delay; } bool millisHasNowPassed(uint32_t deadline) const { return (int32_t)(now_ms - deadline) >= 0; } bool hasOutbound() const { return outbound; } bool hasStartedScheduledTempRadio() const { return scheduled_temp_radio_started; } - bool applyRadioParams(float, float, uint8_t, uint8_t, uint16_t = 0, bool = false) { ++applies; return apply_success; } - bool applySavedRadioParams() { ++restores; return restore_success; } + bool applyRadioParams(float, float, uint8_t, uint8_t, uint16_t = 0, bool temporary = false) { + if (temporary) { ++applies; return apply_success; } + ++restores; return restore_success; + } + bool applySavedRadioParams(); bool isValidScheduledRadioParams(float, float, uint8_t, uint8_t) { return true; } void savePrefs() { ++saves; } int getScheduledRadioSettingIndex(bool, int slot) const { return slot + 1; } @@ -162,6 +203,72 @@ static void startTemp(MyMesh& m, int minutes) { assert(m.temp_radio_applied); } int main() { + for (unsigned failure = 0; failure < +#ifdef USE_LR2021 + 4 +#else + 3 +#endif + ; ++failure) { + // A failure at any independent hardware step retains the saved recovery + // work and its backoff until the complete configuration succeeds. + MyMesh m; + if (failure == 0) radio_driver.gain_success = false; + if (failure == 1) m.restore_success = false; + if (failure == 2) radio_driver.power_success = false; + if (failure == 3) radio_driver.side_detector_success = false; + m.queueSavedRadioApply(); + m.processScheduledRadioSettings(); + assert(m.saved_radio_apply_pending && m.scheduled_radio_retry_at); + const unsigned gain_calls = radio_driver.gain_calls; + const unsigned power_calls = radio_driver.power_calls; + const unsigned restores = m.restores; + for (unsigned i = 0; i < 10; ++i) m.processScheduledRadioSettings(); + assert(radio_driver.gain_calls == gain_calls); + assert(radio_driver.power_calls == power_calls && m.restores == (int)restores); + radio_driver.gain_success = radio_driver.power_success = true; + radio_driver.side_detector_success = true; + m.restore_success = true; + m.advance(1); m.processScheduledRadioSettings(); + assert(!m.saved_radio_apply_pending && !m.scheduled_radio_retry_at); + assert(radio_driver.active_gain == m._prefs.rx_boosted_gain); + assert(radio_driver.active_power == m._prefs.tx_power_dbm); + } + { // A later failed due entry must not undo an earlier durable change. + MyMesh m; + char reply[160]; + m.addScheduledRadioParams(false, 911, 250, 5, 5, m.rtc.now + 2, 0, reply, 48); + m.addScheduledRadioParams(false, 912, 125, 6, 6, m.rtc.now + 3, 0, reply, 80); + m._cli.fail_on_save = 2; + m.advance(3); m.servicePostMeshLoop(); + assert(m._cli.save_attempts == 2 && m.saves == 1); + assert(m.countScheduledRadioSettings(false) == 1); + assert(m._prefs.freq == 911 && m._cli.saved_preamble == 48); + assert(!m.saved_radio_apply_pending); + m.advance(60); m.servicePostMeshLoop(); + assert(m._cli.save_attempts == 3 && m.saves == 2); + assert(m.countScheduledRadioSettings(false) == 0); + assert(m._prefs.freq == 912 && m._cli.saved_preamble == 80); + assert(!m.saved_radio_apply_pending); + } + { // Radios without boosted gain must not become stuck in recovery. + MyMesh m; + radio_driver.gain_supported = false; + radio_driver.gain_success = false; + m.queueSavedRadioApply(); + m.processScheduledRadioSettings(); + assert(!m.saved_radio_apply_pending && radio_driver.gain_calls == 0); + assert(radio_driver.active_power == m._prefs.tx_power_dbm); + } + { // Boot uses the same complete apply result to arm recovery. + MyMesh m; + radio_driver.power_success = false; + m.saved_radio_apply_pending = !m.applySavedRadioParams(); + assert(m.saved_radio_apply_pending); + radio_driver.power_success = true; + m.processScheduledRadioSettings(); + assert(!m.saved_radio_apply_pending); + } { // Expiring one scheduled lease must retain a later temporary entry. MyMesh m; char reply[160]; @@ -182,31 +289,33 @@ int main() { char reply[160]; m.addScheduledRadioParams(false, 912.5, 250, 5, 5, m.rtc.now + 2, 0, reply, 48); assert(!strncmp(reply, "OK", 2)); - m._cli.preamble_save_success = false; + m._cli.save_success = false; m.advance(2); m.servicePostMeshLoop(); - assert(m._cli.preamble_saves == 1 && m.saves == 0); + assert(m._cli.save_attempts == 1 && m.saves == 0); assert(m.next_scheduled_radio_check_at == m.scheduled_radio_save_retry_at); - assert(m._prefs.freq == 909.5f); + assert(m._prefs.freq == 909.5f && m._cli.saved_preamble == 0); + assert(m.countScheduledRadioSettings(false) == 1); for (unsigned i = 0; i < 10; ++i) m.servicePostMeshLoop(); - assert(m._cli.preamble_saves == 1); // storage backoff survives the scheduler's cleanup - m._cli.preamble_save_success = true; + assert(m._cli.save_attempts == 1); // storage backoff survives the scheduler's cleanup + m._cli.save_success = true; m.advance(59); m.servicePostMeshLoop(); - assert(m._cli.preamble_saves == 1); + assert(m._cli.save_attempts == 1); m.advance(1); m.servicePostMeshLoop(); - assert(m._cli.preamble_saves == 2 && m.saves == 1); + assert(m._cli.save_attempts == 2 && m.saves == 1); assert(m._prefs.freq == 912.5f && !m.saved_radio_apply_pending); + assert(m._cli.saved_preamble == 48 && m.countScheduledRadioSettings(false) == 0); } { // Backing off a failed permanent save must not delay temporary start/end. MyMesh m; char reply[160]; m.addScheduledRadioParams(false, 912.5, 250, 5, 5, m.rtc.now + 2, 0, reply, 48); m.addScheduledRadioParams(true, 911.5, 250, 5, 5, m.rtc.now + 30, m.rtc.now + 90, reply, 80); - m._cli.preamble_save_success = false; + m._cli.save_success = false; m.advance(2); m.servicePostMeshLoop(); - assert(m._cli.preamble_saves == 1); + assert(m._cli.save_attempts == 1); m.advance(28); m.servicePostMeshLoop(); assert(m.temp_radio_applied && m.radio_timing.isTemporary()); - assert(m._cli.preamble_saves == 1); + assert(m._cli.save_attempts == 1); m.advance(60); m.servicePostMeshLoop(); assert(!m.temp_radio_applied && !m.radio_timing.isTemporary()); assert(m._prefs.freq == 909.5f); @@ -427,6 +536,7 @@ class RepeaterRadioTimingIntegrationTest(unittest.TestCase): "void MyMesh::updateAdvertTimer()", "void MyMesh::updateFloodAdvertTimer()", "void MyMesh::queueSavedRadioApply()", + "bool MyMesh::applySavedRadioParams()", "void MyMesh::refreshScheduledRadioState()", "void MyMesh::processScheduledRadioSettings()", "void MyMesh::applyTempRadioParams(", @@ -442,7 +552,10 @@ class RepeaterRadioTimingIntegrationTest(unittest.TestCase): # The remaining service tail handles unrelated MQTT/OTA/peripheral work. post = post[:post.index("#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)")] + "}\n" methods += "\n" + post - self.compile_and_run(HARNESS.replace("@METHODS@", methods)) + for lr2021 in (False, True): + with self.subTest(lr2021=lr2021): + defines = "#define USE_LR2021 1\n" if lr2021 else "" + self.compile_and_run(defines + HARNESS.replace("@METHODS@", methods)) def test_production_cli_replies_and_duration_validation(self): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8")