Save an opt-in off/no/yes answer to private Am I lost questions from known chat contacts. Keep reply traffic and ACK state bounded, preserve pending user delivery, and expose the setting through local CLI and ESP32 WebConfig. Retain compact STM32 application/filesystem boundaries, qualify the Tag BLE queue allowance, and exercise saved-image compatibility and browser controls. Fix stale room mailbox protocol dependencies in the ACL/route test fixtures. Validation: ten Companion builds, sanitized production-path regressions, preference fault/compatibility tests, browser tests, and 86 capacity/build recipe checks passed. No live radio was flashed for this feature.
352 lines
16 KiB
Python
352 lines
16 KiB
Python
"""Exercise real Companion preferences saves and startup recovery with I/O faults."""
|
|
from pathlib import Path
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_radio_receive_contract import method
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
HARNESS = r'''
|
|
#include <cassert>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <new>
|
|
#include <cerrno>
|
|
#include <helpers/IdentityStore.h>
|
|
#include "ContactFileTransaction.h"
|
|
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
|
|
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
|
|
#include <helpers/AtomicFileWriter.h>
|
|
#endif
|
|
#include "examples/companion_radio/NodePrefs.h"
|
|
#include "examples/companion_radio/PrefsStorageLayout.h"
|
|
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
|
struct DataStore {
|
|
MemoryFS fs;
|
|
FILESYSTEM* _fs=&fs;
|
|
bool _prefs_load_incomplete=false, _primary_storage_unavailable=false;
|
|
bool _secondary_authority_unknown=false;
|
|
const char* _prefs_recovery_source=nullptr;
|
|
DataStore(){
|
|
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
|
|
fs.rename_replaces=true;
|
|
#endif
|
|
}
|
|
bool loadPrefs(CompanionNodePrefs&, double&, double&);
|
|
bool loadPrefsInt(const char*, CompanionNodePrefs&, double&, double&);
|
|
bool savePrefs(const CompanionNodePrefs&, double, double);
|
|
};
|
|
File openRead(FILESYSTEM* fs,const char* path){return fs->open(path,"r");}
|
|
File openWrite(FILESYSTEM* fs,const char* path){return fs->open(path,"w");}
|
|
bool contactPathPresence(FILESYSTEM* fs,const char* path,bool& present){
|
|
present=fs->exists(path);return true;
|
|
}
|
|
@METHODS@
|
|
int main(int argc,char** argv){
|
|
const int scenario=argc>1?atoi(argv[1]):0;
|
|
DataStore store;CompanionNodePrefs original;
|
|
strcpy(original.node_name,"durable node");original.freq=910.525f;
|
|
original.ble_pin=876543;original.bluetooth_stealth_mode=2;
|
|
original.gps_interval=123;original.sf=7;original.cr=7;original.bw=62.5f;
|
|
original.tx_delay_factor=1.25f;original.direct_tx_delay_factor=0.75f;
|
|
original.interference_threshold=8;original.agc_reset_interval=15;original.tz_offset=-7;
|
|
original.flood_retry_attempts=4;original.flood_retry_max_path=3;
|
|
original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0;
|
|
original.one_key_dm_enabled=1;
|
|
original.gps_sync_interval_hours=336;
|
|
original.usb_debug_enabled=1;
|
|
original.lost_reply=2;
|
|
#ifdef TBEAM_1W
|
|
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
|
|
#endif
|
|
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
strcpy(original.wifi_ssid,"test-network");strcpy(original.wifi_pwd,"test-password");
|
|
#endif
|
|
assert(store.savePrefs(original,47.1,-122.2));
|
|
const auto disk=store.fs.files["/new_prefs"];
|
|
CompanionNodePrefs changed=original;changed.freq=910.1f;
|
|
if(scenario==0){
|
|
store.fs.fail_write=true;
|
|
assert(!store.savePrefs(changed,42.3,-121.2));
|
|
assert(store.fs.files["/new_prefs"]==disk);
|
|
store.fs.fail_write=false;store.fs.fail_write_after=17;
|
|
assert(!store.savePrefs(changed,42.3,-121.2));
|
|
assert(store.fs.files["/new_prefs"]==disk);
|
|
store.fs.fail_write_after=-1;
|
|
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
|
|
const int rename_steps[]={1};
|
|
#else
|
|
const int rename_steps[]={1,2};
|
|
#endif
|
|
for(int fail_step : rename_steps){
|
|
store.fs.fail_rename=fail_step;
|
|
assert(!store.savePrefs(changed,42.3,-121.2));
|
|
assert(store.fs.files["/new_prefs"]==disk);
|
|
}
|
|
assert(store.savePrefs(changed,42.3,-121.2));
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
assert(store.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.freq==changed.freq&&loaded.ble_pin==original.ble_pin);
|
|
assert(loaded.tx_delay_factor==1.25f&&loaded.direct_tx_delay_factor==0.75f);
|
|
assert(loaded.interference_threshold==8&&loaded.agc_reset_interval==15&&loaded.tz_offset==-7);
|
|
assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3);
|
|
assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0);
|
|
assert(loaded.one_key_dm_enabled==1);
|
|
assert(loaded.gps_sync_interval_hours==336);
|
|
assert(loaded.usb_debug_enabled==1);
|
|
assert(loaded.lost_reply==2);
|
|
#ifdef TBEAM_1W
|
|
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
|
|
#endif
|
|
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
assert(!strcmp(loaded.wifi_ssid,"test-network")&&!strcmp(loaded.wifi_pwd,"test-password"));
|
|
#endif
|
|
assert(lat==42.3&&lon==-121.2);
|
|
} else if(scenario==1){
|
|
for(int fault : {0,1,2,3,4}){
|
|
for(const char* path : {"/new_prefs","/node_prefs"}){
|
|
DataStore boot;boot.fs.files[path]=disk;
|
|
CompanionNodePrefs live;double lat=1,lon=2;
|
|
if(fault==0)boot.fs.fail_read_open=true;
|
|
if(fault==1)boot.fs.fail_read_after=0;
|
|
if(fault==2)boot.fs.fail_read_after=84;
|
|
if(fault==3)boot.fs.fail_read_after=214;
|
|
if(fault==4)boot.fs.files[path].resize(87);
|
|
const auto durable=boot.fs.files[path];
|
|
#if defined(ESP32_PLATFORM)
|
|
assert(boot.loadPrefs(live,lat,lon)); // unrecoverable settings permit defaults
|
|
#else
|
|
assert(!boot.loadPrefs(live,lat,lon));
|
|
#endif
|
|
assert(live.node_name[0]==0&&lat==1&&lon==2);
|
|
assert(boot.fs.files[path]==durable); // no destructive read-side reset
|
|
boot.fs.fail_read_after=-1;boot.fs.fail_read_open=false;
|
|
#if defined(ESP32_PLATFORM)
|
|
assert(boot.savePrefs(live,lat,lon));
|
|
assert(boot.fs.files["/new_prefs"]!=disk);
|
|
#else
|
|
assert(!boot.loadPrefs(live,lat,lon)); // quarantine lasts this boot
|
|
assert(!boot.savePrefs(live,lat,lon));
|
|
assert(boot.fs.files[path]==durable);
|
|
#endif
|
|
}
|
|
}
|
|
DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0;
|
|
assert(fresh.loadPrefs(defaults,lat,lon));
|
|
assert(defaults.wifi_enabled==0); // Fresh installs never enable WiFi implicitly.
|
|
assert(fresh.savePrefs(defaults,lat,lon));
|
|
} else if(scenario==2){
|
|
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
|
|
store.fs.fail_rename_from={"/new_prefs.tmp","/new_prefs.bak"};
|
|
assert(!store.savePrefs(changed,42.3,-121.2));
|
|
assert(!store.fs.exists("/new_prefs"));
|
|
assert(store.fs.files["/new_prefs.bak"]==disk);
|
|
DataStore failed_boot;failed_boot.fs=store.fs;
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
#if defined(ESP32_PLATFORM)
|
|
assert(failed_boot.loadPrefs(loaded,lat,lon)); // verified backup is usable in RAM
|
|
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
|
|
assert(!failed_boot.savePrefs(loaded,lat,lon)); // preserve source while rename fails
|
|
assert(failed_boot.fs.files["/new_prefs.bak"]==disk);
|
|
failed_boot.fs.fail_rename_from.clear();
|
|
assert(failed_boot.savePrefs(loaded,lat,lon));
|
|
#else
|
|
assert(!failed_boot.loadPrefs(loaded,lat,lon));
|
|
failed_boot.fs.fail_rename_from.clear();
|
|
assert(!failed_boot.loadPrefs(loaded,lat,lon));
|
|
assert(!failed_boot.savePrefs(changed,42.3,-121.2));
|
|
assert(failed_boot.fs.files["/new_prefs.bak"]==disk);
|
|
#endif
|
|
DataStore reboot;reboot.fs=failed_boot.fs;
|
|
assert(reboot.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
|
|
assert(lat==47.1&&lon==-122.2);
|
|
assert(loaded.lost_reply==2);
|
|
#endif
|
|
} else if(scenario==3){
|
|
// Copy the real adapter-bearing preferences from dirty storage. This
|
|
// catches reads of uninitialized serializer bools under UBSan reliably.
|
|
alignas(CompanionNodePrefs) unsigned char memory[sizeof(CompanionNodePrefs)];
|
|
memset(memory,0xa5,sizeof(memory));
|
|
auto* prefs=new(memory)CompanionNodePrefs();
|
|
CompanionNodePrefs snapshot=*prefs;
|
|
assert(!snapshot.isDirty());
|
|
prefs->~CompanionNodePrefs();
|
|
} else if(scenario==5){
|
|
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
|
|
legacy.fs.files["/new_prefs"].resize(215);
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.tx_delay_factor==0.5f&&loaded.direct_tx_delay_factor==0.2f);
|
|
assert(loaded.interference_threshold==0&&loaded.agc_reset_interval==0&&loaded.tz_offset==0);
|
|
assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1);
|
|
assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1);
|
|
assert(loaded.one_key_dm_enabled==0);
|
|
assert(loaded.freq==original.freq&&loaded.ble_pin==original.ble_pin);
|
|
} else if(scenario==6){
|
|
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
|
|
#if defined(TBEAM_1W)
|
|
legacy.fs.files["/new_prefs"].resize(233);
|
|
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
legacy.fs.files["/new_prefs"].resize(323);
|
|
#else
|
|
legacy.fs.files["/new_prefs"].resize(226);
|
|
#endif
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.flood_retry_attempts==15&&loaded.flood_retry_max_path==1);
|
|
assert(loaded.flood_retry_group_max_path==0xff&&loaded.flood_retry_advert_enabled==1);
|
|
assert(loaded.one_key_dm_enabled==0);
|
|
} else if(scenario==7){
|
|
// Pre-consent image: all earlier settings survive with default-off consent.
|
|
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
|
|
legacy.fs.files["/new_prefs"].resize(disk.size()-6);
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.flood_retry_advert_enabled==0);
|
|
assert(loaded.one_key_dm_enabled==0);
|
|
} else if(scenario==9){
|
|
// The previous complete image loads with the board policy even when the
|
|
// receiving prefs object previously held an explicit configured interval.
|
|
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
|
|
legacy.fs.files["/new_prefs"].resize(disk.size()-4);
|
|
CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;loaded.usb_debug_enabled=1;
|
|
double lat=0,lon=0;
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.gps_sync_interval_hours==0 && loaded.one_key_dm_enabled==1);
|
|
assert(loaded.usb_debug_enabled==0);
|
|
// Half of the two-byte append is never accepted as a complete image.
|
|
DataStore torn;torn.fs.files["/new_prefs"]=disk;
|
|
torn.fs.files["/new_prefs"].resize(disk.size()-3);
|
|
assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon));
|
|
// The complete published GPS image is accepted without borrowing either
|
|
// GPS byte as the new debug preference.
|
|
DataStore gps_image;gps_image.fs.files["/new_prefs"]=disk;
|
|
gps_image.fs.files["/new_prefs"].resize(disk.size()-2);
|
|
loaded.usb_debug_enabled=1;
|
|
assert(gps_image.loadPrefsInt("/new_prefs",loaded,lat,lon));
|
|
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
|
|
// The compact known-size table must never narrow the actual file length.
|
|
// A large unknown image sharing a recognized low 16 bits remains invalid.
|
|
DataStore oversized;oversized.fs.files["/new_prefs"]=disk;
|
|
oversized.fs.files["/new_prefs"].resize(disk.size()+65536);
|
|
assert(!oversized.loadPrefsInt("/new_prefs",loaded,lat,lon));
|
|
assert(loaded.gps_sync_interval_hours==336&&loaded.usb_debug_enabled==0);
|
|
} else if(scenario==8){
|
|
for(uint8_t enabled : {0,1}){
|
|
CompanionNodePrefs saved=original;saved.wifi_enabled=enabled;
|
|
assert(store.savePrefs(saved,47.1,-122.2));
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
loaded.wifi_enabled=1-enabled;
|
|
assert(store.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.wifi_enabled==enabled); // Saved choices override the default.
|
|
assert(store.fs.files["/new_prefs"][156]==enabled); // Established byte offset.
|
|
}
|
|
for(unsigned size : {84,144,155,156}){
|
|
DataStore legacy;legacy.fs.files["/new_prefs"]=store.fs.files["/new_prefs"];
|
|
legacy.fs.files["/new_prefs"].resize(size);
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.wifi_enabled==0); // Old files with no WiFi field use opt-in.
|
|
}
|
|
} else if(scenario==4){
|
|
DataStore legacy;legacy.fs.files["/node_prefs"]=disk;
|
|
legacy.fs.fail_write_after=17;
|
|
#if defined(ESP32_PLATFORM)
|
|
legacy.fs.fail_rename_from={"/node_prefs"};
|
|
#endif
|
|
CompanionNodePrefs loaded;double lat=0,lon=0;
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(loaded.freq==original.freq);
|
|
assert(legacy.fs.files["/node_prefs"]==disk&&!legacy.fs.exists("/new_prefs"));
|
|
legacy.fs.fail_write_after=-1;
|
|
legacy.fs.fail_rename_from.clear();
|
|
assert(legacy.loadPrefs(loaded,lat,lon));
|
|
assert(!legacy.fs.exists("/node_prefs")&&legacy.fs.files["/new_prefs"]==disk);
|
|
}
|
|
}
|
|
'''
|
|
|
|
|
|
def esp_recovery_helpers(source):
|
|
"""Compile production recovery, substituting only its OS metadata call.
|
|
|
|
SPIFFS namespace presence is independent of open/read failures. Binding
|
|
the fake syscall to the filesystem argument also supports several device
|
|
instances in the same host test without a process-global mount mock.
|
|
"""
|
|
presence = method(source, 'static bool companionPathPresence(')
|
|
presence = presence.replace('struct stat info;', 'struct FixtureStat info;')
|
|
presence = presence.replace('::stat(vfs_path, &info)', 'fixtureStat(fs, vfs_path, &info)')
|
|
return '''
|
|
#if defined(ESP32_PLATFORM)
|
|
struct FixtureStat {};
|
|
static int fixtureStat(FILESYSTEM* fs, const char* path, FixtureStat*) {
|
|
assert(!strncmp(path, "/spiffs", 7));
|
|
if (!fs->files.count(path + 7)) { errno=ENOENT; return -1; }
|
|
return 0;
|
|
}
|
|
''' + presence + '\n' + method(source, 'static bool promoteCompanionRecoveryFile(') + '\n#endif\n'
|
|
|
|
|
|
class CompanionPreferencesTransactionTests(unittest.TestCase):
|
|
def test_real_preferences_io_failures(self):
|
|
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
|
|
methods = esp_recovery_helpers(source) + '\n'.join(method(source, signature) for signature in (
|
|
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(',
|
|
'bool DataStore::savePrefs('))
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
work = Path(directory)
|
|
# Binary preference persistence never invokes JSON blob conversion.
|
|
# Satisfy those unrelated serializer links without crypto libraries.
|
|
(work / 'Utils.h').write_text('''#pragma once
|
|
#include <Arduino.h>
|
|
#include <cassert>
|
|
namespace mesh { struct Utils {
|
|
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
|
|
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
|
|
}; }
|
|
''')
|
|
(work / 'platform_shim.h').write_text('''#include <cstdlib>
|
|
#include <cstdio>
|
|
inline char* utoa(unsigned int value,char* output,int base){
|
|
if(base!=10)abort();sprintf(output,"%u",value);return output;
|
|
}
|
|
''')
|
|
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(HARNESS.replace('@METHODS@', methods))
|
|
for platform in ('ESP32_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM', 'NRF52_PLATFORM',
|
|
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
|
|
with self.subTest(platform=platform):
|
|
binary = work / 'test'
|
|
sanitizer_flags = [] if os.name == 'nt' else [
|
|
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
|
'-fno-pie', '-no-pie']
|
|
build = subprocess.run(['g++', '-std=c++17',
|
|
*['-D'+flag+'=1' for flag in platform.split(',')],
|
|
*sanitizer_flags, '-include', str(work / 'platform_shim.h'),
|
|
'-I', str(work),
|
|
'-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
|
|
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
|
|
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT),
|
|
str(work / 'test.cpp'),
|
|
str(ROOT / 'src/helpers/ConfigSerializer.cpp'),
|
|
str(ROOT / 'src/helpers/DynamicConfigSerializer.cpp'),
|
|
str(ROOT / 'src/helpers/CommonRadioPrefs.cpp'),
|
|
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
|
|
'-o', str(binary)], capture_output=True, text=True)
|
|
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
|
|
for scenario in range(10):
|
|
with self.subTest(scenario=scenario):
|
|
run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True)
|
|
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|