mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 06:22:48 +00:00
Harden storage transactions and deferred radio settings
This commit is contained in:
1 parent
8f09daaad8
commit
b4315dabdb
26 files changed
+1770
-153
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
} }
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+5
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user