Harden storage transactions and deferred radio settings

This commit is contained in:
mikecarper committed 2026-09-15 16:01:46 -07:00
1 parent 8f09daaad8
commit b4315dabdb
26 files changed
+1770 -153

No files matched your search

+4
View File
@@ -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
+134 -18
View File
@@ -6,6 +6,10 @@
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#include <helpers/ContactFileTransaction.h>
#endif
#if defined(ESP32_PLATFORM)
#include <errno.h>
#include <sys/stat.h>
#endif
#if COMPANION_FEATURE_JOHN
#include <helpers/bible/JohnBookmarkFiles.h>
#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<size_t>(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;
+4 -1
View File
@@ -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];
+19
View File
@@ -62,6 +62,8 @@
#if COMPANION_FEATURE_OTA_CLI
#include <helpers/ota/OtaCli.h>
#include <helpers/ota/OtaContext.h>
#include <helpers/ota/CompanionOtaConfig.h>
#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;
}
+10 -23
View File
@@ -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);
+102 -56
View File
@@ -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 <RTClib.h>
#include <ctype.h>
@@ -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);
+8
View File
@@ -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,
+1 -2
View File
@@ -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;
+3
View File
@@ -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,
+160
View File
@@ -0,0 +1,160 @@
#if defined(ENABLE_OTA) && (defined(COMPANION_RADIO_FULL) || COMPANION_FEATURE_OTA_CLI)
#include "CompanionOtaConfig.h"
#include <helpers/PersistentStoreFormat.h>
#include <stdio.h>
#if defined(ESP32_PLATFORM)
#include <errno.h>
#include <sys/stat.h>
#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<size_t>(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
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <helpers/IdentityStore.h>
#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);
} }
+24 -23
View File
@@ -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 <off|any|signed>"); 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 <off|trusted>"); 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));
+41
View File
@@ -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 <typename Context> 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 <typename Context> 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;
}
};
} }
+14
View File
@@ -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;
}
+3
View File
@@ -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);
+5
View File
@@ -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@
+318
View File
@@ -0,0 +1,318 @@
#include <algorithm>
#include <cassert>
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <limits>
#include <map>
#include <string>
#include <vector>
#include <Arduino.h>
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<std::string, std::vector<uint8_t>>;
Files files;
size_t max_write = std::numeric_limits<size_t>::max();
size_t max_read = std::numeric_limits<size_t>::max();
std::string fail_open;
int stat_error = 0;
unsigned fail_rename = 0, renames = 0, writes = 0;
std::vector<Files> 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<bool>(*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 <FS.h>
#define FILESYSTEM fs::FS
#else
#define FILESYSTEM FakeFilesystem
#endif
#include <helpers/ContactInfo.h>
#include <helpers/PersistentStoreFormat.h>
#include <helpers/ContactFileTransaction.h>
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
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<ContactInfo> 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<uint8_t>(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<size_t>::max();
filesystem.stat_error = 0;
filesystem.files[identity_path].resize(96);
DataStore rebooted;
assert(rebooted.loadMainIdentity(identity));
assert(rebooted.canCreateMainIdentity());
}
}
static std::vector<uint8_t> 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();
}
+5
View File
@@ -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.
+170
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
#include <helpers/CommonPrefsRecovery.h>
#include <helpers/MQTTPrefsAtomicStore.h>
class MemoryFS;
class File {
MemoryFS* fs = nullptr;
std::shared_ptr<std::vector<uint8_t>> bytes;
bool valid = false;
public:
File() = default;
File(MemoryFS* owner, std::shared_ptr<std::vector<uint8_t>> 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<std::string, std::shared_ptr<std::vector<uint8_t>>> files;
std::set<std::string> 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<std::vector<uint8_t>>();
if (!exists(path)) return {};
return File(this, files[path]);
}
void put(const char* path, const std::vector<uint8_t>& image) {
files[path] = std::make_shared<std::vector<uint8_t>>(image);
}
const std::vector<uint8_t>& 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<uint8_t> previous_image = {1, 2, 3, 4};
const std::vector<uint8_t> 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()
+162
View File
@@ -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 <cassert>
#include <math.h>
#include <cstring>
#include <helpers/IdentityStore.h>
#include "ContactFileTransaction.h"
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#include <helpers/PrefsSaveRouting.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
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<uint8_t> 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<uint8_t> 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<typename Writer>\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 <helpers/IdentityStore.h>'))
(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()
+359
View File
@@ -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 <cassert>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <sys/stat.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/ota/CompanionOtaConfig.h>
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 <helpers/ota/OtaContext.h>
#include <cassert>
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()
+1
View File
@@ -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"); }
+65
View File
@@ -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()
@@ -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));
+3 -2
View File
@@ -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;"),
+139 -26
View File
@@ -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 <typename T> 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*>(&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")