Persist Companion Bluetooth on-off preference across reboots

This commit is contained in:
mikecarper committed 2026-10-03 03:09:20 -07:00
1 parent c917158167
commit 4086222d85
11 files changed
+528 -21

No files matched your search

+3
View File
@@ -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'
+13 -2
View File
@@ -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
+15
View File
@@ -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);
+10 -1
View File
@@ -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<uint8_t>(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 <name|default>\r\n");
#if defined(BLE_PIN_CODE)
terminalOutput().print(" get bluetooth (alias: get ble)\r\n");
terminalOutput().print(" set bluetooth <on|off> [force] (this boot; force only with off)\r\n");
terminalOutput().print(" set bluetooth <on|off> [force] (saved; force only with off)\r\n");
terminalOutput().print(" get bluetooth.mac\r\n");
terminalOutput().print(
" set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>\r\n");
+2
View File
@@ -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();
+2
View File
@@ -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 {
+51 -13
View File
@@ -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();
+6
View File
@@ -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();
}
}
+152 -4
View File
@@ -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,&ethernet_interface);
interface_manager.setBluetoothAutoEnable(false);
interface_manager.enable();
assert(!bluetooth_interface.enabled&&bluetooth_interface.enables==0);
assert(usb_serial_interface.enabled&&wifi_interface.enabled&&ethernet_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 <cstdint>\n#include <cstddef>\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 <cstdint>\n#include <cstddef>\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(')
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <helpers/MultiSerialInterface.h>
#include <helpers/BluetoothMac.h>
#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<class... T> 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 <cstdint>\n#include <cstddef>\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 <typename T> 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 <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>'))
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()
@@ -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);