From 4086222d856f8cbc84c3b3b00cd3a212bf48db89 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 03:09:20 -0700 Subject: [PATCH] Persist Companion Bluetooth on-off preference across reboots --- .github/workflows/run-unit-tests.yml | 3 + docs/cli_commands.md | 15 +- examples/companion_radio/DataStore.cpp | 15 + examples/companion_radio/MyMesh.cpp | 11 +- examples/companion_radio/MyMesh.h | 2 + examples/companion_radio/NodePrefs.h | 2 + examples/companion_radio/main.cpp | 64 +++- src/helpers/MultiSerialInterface.h | 6 + test/test_companion_bluetooth_control.py | 156 +++++++++- test/test_companion_bluetooth_persistence.py | 273 ++++++++++++++++++ .../test_companion_preferences_transaction.py | 2 +- 11 files changed, 528 insertions(+), 21 deletions(-) create mode 100644 test/test_companion_bluetooth_persistence.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 11ad8dd5c..40972e2bc 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -205,6 +205,9 @@ jobs: - name: Verify Companion Bluetooth CLI connection protection run: python3 -B test/test_companion_bluetooth_control.py -v + - name: Verify Companion Bluetooth reboot persistence + run: python3 -B test/test_companion_bluetooth_persistence.py -v + - name: Verify nRF52 SoftDevice LTO and persistent BLE subscriptions env: MESHCORE_REQUIRE_ARM_COMPILER: '1' diff --git a/docs/cli_commands.md b/docs/cli_commands.md index d27d05e03..228dc58a4 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1619,7 +1619,17 @@ set bluetooth on `get ble` and `set ble ...` are aliases. This controls the running Bluetooth service on ESP32 and nRF52 Companions: `off` stops advertising and disconnects -Bluetooth clients; `on` enables it again. The change lasts until reboot. +Bluetooth clients; `on` enables it again. Both choices are saved and survive +reboots. USB remains available when Bluetooth is off. Older saved preferences +without this setting retain the previous default of Bluetooth on. + +Successful local commands report `(saved)`. If saving fails, the command reports +an error and leaves Bluetooth unchanged. When requested over Bluetooth, `off` +first reports `(save pending)` so its reply can drain; the firmware rechecks the +alternate connection and saves the preference before disconnecting. If that +connection disappears or the save fails, shutdown is cancelled. `get bluetooth` +reports the current runtime state. Broad `2.4ghz` controls still apply to the +current boot only and do not change this saved Bluetooth preference. Plain `off` requires another active management connection. A connected USB terminal or binary client, TCP client, or browser terminal can provide that @@ -1629,7 +1639,8 @@ bridge), issue the command through that USB client so the request itself proves it is active. Append `force` to `off` to permit disconnecting the only active connection. -Use USB, another management transport, or reboot to regain access afterward. +Use USB or another management transport and `set bluetooth on` to regain +access afterward. Reboot alone will not re-enable saved-off Bluetooth. `force` is accepted only as the final argument to `off`. Commands sent through Bluetooth allow its reply to drain before shutdown diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index dac7b0572..e87c2bfe7 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -999,6 +999,9 @@ bool DataStore::loadPrefsInt(const char *filename, File file = openRead(_fs, filename); if (file) { CompanionNodePrefs loaded_prefs = _prefs; + // Files written before this tail existed always started Bluetooth at boot. + // Do not inherit a runtime off value when loading one of those images. + loaded_prefs.bluetooth_enabled = 1; double loaded_lat = node_lat; double loaded_lon = node_lon; // The original image ended after ble_pin at byte 84. Later releases only @@ -1046,6 +1049,13 @@ bool DataStore::loadPrefsInt(const char *filename, #endif #if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) 328, +#endif +#ifdef TBEAM_1W + 239, // Bluetooth enable preference, appended after one-key DM policy +#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + 329, +#else + 232, #endif }; const uint32_t prefs_size = file.size(); @@ -1188,10 +1198,13 @@ bool DataStore::loadPrefsInt(const char *filename, sizeof(loaded_prefs.flood_retry_advert_enabled)); readOptionalField(&loaded_prefs.one_key_dm_enabled, sizeof(loaded_prefs.one_key_dm_enabled)); + readOptionalField(&loaded_prefs.bluetooth_enabled, + sizeof(loaded_prefs.bluetooth_enabled)); // Any bytes left over form only part of a historically appended field. // Preserve the file and defaults rather than treating that tail as EOF. success = success && file.available() == 0; + success = success && loaded_prefs.bluetooth_enabled <= 1; file.close(); if (!success) return false; _prefs = loaded_prefs; @@ -1348,6 +1361,8 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou sizeof(_prefs.flood_retry_advert_enabled)) == sizeof(_prefs.flood_retry_advert_enabled); success = success && file.write((uint8_t *)&_prefs.one_key_dm_enabled, sizeof(_prefs.one_key_dm_enabled)) == sizeof(_prefs.one_key_dm_enabled); + success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled, + sizeof(_prefs.bluetooth_enabled)) == sizeof(_prefs.bluetooth_enabled); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) success = file.commit(success); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 080ba2615..c2fadae07 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2310,6 +2310,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.wifi_enabled = 0; #endif memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name)); + _prefs.bluetooth_enabled = 1; _prefs.display_rotation_degrees = 0; _prefs.cad_enabled = DEFAULT_CAD_ENABLED ? 1 : 0; _prefs.cad_scan_timeout_ms = 0; @@ -4137,6 +4138,14 @@ uint32_t MyMesh::getBLEPin() { return _active_ble_pin; } +bool MyMesh::setBluetoothEnabledPreference(bool enabled) { + return savePreference(_prefs.bluetooth_enabled, static_cast(enabled)); +} + +bool MyMesh::isBluetoothEnabledPreference() const { + return _prefs.bluetooth_enabled != 0; +} + #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) static void copyMqttString(char* dest, size_t dest_size, const char* src) { strncpy(dest, src ? src : "", dest_size - 1); @@ -9161,7 +9170,7 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" set bluetooth.name \r\n"); #if defined(BLE_PIN_CODE) terminalOutput().print(" get bluetooth (alias: get ble)\r\n"); - terminalOutput().print(" set bluetooth [force] (this boot; force only with off)\r\n"); + terminalOutput().print(" set bluetooth [force] (saved; force only with off)\r\n"); terminalOutput().print(" get bluetooth.mac\r\n"); terminalOutput().print( " set bluetooth.mac \r\n"); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4145649d6..afcfbe4fe 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -194,6 +194,8 @@ public: const char *getNodeName(); CompanionNodePrefs *getNodePrefs(); uint32_t getBLEPin(); + bool setBluetoothEnabledPreference(bool enabled); + bool isBluetoothEnabledPreference() const; #if defined(BLE_PIN_CODE) bool prepareBluetoothMacForBoot(bool& address_rotated); bool armBluetoothMacRotationAfterConnection(); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index f27d8df7e..a3350b26c 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -108,6 +108,8 @@ public: uint8_t flood_retry_advert_enabled = 1; // Appended persisted field: old preferences images default to consent required. uint8_t one_key_dm_enabled = 0; + // Appended after all existing fields so older images keep Bluetooth enabled. + uint8_t bluetooth_enabled = 1; private: class RadioPrefs : public CommonRadioPrefs { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3d7cffb95..192a129a4 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2340,7 +2340,9 @@ void halt() { #endif companion_bluetooth_initialized = true; companion_bluetooth_start_failure[0] = 0; - if (interface_manager.isEnabled()) bluetooth_interface.enable(); + if (interface_manager.isEnabled() && the_mesh.isBluetoothEnabledPreference()) { + bluetooth_interface.enable(); + } } static void serviceDeferredCompanionBluetooth() { @@ -2539,6 +2541,13 @@ static void serviceCompanionBluetoothControl() { // Give the requesting BLE client its reply, with a bounded wait even when // its notification queue is stuck. The command itself reports a request. if (bluetooth_interface.hasPendingIO() && elapsed < 1750) return; + if (!the_mesh.setBluetoothEnabledPreference(false)) { + companion_bluetooth_off_at = 0; + mesh::usbLoggingPort().println( + "Bluetooth off cancelled: preference save failed"); + return; + } + interface_manager.setBluetoothAutoEnable(false); disableCompanionBluetoothForCli(); } #endif @@ -2554,12 +2563,6 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, return true; } #if defined(BLE_PIN_CODE) - if (!companion_bluetooth_initialized) { - snprintf(reply, reply_size, - "Error: Bluetooth unavailable in this boot: %s", - companion_bluetooth_start_failure); - return true; - } if (action == CompanionBluetoothCommand::Get) { snprintf(reply, reply_size, "bluetooth %s%s", interface_manager.isBluetoothEnabled() ? "on" : "off", @@ -2567,19 +2570,43 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, return true; } if (action == CompanionBluetoothCommand::On) { + if (!companion_bluetooth_initialized) { + snprintf(reply, reply_size, + "Error: Bluetooth unavailable in this boot: %s", + companion_bluetooth_start_failure); + return true; + } if (!mesh::wireless::control().allowService(mesh::wireless::Bluetooth)) { snprintf(reply, reply_size, "Error: 2.4ghz is off or changing; use set 2.4ghz on first"); return true; } - companion_bluetooth_off_at = 0; + const bool previous_preference = the_mesh.isBluetoothEnabledPreference(); + if (!the_mesh.setBluetoothEnabledPreference(true)) { + snprintf(reply, reply_size, "Error: Bluetooth preference save failed; unchanged"); + return true; + } + interface_manager.setBluetoothAutoEnable(true); interface_manager.enableBluetooth(); - snprintf(reply, reply_size, "%s", interface_manager.isBluetoothEnabled() - ? "OK - Bluetooth on (this boot)" : "Error: Bluetooth enable failed"); + if (interface_manager.isBluetoothEnabled()) { + companion_bluetooth_off_at = 0; + snprintf(reply, reply_size, "OK - Bluetooth on (saved)"); + } else if (the_mesh.setBluetoothEnabledPreference(previous_preference)) { + interface_manager.setBluetoothAutoEnable(previous_preference); + snprintf(reply, reply_size, "Error: Bluetooth enable failed; preference restored"); + } else { + snprintf(reply, reply_size, + "Error: Bluetooth enable failed; rollback save failed (saved on)"); + } return true; } if (!interface_manager.isBluetoothEnabled()) { + if (!the_mesh.setBluetoothEnabledPreference(false)) { + snprintf(reply, reply_size, "Error: Bluetooth preference save failed; unchanged"); + return true; + } companion_bluetooth_off_at = 0; - snprintf(reply, reply_size, "OK - Bluetooth already off (this boot)"); + interface_manager.setBluetoothAutoEnable(false); + snprintf(reply, reply_size, "OK - Bluetooth already off (saved)"); return true; } const bool force = action == CompanionBluetoothCommand::ForceOff; @@ -2598,13 +2625,18 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, return true; } if (non_bluetooth_requester) { + if (!the_mesh.setBluetoothEnabledPreference(false)) { + snprintf(reply, reply_size, "Error: Bluetooth preference save failed; unchanged"); + return true; + } + interface_manager.setBluetoothAutoEnable(false); disableCompanionBluetoothForCli(); - snprintf(reply, reply_size, "OK - Bluetooth off (this boot)"); + snprintf(reply, reply_size, "OK - Bluetooth off (saved)"); } else { companion_bluetooth_force_off = force; companion_bluetooth_off_at = millis() + 250; if (companion_bluetooth_off_at == 0) companion_bluetooth_off_at = 1; - snprintf(reply, reply_size, "OK - Bluetooth off requested (this boot)"); + snprintf(reply, reply_size, "OK - Bluetooth off requested (save pending)"); } #else (void)source; @@ -3021,6 +3053,12 @@ void setup() { // platforms have no separate port, so this is a harmless no-op there. mesh::beginUsbLoggingPort(); +#if defined(BLE_PIN_CODE) + // Keep stack registration available for a later USB `set bluetooth on`, + // without advertising even briefly when startup enables all interfaces. + interface_manager.setBluetoothAutoEnable(the_mesh.isBluetoothEnabledPreference()); +#endif + // Lock the saved transport selection before bringing up either wireless stack. #if defined(COMPANION_EXCLUSIVE_WIFI_BLE) loadCompanionTransportModeForBoot(); diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index e23d36721..5ea761525 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -27,6 +27,7 @@ private: }; bool _enabled = false; + bool _bluetooth_auto_enable = true; RegisteredInterface _interfaces[MAX_INTERFACES] = {}; BaseSerialInterface* _lastRxInterface = nullptr; BaseSerialInterface* _lockedReplyInterface = nullptr; @@ -55,6 +56,10 @@ private: } public: + // The Companion's saved preference controls blanket startup/wake enables. + // Explicit Bluetooth/wireless controls remain able to enable it temporarily. + void setBluetoothAutoEnable(bool enabled) { _bluetooth_auto_enable = enabled; } + bool addInterface(InterfaceType type, BaseSerialInterface* iface) { // make sure an interface was provided if(iface == nullptr){ @@ -143,6 +148,7 @@ public: _lockedReplyInterface = nullptr; for(auto iface : _interfaces){ if(iface.instance){ + if (iface.type == InterfaceType::Bluetooth && !_bluetooth_auto_enable) continue; iface.instance->enable(); } } diff --git a/test/test_companion_bluetooth_control.py b/test/test_companion_bluetooth_control.py index b6d49b43b..0b5d5f6dd 100644 --- a/test/test_companion_bluetooth_control.py +++ b/test/test_companion_bluetooth_control.py @@ -26,8 +26,8 @@ uint32_t companion_bluetooth_off_at=0; bool companion_bluetooth_force_off=false; struct Transport : BaseSerialInterface { bool enabled=false,connected=false,busy=false,frame=false,enable_fails=false; - unsigned disabled=0; - void enable() override {enabled=!enable_fails;} + unsigned disabled=0,enables=0; + void enable() override {++enables;enabled=!enable_fails;} void disable() override {enabled=false;connected=false;++disabled;} bool isEnabled() const override{return enabled;} bool isConnected() const override{return connected;} @@ -43,6 +43,13 @@ MultiSerialInterface interface_manager; bool isUsbTerminalDataConnected(){return usb_open;} unsigned cancelled=0,logs=0; struct { + bool bluetooth_preference=true,durable_bluetooth_preference=true,storage_accepts=true; + unsigned saves=0,fail_save_at=0; + bool isBluetoothEnabledPreference() const {return bluetooth_preference;} + bool setBluetoothEnabledPreference(bool enabled){ + ++saves;if(!storage_accepts||saves==fail_save_at)return false; + bluetooth_preference=durable_bluetooth_preference=enabled;return true; + } bool isAnyNetworkTerminalMode(){return network_terminal;} void cancelSerialOperationsForRoute(BaseSerialInterface* route){ assert(route==&bluetooth_interface);++cancelled; @@ -80,6 +87,7 @@ int main(){ assert(!command("set ble.stealth on")); assert(command("set bluetooth off")&&strstr(reply,"no other active")); assert(usb_power&&bluetooth_interface.enabled&&!companion_bluetooth_off_at); + assert(the_mesh.saves==0&&the_mesh.durable_bluetooth_preference); // A disabled TCP interface and a socket that is merely listening don't count. wifi_interface.connected=true;wifi_interface.enabled=false; assert(command("set ble off")&&strstr(reply,"Error:")); @@ -89,9 +97,11 @@ int main(){ wifi_interface.connected=true; assert(command("set ble off")&&strstr(reply,"requested")); assert(command("get ble")&&strstr(reply,"off pending")); + assert(the_mesh.durable_bluetooth_preference); // Requested is not yet committed. now_ms+=249;serviceCompanionBluetoothControl();assert(bluetooth_interface.enabled); wifi_interface.connected=false;++now_ms;serviceCompanionBluetoothControl(); assert(bluetooth_interface.enabled&&!companion_bluetooth_off_at&&logs==1); + assert(the_mesh.saves==0&&the_mesh.durable_bluetooth_preference); // Force may disconnect the sole client, but waits for queued notifications. assert(command(" set ble\t off\tforce \t")&&strstr(reply,"requested")); bluetooth_interface.busy=true;now_ms+=250;serviceCompanionBluetoothControl(); @@ -99,11 +109,13 @@ int main(){ now_ms+=1749;serviceCompanionBluetoothControl();assert(bluetooth_interface.enabled); ++now_ms;serviceCompanionBluetoothControl(); assert(!bluetooth_interface.enabled&&cancelled==1); + assert(!the_mesh.durable_bluetooth_preference); assert(interface_manager.captureReplyRoute()==nullptr); assert(command("set ble off")&&strstr(reply,"already off")); assert(command("get ble")&&strcmp(reply,"bluetooth off")==0); bluetooth_interface.busy=false; assert(command("set bluetooth on")&&strstr(reply,"OK"));route(bluetooth_interface); + assert(the_mesh.durable_bluetooth_preference); // Another native CDC session counts; HWCDC/bridge presence alone doesn't. usb_open=true;usb_serial_interface.connected=true; assert(command("set ble off")); @@ -120,9 +132,18 @@ int main(){ usb_mota_mode=false;usb_open=false; // An actual USB command proves that requester is present on every backend. route(usb_serial_interface); - assert(command("set ble off")&&strstr(reply,"Bluetooth off (this boot)")); + assert(command("set ble off")&&strstr(reply,"Bluetooth off")); assert(!bluetooth_interface.enabled&&interface_manager.captureReplyRoute()==&usb_serial_interface); + assert(!the_mesh.durable_bluetooth_preference); + assert(usb_serial_interface.enabled); // Persisting BLE off never disables USB. + const unsigned saved_off_enables=bluetooth_interface.enables; + interface_manager.disable();interface_manager.enable(); + assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==saved_off_enables); + assert(usb_serial_interface.enabled);route(usb_serial_interface); assert(command("set ble on")&&bluetooth_interface.enabled); + assert(the_mesh.durable_bluetooth_preference); + interface_manager.disable();interface_manager.enable(); + assert(bluetooth_interface.enabled); // Successful on also restores the aggregate boot policy. // ASCII terminal commands cannot inherit the previous framed BLE route. route(bluetooth_interface); assert(command("set ble off",CompanionBluetoothCommandSource::Terminal)); @@ -137,6 +158,34 @@ int main(){ assert(command("set ble off")&&strstr(reply,"requested")); now_ms+=250;serviceCompanionBluetoothControl();assert(!bluetooth_interface.enabled); assert(command("set ble on"));route(bluetooth_interface);ethernet_interface.connected=false; + // Failed delayed saves leave the requesting BLE session alive and cancellable. + assert(command("set ble off force")); + const unsigned delayed_disabled=bluetooth_interface.disabled; + const uint32_t pending_off=companion_bluetooth_off_at; + the_mesh.storage_accepts=false; + assert(command("set ble on")&&strstr(reply,"Error:")); + assert(companion_bluetooth_off_at==pending_off); // Failed reversal does not cancel a prior request. + now_ms+=250;serviceCompanionBluetoothControl(); + assert(bluetooth_interface.enabled&&the_mesh.durable_bluetooth_preference); + assert(!companion_bluetooth_off_at&&bluetooth_interface.disabled==delayed_disabled); + the_mesh.storage_accepts=true; + // Failed immediate off saves must not disconnect the USB recovery command. + route(usb_serial_interface);the_mesh.storage_accepts=false; + assert(command("set ble off")&&strstr(reply,"Error:")); + assert(bluetooth_interface.enabled&&the_mesh.durable_bluetooth_preference); + assert(interface_manager.captureReplyRoute()==&usb_serial_interface); + the_mesh.storage_accepts=true; + assert(command("set ble off")&&!bluetooth_interface.enabled); + assert(!the_mesh.durable_bluetooth_preference); + // Failed on saves restore the off runtime state as well as durable preference. + the_mesh.storage_accepts=false; + const unsigned failed_on_enables=bluetooth_interface.enables; + assert(command("set ble on")&&strstr(reply,"Error:")); + assert(!bluetooth_interface.enabled&&!the_mesh.durable_bluetooth_preference); + assert(bluetooth_interface.enables==failed_on_enables); // No transient advertising. + the_mesh.storage_accepts=true; + assert(command("set ble on")&&bluetooth_interface.enabled); + assert(the_mesh.durable_bluetooth_preference);route(bluetooth_interface); // Pending deadlines work across millis() wrap, including the zero sentinel. for(uint32_t start : {0xffffff00u,0xffffff06u}){ now_ms=start;assert(command("set ble off force")); @@ -145,12 +194,28 @@ int main(){ assert(command("set ble on"));route(bluetooth_interface); } companion_bluetooth_initialized=false; + bluetooth_interface.enabled=false; + assert(command("get ble")&&strstr(reply,"bluetooth off")); assert(command("set ble on")&&strstr(reply,"unavailable")); - assert(command("set ble off force")&&strstr(reply,"unavailable")); + assert(command("set ble off force")&&strstr(reply,"already off")); + assert(!the_mesh.durable_bluetooth_preference); companion_bluetooth_initialized=true; assert(command("set ble off",CompanionBluetoothCommandSource::Terminal)); bluetooth_interface.enable_fails=true; assert(command("set ble on")&&strstr(reply,"enable failed")); + assert(!the_mesh.durable_bluetooth_preference&&!bluetooth_interface.enabled); + assert(command("get ble")&&strcmp(reply,"bluetooth off")==0); + bluetooth_interface.enable_fails=false; + interface_manager.disable();interface_manager.enable(); + assert(!bluetooth_interface.enabled&&usb_serial_interface.enabled); // Policy rollback matches saved off. + // If both enable and its rollback save fail, report the durable on choice honestly. + bluetooth_interface.enable_fails=true;the_mesh.fail_save_at=the_mesh.saves+2; + assert(command("set ble on")&&strstr(reply,"rollback save failed")); + assert(the_mesh.durable_bluetooth_preference&&!bluetooth_interface.enabled); + assert(command("get ble")&&strcmp(reply,"bluetooth off")==0); // Status remains the live state. + the_mesh.fail_save_at=0;bluetooth_interface.enable_fails=false; + interface_manager.disable();interface_manager.enable(); + assert(bluetooth_interface.enabled); // Aggregate policy remains consistent with the durable on value. #else assert(command("get ble")&&strstr(reply,"not supported")); assert(command("set ble off force")&&strstr(reply,"not supported")); @@ -200,6 +265,89 @@ class BluetoothControlTests(unittest.TestCase): ran = subprocess.run([str(binary)],capture_output=True,text=True) self.assertEqual(ran.returncode,0,ran.stderr) + def test_saved_off_policy_keeps_usb_recovery_and_explicit_runtime_on(self): + # Execute the actual multi-transport manager. A saved-off boot must + # not briefly advertise when the aggregate interface is enabled. + source_text = HARNESS.split('@METHODS@')[0] + r''' +int main(){ + interface_manager.addInterface(InterfaceType::Bluetooth,&bluetooth_interface); + interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface); + interface_manager.addInterface(InterfaceType::WiFi,&wifi_interface); + interface_manager.addInterface(InterfaceType::Ethernet,ðernet_interface); + interface_manager.setBluetoothAutoEnable(false); + interface_manager.enable(); + assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==0); + assert(usb_serial_interface.enabled&&wifi_interface.enabled&ðernet_interface.enabled); + interface_manager.disable();interface_manager.enable(); + assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==0); + // USB can explicitly enable an already-initialized stack while saved off. + interface_manager.enableBluetooth();assert(bluetooth_interface.enabled); + assert(bluetooth_interface.enables==1); + interface_manager.disable();interface_manager.enable(); + assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==1); + interface_manager.setBluetoothAutoEnable(true); + interface_manager.disable();interface_manager.enable(); + assert(bluetooth_interface.enabled&&bluetooth_interface.enables==2); + // The legacy default remains automatic on. + MultiSerialInterface legacy;Transport ble; + legacy.addInterface(InterfaceType::Bluetooth,&ble);legacy.enable(); + assert(ble.enabled&&ble.enables==1); +} +''' + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + (folder/'Arduino.h').write_text('#pragma once\n#include \n#include \n') + source = folder/'startup_policy.cpp' + source.write_text(source_text) + binary = folder/'startup_policy' + built = subprocess.run([os.environ.get('CXX','g++'), '-std=c++17', + '-I',str(folder),'-I',str(ROOT),'-I',str(ROOT/'src'), + str(source),'-o',str(binary)],capture_output=True,text=True) + self.assertEqual(built.returncode,0,built.stderr) + ran = subprocess.run([str(binary)],capture_output=True,text=True) + self.assertEqual(ran.returncode,0,ran.stderr) + + def test_wireless_restore_does_not_change_the_saved_bluetooth_choice(self): + adapter = method(MAIN, 'class CompanionWirelessBackend')+';\n' + source_text = HARNESS.split('@METHODS@')[0] + '\n' + method( + MAIN, 'static void disableCompanionBluetoothForCli(') + '\n' + adapter + r''' +int main(){ + using namespace mesh::wireless; + interface_manager.addInterface(InterfaceType::Bluetooth,&bluetooth_interface); + interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface); + the_mesh.bluetooth_preference=the_mesh.durable_bluetooth_preference=false; + interface_manager.setBluetoothAutoEnable(false);interface_manager.enable(); + CompanionWirelessBackend backend;control().begin(backend); + assert(backend.available()==Bluetooth&&!bluetooth_interface.enabled); + char reply[160]; + control().handle("set 2.4ghz off",reply,sizeof(reply),now_ms,Independent); + now_ms+=250;control().service(now_ms); + control().handle("set 2.4ghz on",reply,sizeof(reply),now_ms,Independent); + now_ms+=250;control().service(now_ms); + assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==0); + assert(!the_mesh.durable_bluetooth_preference&&the_mesh.saves==0); + // Explicit all is a runtime-only override; the saved-off boot policy remains. + control().handle("set 2.4ghz on all",reply,sizeof(reply),now_ms,Independent); + now_ms+=250;control().service(now_ms); + assert(bluetooth_interface.enabled&&!the_mesh.durable_bluetooth_preference); + assert(the_mesh.saves==0); + interface_manager.disable();interface_manager.enable(); + assert(!bluetooth_interface.enabled&&usb_serial_interface.enabled); +} +''' + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + (folder/'Arduino.h').write_text('#pragma once\n#include \n#include \n') + source = folder/'wireless_policy.cpp' + source.write_text(source_text) + binary = folder/'wireless_policy' + built = subprocess.run([os.environ.get('CXX','g++'), '-std=c++17', + '-DBLE_PIN_CODE=123456','-I',str(folder),'-I',str(ROOT),'-I',str(ROOT/'src'), + str(source),'-o',str(binary)],capture_output=True,text=True) + self.assertEqual(built.returncode,0,built.stderr) + ran = subprocess.run([str(binary)],capture_output=True,text=True) + self.assertEqual(ran.returncode,0,ran.stderr) + def test_cli_entry_points_pass_the_current_source(self): mesh = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text() terminal = method(mesh,'void MyMesh::handleTerminalCommand(') diff --git a/test/test_companion_bluetooth_persistence.py b/test/test_companion_bluetooth_persistence.py new file mode 100644 index 000000000..d5026466c --- /dev/null +++ b/test/test_companion_bluetooth_persistence.py @@ -0,0 +1,273 @@ +"""Execute persistent Bluetooth preferences, storage faults, and boot gates.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_companion_preferences_transaction import HARNESS as STORE_HARNESS +from test_companion_preferences_transaction import esp_recovery_helpers +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +PREFERENCE_HARNESS = r''' +struct MyMesh { + CompanionNodePrefs _prefs; + DataStore store; + unsigned saves=0; + bool savePrefs(){++saves;return store.savePrefs(_prefs,47.1,-122.2);} + @SAVE_PREFERENCE@ + bool setBluetoothEnabledPreference(bool); + bool isBluetoothEnabledPreference() const; +}; +@PREFERENCE_METHODS@ +int main(){ + MyMesh mesh; + assert(mesh.isBluetoothEnabledPreference()); // New devices retain legacy-on behavior. + mesh._prefs.wifi_enabled=0;mesh._prefs.usb_logging_enabled=1; + strcpy(mesh._prefs.node_name,"durable node");mesh._prefs.freq=910.525f; + mesh._prefs.ble_pin=876543;mesh._prefs.bluetooth_stealth_mode=2; + mesh._prefs.one_key_dm_enabled=1; + assert(mesh.savePrefs()); + const auto on_disk=mesh.store.fs.files["/new_prefs"]; +#if defined(TBEAM_1W) + assert(on_disk.size()==239); +#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE) + assert(on_disk.size()==329); +#else + assert(on_disk.size()==232); +#endif + assert(on_disk.back()==1); + // Both choices survive a fresh instance reading the actual on-device image. + for(bool enabled : {false,true,false}){ + assert(mesh.setBluetoothEnabledPreference(enabled)); + DataStore reboot;reboot.fs=mesh.store.fs; + CompanionNodePrefs prefs;prefs.bluetooth_enabled=!enabled; + double lat=0,lon=0;assert(reboot.loadPrefs(prefs,lat,lon)); + assert(prefs.bluetooth_enabled==enabled&&lat==47.1&&lon==-122.2); + assert(prefs.ble_pin==876543&&prefs.freq==910.525f); + assert(prefs.bluetooth_stealth_mode==2&&prefs.one_key_dm_enabled==1); + assert(prefs.wifi_enabled==0&&prefs.usb_logging_enabled==1); + assert(reboot.fs.files["/new_prefs"].back()==enabled); + } + const auto off_disk=mesh.store.fs.files["/new_prefs"]; + // The public setter must roll live state back when the atomic save fails. + mesh.store.fs.fail_write_after=17; + assert(!mesh.setBluetoothEnabledPreference(true)); + assert(!mesh.isBluetoothEnabledPreference()); + assert(mesh.store.fs.files["/new_prefs"]==off_disk); + mesh.store.fs.fail_write_after=-1; + mesh.store.fs.fail_rename=1; + assert(!mesh.setBluetoothEnabledPreference(true)); + assert(!mesh.isBluetoothEnabledPreference()); + assert(mesh.store.fs.files["/new_prefs"]==off_disk); + mesh.store.fs.fail_rename=0; + assert(mesh.setBluetoothEnabledPreference(true)); + mesh.store.fs.fail_write=true; + assert(!mesh.setBluetoothEnabledPreference(false)); + assert(mesh.isBluetoothEnabledPreference()); + mesh.store.fs.fail_write=false; + // Legacy images cannot accidentally inherit an off value from the caller. + for(unsigned size : {84u,156u,215u,226u,unsigned(on_disk.size()-1)}){ + DataStore legacy;legacy.fs.files["/new_prefs"]=on_disk; + legacy.fs.files["/new_prefs"].resize(size); + CompanionNodePrefs prefs;prefs.bluetooth_enabled=0; + double lat=0,lon=0;assert(legacy.loadPrefs(prefs,lat,lon)); + assert(prefs.bluetooth_enabled==1&&prefs.ble_pin==876543); + assert(legacy.fs.files["/new_prefs"].size()==size); // Read-only migration. + } + // Invalid new encoding rejects the transaction rather than partially loading. + for(uint8_t corrupt : {2,255}){ + DataStore damaged;damaged.fs.files["/new_prefs"]=off_disk; + damaged.fs.files["/new_prefs"].back()=corrupt; + CompanionNodePrefs prefs;prefs.bluetooth_enabled=1; + strcpy(prefs.node_name,"unchanged");double lat=1,lon=2; + assert(!damaged.loadPrefsInt("/new_prefs",prefs,lat,lon)); + assert(prefs.bluetooth_enabled==1&&!strcmp(prefs.node_name,"unchanged")); + assert(lat==1&&lon==2); + } +} +''' + +STARTUP_HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include "examples/companion_radio/BluetoothName.h" +#define BLE_NAME_PREFIX "MC-" +uint32_t now_ms=100; +uint32_t millis(){return now_ms;} +bool companion_bluetooth_initialized=false; +char companion_bluetooth_start_failure[96]={}; +uint32_t companion_bluetooth_start_at=0; +constexpr uint32_t COMPANION_BLUETOOTH_RETRY_MS=5000; +uint8_t companion_bluetooth_session_stealth_mode=mesh::companion::BLUETOOTH_STEALTH_OFF; +struct CompanionNodePrefs { + char bluetooth_name[64]={},node_name[32]="durable node"; + uint8_t bluetooth_stealth_peer_type=0,bluetooth_stealth_peer[6]={}; +}; +struct { + CompanionNodePrefs prefs; + bool preference=true; + CompanionNodePrefs* getNodePrefs(){return &prefs;} + bool isBluetoothEnabledPreference() const {return preference;} + uint32_t getBLEPin(){return 876543;} +} the_mesh; +namespace mesh { +struct Logger {void println(const char*){}}; +Logger& usbLoggingPort(){static Logger logger;return logger;} +} +struct Transport : BaseSerialInterface { + bool enabled=false,begin_succeeds=true; + unsigned enables=0,begins=0; + template bool begin(T...){++begins;return begin_succeeds;} + const char* beginFailure(){return "fixture startup failure";} + void enable() override{enabled=true;++enables;} + void disable() override{enabled=false;} + bool isEnabled() const override{return enabled;} + bool isConnected() const override{return false;} + bool isReadBusy() const override{return false;} + bool isWriteBusy() const override{return false;} + size_t writeFrame(const uint8_t*,size_t n) override{return n;} + size_t checkRecvFrame(uint8_t*) override{return 0;} +} bluetooth_interface,usb_serial_interface; +MultiSerialInterface interface_manager; +bool identity_accepts=true; +unsigned identity_preparations=0; +bool prepareCompanionBluetoothIdentity(CompanionNodePrefs*){ + ++identity_preparations;return identity_accepts; +} +const uint8_t* companionBluetoothAddress(const CompanionNodePrefs*,bool&){return nullptr;} +@METHODS@ +void fresh(bool preference){ + interface_manager=MultiSerialInterface();bluetooth_interface=Transport(); + usb_serial_interface=Transport();companion_bluetooth_initialized=false; + companion_bluetooth_start_at=0;identity_accepts=true;identity_preparations=0; + the_mesh.preference=preference; + interface_manager.setBluetoothAutoEnable(preference); + assert(interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface)); +} +int main(){ + // Both immediate and deferred stack registration must honor saved-off + // advertising while preserving an initialized stack for USB recovery. + for(bool manager_started : {false,true}){ + fresh(false); + if(manager_started)interface_manager.enable(); + startCompanionBluetooth(); + assert(companion_bluetooth_initialized&&bluetooth_interface.begins==1); + assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==0); + if(!manager_started)interface_manager.enable(); + assert(usb_serial_interface.enabled&&!bluetooth_interface.enabled); + startCompanionBluetooth();serviceDeferredCompanionBluetooth(); + assert(bluetooth_interface.begins==1&&bluetooth_interface.enables==0); + interface_manager.enableBluetooth(); + assert(bluetooth_interface.enabled&&bluetooth_interface.begins==1); + } + // A scheduled identity/start retry may initialize, never enable a saved-off + // transport when the preference was changed before the retry became due. + fresh(true);interface_manager.enable();identity_accepts=false; + startCompanionBluetooth();assert(companion_bluetooth_start_at!=0); + assert(!companion_bluetooth_initialized&&bluetooth_interface.begins==0); + the_mesh.preference=false;interface_manager.setBluetoothAutoEnable(false); + identity_accepts=true;now_ms+=COMPANION_BLUETOOTH_RETRY_MS; + serviceDeferredCompanionBluetooth(); + assert(companion_bluetooth_initialized&&bluetooth_interface.begins==1); + assert(bluetooth_interface.enables==0&&!bluetooth_interface.enabled&&usb_serial_interface.enabled); + // Existing on/default preference still starts normally in either order. + fresh(true);startCompanionBluetooth();interface_manager.enable(); + assert(bluetooth_interface.enabled&&bluetooth_interface.enables==1); + fresh(true);interface_manager.enable();startCompanionBluetooth(); + assert(bluetooth_interface.enabled&&bluetooth_interface.enables==1); +} +''' + + +class BluetoothPersistenceTests(unittest.TestCase): + def test_actual_startup_and_deferred_retry_do_not_advertise_saved_off(self): + main = (ROOT/'examples/companion_radio/main.cpp').read_text() + methods = '\n'.join(method(main, signature) for signature in ( + 'static void scheduleCompanionBluetoothRetry(', + 'static void startCompanionBluetooth(', + 'static void serviceDeferredCompanionBluetooth(')) + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work/'Arduino.h').write_text('#pragma once\n#include \n#include \n') + source = work/'startup.cpp' + source.write_text(STARTUP_HARNESS.replace('@METHODS@', methods)) + for platform in ('NRF52_PLATFORM', 'ESP32'): + with self.subTest(platform=platform): + binary = work/'startup' + built = subprocess.run([os.environ.get('CXX','g++'), '-std=c++17', + '-DBLE_PIN_CODE=123456','-D'+platform+'=1', + '-I',str(work),'-I',str(ROOT/'src'),'-I',str(ROOT), + str(source),'-o',str(binary)],capture_output=True,text=True) + self.assertEqual(built.returncode,0,built.stdout+built.stderr) + ran = subprocess.run([str(binary)],capture_output=True,text=True) + self.assertEqual(ran.returncode,0,ran.stdout+ran.stderr) + + def test_boot_policy_is_applied_before_any_interface_can_start(self): + main = (ROOT/'examples/companion_radio/main.cpp').read_text() + setup = method(main, '\nvoid setup()') + policy = setup.index('interface_manager.setBluetoothAutoEnable(') + self.assertLess(policy, setup.index('startCompanionBluetooth();')) + self.assertLess(policy, setup.index('the_mesh.startInterface(')) + + def test_actual_preference_roundtrips_and_atomic_failure_rollback(self): + store = (ROOT/'examples/companion_radio/DataStore.cpp').read_text() + mesh = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text() + header = (ROOT/'examples/companion_radio/MyMesh.h').read_text() + methods = esp_recovery_helpers(store) + '\n'.join(method(store, signature) for signature in ( + 'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(', + 'bool DataStore::savePrefs(')) + prefix = STORE_HARNESS.split('int main(')[0].replace('@METHODS@', methods) + harness = PREFERENCE_HARNESS.replace('@SAVE_PREFERENCE@', method( + header, 'template bool savePreference(')) + harness = harness.replace('@PREFERENCE_METHODS@', '\n'.join(method(mesh, signature) + for signature in ('bool MyMesh::setBluetoothEnabledPreference(', + 'bool MyMesh::isBluetoothEnabledPreference('))) + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work/'Utils.h').write_text('''#pragma once +#include +#include +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 +#include +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 ')) + source = work/'preferences.cpp' + source.write_text(prefix+harness) + for flags in ('NRF52_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM', + 'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'): + with self.subTest(platform=flags): + binary = work/'preferences' + built = subprocess.run([os.environ.get('CXX','g++'), '-std=c++17', + *['-D'+flag+'=1' for flag in flags.split(',')], + '-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(source), + *[str(ROOT/'src/helpers'/name) for name in ('ConfigSerializer.cpp', + 'DynamicConfigSerializer.cpp','CommonRadioPrefs.cpp','TxtDataHelpers.cpp')], + '-o',str(binary)],capture_output=True,text=True) + self.assertEqual(built.returncode,0,built.stdout+built.stderr) + ran = subprocess.run([str(binary)],capture_output=True,text=True) + self.assertEqual(ran.returncode,0,ran.stdout+ran.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index fed49413c..27b519890 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -194,7 +194,7 @@ int main(int argc,char** argv){ } 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()-1); + legacy.fs.files["/new_prefs"].resize(disk.size()-2); CompanionNodePrefs loaded;double lat=0,lon=0; assert(legacy.loadPrefs(loaded,lat,lon)); assert(loaded.flood_retry_advert_enabled==0);