From 071ca663c2f8bed09b845e2bd06840ed66a5d73e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 14 Sep 2026 23:02:54 -0700 Subject: [PATCH] Add guarded Bluetooth CLI on/off with force override Add get bluetooth and set bluetooth on|off, with ble aliases. Require another active management connection before disabling Bluetooth unless off has a trailing force argument. Keep the toggle local to the current boot. Drain Bluetooth replies before disconnecting and recheck the remaining connection before applying a normal shutdown. Enabling Bluetooth cancels a pending shutdown. Document the commands and add compiled regression coverage to CI for native USB, HWCDC, UART, network clients, force, and timer rollover. Validation: CLI, Bluetooth, and USB regression tests passed. XIAO ESP32-S3 and RAK4631 Full Companion builds passed RAM, flash, and capability checks. --- .github/workflows/run-unit-tests.yml | 3 + docs/cli_commands.md | 33 +++ examples/companion_radio/CompanionBluetooth.h | 47 ++++ examples/companion_radio/MyMesh.cpp | 14 +- examples/companion_radio/main.cpp | 118 +++++++++- test/test_companion_bluetooth_control.py | 215 ++++++++++++++++++ 6 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 examples/companion_radio/CompanionBluetooth.h create mode 100644 test/test_companion_bluetooth_control.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index fd668ea5..d3365925 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -125,6 +125,9 @@ jobs: - name: Verify Companion contact persistence and OTA sleep guards run: python3 -B test/test_companion_contact_sleep_contract.py -v + - name: Verify Companion Bluetooth CLI connection protection + run: python3 -B test/test_companion_bluetooth_control.py -v + - name: Verify message reader buttons, touch targets, and footer layouts working-directory: test run: >- diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 7dbc177f..d2923405 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1410,6 +1410,39 @@ Station G2/G3 targets default to `off`. --- +#### Turn Bluetooth on or off (Companion) + +```text +get bluetooth +set bluetooth off +set bluetooth off force +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. + +Plain `off` requires another active management connection. A connected USB +terminal or binary client, TCP client, or browser terminal can provide that +connection. WiFi association, a listening server, and USB power alone do not. +On USB hardware without an observable open-host session (HWCDC or a UART +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. +`force` is accepted only as the final argument to `off`. + +Commands sent through Bluetooth allow its reply to drain before shutdown +(normally 250 ms, at most two seconds). `get bluetooth` shows `off pending` +during that wait. A normal shutdown is cancelled if the other management +connection disappears before it applies; `force` bypasses that check too. + +Bluetooth must be available in the current build and boot transport mode. +An exclusive-WiFi boot still requires `set companion.transport ble` and a +reboot before Bluetooth can be enabled. + #### View or change the independent Bluetooth name (Companion) **Usage:** diff --git a/examples/companion_radio/CompanionBluetooth.h b/examples/companion_radio/CompanionBluetooth.h new file mode 100644 index 00000000..435580d6 --- /dev/null +++ b/examples/companion_radio/CompanionBluetooth.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +enum class CompanionBluetoothCommandSource { Other, Terminal, Framed }; +enum class CompanionBluetoothCommand { None, Get, On, Off, ForceOff, Invalid }; + +inline const char* skipBluetoothCommandSpace(const char* text) { + while (*text == ' ' || *text == '\t') ++text; + return text; +} + +inline bool bluetoothCommandWord(const char* text, const char* word) { + const size_t length = strlen(word); + return strncmp(text, word, length) == 0 + && *skipBluetoothCommandSpace(text + length) == 0; +} + +inline CompanionBluetoothCommand parseCompanionBluetoothCommand(const char* text) { + if (!text) return CompanionBluetoothCommand::None; + text = skipBluetoothCommandSpace(text); + if (bluetoothCommandWord(text, "get bluetooth") + || bluetoothCommandWord(text, "get ble")) return CompanionBluetoothCommand::Get; + const char* value = nullptr; + const char* const prefixes[] = {"set bluetooth", "set ble"}; + for (const char* prefix : prefixes) { + const size_t length = strlen(prefix); + if (strncmp(text, prefix, length) == 0 + && (text[length] == 0 || text[length] == ' ' || text[length] == '\t')) { + value = skipBluetoothCommandSpace(text + length); + break; + } + } + if (!value) return CompanionBluetoothCommand::None; + if (bluetoothCommandWord(value, "on")) return CompanionBluetoothCommand::On; + if (bluetoothCommandWord(value, "off")) return CompanionBluetoothCommand::Off; + if (strncmp(value, "off", 3) == 0 && (value[3] == ' ' || value[3] == '\t') + && bluetoothCommandWord(skipBluetoothCommandSpace(value + 3), "force")) { + return CompanionBluetoothCommand::ForceOff; + } + return CompanionBluetoothCommand::Invalid; +} + +bool handleCompanionBluetoothCommand( + const char* command, char* reply, size_t reply_size, + CompanionBluetoothCommandSource source = CompanionBluetoothCommandSource::Other); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 177b8769..ad62bd72 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,5 +1,6 @@ #include #include "MyMesh.h" +#include "CompanionBluetooth.h" #if defined(MESH_SOAK_DIAGNOSTICS) #include "../../tools/hil/S3SoakDiagnostics.h" #endif @@ -2301,6 +2302,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; + if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true; + if (strcmp(command, "get powersaving") == 0 || strcmp(command, "powersaving") == 0) { snprintf(reply, reply_size, "> %s", _prefs.powersaving_enabled ? "on" : "off"); return true; @@ -5331,7 +5334,9 @@ void MyMesh::handleCmdFrame(size_t len) { char command[MAX_FRAME_SIZE] = {0}; memcpy(command, &cmd_frame[1], command_len); char reply[MAX_FRAME_SIZE] = {0}; - if (!handleLocalControlCommand(command, reply, sizeof(reply))) { + if (!handleCompanionBluetoothCommand(command, reply, sizeof(reply), + CompanionBluetoothCommandSource::Framed) + && !handleLocalControlCommand(command, reply, sizeof(reply))) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else { out_frame[0] = RESP_CODE_OK; @@ -7492,6 +7497,11 @@ void MyMesh::handleTerminalCommand(char* command) { #endif char local_reply[160]; + if (handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply), + CompanionBluetoothCommandSource::Terminal)) { + terminalOutput().printf(" %s\r\n", local_reply); + return; + } if (handleDirectCommand(command, local_reply, sizeof(local_reply))) { terminalOutput().printf(" %s\r\n", local_reply); return; @@ -7983,6 +7993,8 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" get bluetooth.name\r\n"); 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(" get bluetooth.mac\r\n"); terminalOutput().print( " set bluetooth.mac \r\n"); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 0cde0622..64b6c544 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2,6 +2,7 @@ #include #include #include "MyMesh.h" +#include "CompanionBluetooth.h" #include "CompanionWiFi.h" #if MESH_PACKET_LOGGING #include @@ -1797,7 +1798,9 @@ void halt() { ota_console_client.print("> "); #else char reply[160]; reply[0] = 0; - if (!the_mesh.handleLocalCommand(ota_console_line, reply) + if (!handleCompanionBluetoothCommand(ota_console_line, reply, sizeof(reply), + CompanionBluetoothCommandSource::Terminal) + && !the_mesh.handleLocalCommand(ota_console_line, reply) && !mesh::ota::handle_ota_command(ota_console_line, reply, board)) strcpy(reply, "Unknown local or OTA command"); ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> "); @@ -2249,6 +2252,118 @@ void halt() { } #endif +#if defined(BLE_PIN_CODE) +static uint32_t companion_bluetooth_off_at = 0; +static bool companion_bluetooth_force_off = false; + +static bool hasCompanionNonBluetoothClient() { + if (!interface_manager.isEnabled()) return false; + if (interface_manager.isInterfaceConnected(InterfaceType::WiFi) + || interface_manager.isInterfaceConnected(InterfaceType::Ethernet)) return true; +#if COMPANION_FEATURE_TEXT_TERMINAL \ + && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)) + if (the_mesh.isAnyNetworkTerminalMode()) return true; +#endif +#if defined(ENABLE_USB_INTERFACE) \ + && (defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ + || (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ + && (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0))) + // Native CDC reports an open host session (including nRF52's DTR-low + // MeshCLI session). HWCDC SOFs and UART bridges cannot prove an open app; + // those clients count only when they issue this command themselves. +#if COMPANION_FEATURE_USB_MOTA_SOURCE + if (usb_mota_mode) return false; +#endif + if (usb_serial_interface.isEnabled() && isUsbTerminalDataConnected()) return true; +#endif + return false; +} + +static void disableCompanionBluetoothForCli() { + companion_bluetooth_off_at = 0; + the_mesh.cancelSerialOperationsForRoute(&bluetooth_interface); + interface_manager.disableBluetooth(); + interface_manager.forgetReplyRouteForDisconnected(&bluetooth_interface); +} + +static void serviceCompanionBluetoothControl() { + if (companion_bluetooth_off_at == 0) return; + const int32_t elapsed = (int32_t)(millis() - companion_bluetooth_off_at); + if (elapsed < 0) return; + // The other host may have disconnected while the BLE reply was draining. + if (!companion_bluetooth_force_off && !hasCompanionNonBluetoothClient()) { + companion_bluetooth_off_at = 0; + mesh::usbLoggingPort().println( + "Bluetooth off cancelled: no other active management connection"); + return; + } + // 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; + disableCompanionBluetoothForCli(); +} +#endif + +bool handleCompanionBluetoothCommand(const char* command, char* reply, + size_t reply_size, + CompanionBluetoothCommandSource source) { + if (!reply || reply_size == 0) return false; + const auto action = parseCompanionBluetoothCommand(command); + if (action == CompanionBluetoothCommand::None) return false; + if (action == CompanionBluetoothCommand::Invalid) { + snprintf(reply, reply_size, "Error: use set bluetooth on|off [force]; force only with off"); + return true; + } +#if defined(BLE_PIN_CODE) + if (!companion_bluetooth_initialized) { + snprintf(reply, reply_size, "Error: Bluetooth unavailable in this boot; check transport mode/startup"); + return true; + } + if (action == CompanionBluetoothCommand::Get) { + snprintf(reply, reply_size, "bluetooth %s%s", + interface_manager.isBluetoothEnabled() ? "on" : "off", + companion_bluetooth_off_at ? " (off pending)" : ""); + return true; + } + if (action == CompanionBluetoothCommand::On) { + companion_bluetooth_off_at = 0; + interface_manager.enableBluetooth(); + snprintf(reply, reply_size, "%s", interface_manager.isBluetoothEnabled() + ? "OK - Bluetooth on (this boot)" : "Error: Bluetooth enable failed"); + return true; + } + if (!interface_manager.isBluetoothEnabled()) { + companion_bluetooth_off_at = 0; + snprintf(reply, reply_size, "OK - Bluetooth already off (this boot)"); + return true; + } + const bool force = action == CompanionBluetoothCommand::ForceOff; + BaseSerialInterface* route = interface_manager.captureReplyRoute(); + const bool non_bluetooth_requester = source == CompanionBluetoothCommandSource::Terminal + || (source == CompanionBluetoothCommandSource::Framed + && route != &bluetooth_interface + && interface_manager.isReplyRouteAvailable(route)); + if (!force && !non_bluetooth_requester && !hasCompanionNonBluetoothClient()) { + snprintf(reply, reply_size, + "Error: no other active management connection; use USB/TCP or set bluetooth off force"); + return true; + } + if (non_bluetooth_requester) { + disableCompanionBluetoothForCli(); + snprintf(reply, reply_size, "OK - Bluetooth off (this boot)"); + } 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)"); + } +#else + (void)source; + snprintf(reply, reply_size, "Error: Bluetooth is not supported in this build"); +#endif + return true; +} + #if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS static void logFullCompanionMemory(const char* stage) { mesh::usbLoggingPort().printf( @@ -2920,5 +3035,6 @@ void loop() { serviceDeferredCompanionBluetooth(); #endif serviceCompanionBluetoothIdentity(); + serviceCompanionBluetoothControl(); #endif } diff --git a/test/test_companion_bluetooth_control.py b/test/test_companion_bluetooth_control.py new file mode 100644 index 00000000..adcb9d50 --- /dev/null +++ b/test/test_companion_bluetooth_control.py @@ -0,0 +1,215 @@ +"""Execute Bluetooth CLI parsing, real transport routing, and shutdown guards.""" +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] +MAIN = (ROOT / 'examples/companion_radio/main.cpp').read_text() + +HARNESS = r''' +#include +#include +#include +#include +#include +#include "examples/companion_radio/CompanionBluetooth.h" +uint32_t now_ms=100; +uint32_t millis(){return now_ms;} +bool usb_open=false, usb_power=true, network_terminal=false; +bool companion_bluetooth_initialized=true, usb_mota_mode=false; +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;} + void disable() override {enabled=false;connected=false;++disabled;} + bool isEnabled() const override{return enabled;} + bool isConnected() const override{return connected;} + bool isReadBusy() const override{return false;} + bool isWriteBusy() const override{return busy;} + bool hasPendingIO() const override{return busy;} + size_t writeFrame(const uint8_t*,size_t n) override{return n;} + size_t checkRecvFrame(uint8_t* out) override{ + if(!frame)return 0; frame=false;out[0]=0x42;return 1; + } +} bluetooth_interface,usb_serial_interface,wifi_interface,ethernet_interface; +MultiSerialInterface interface_manager; +bool isUsbTerminalDataConnected(){return usb_open;} +unsigned cancelled=0,logs=0; +struct { + bool isAnyNetworkTerminalMode(){return network_terminal;} + void cancelSerialOperationsForRoute(BaseSerialInterface* route){ + assert(route==&bluetooth_interface);++cancelled; + } +} the_mesh; +namespace mesh { +struct Logger {void println(const char*){++logs;}} logger; +Logger& usbLoggingPort(){return logger;} +} +@METHODS@ +char reply[160]; +bool command(const char* text,CompanionBluetoothCommandSource source=CompanionBluetoothCommandSource::Framed){ + memset(reply,0,sizeof(reply)); + return handleCompanionBluetoothCommand(text,reply,sizeof(reply),source); +} +void route(Transport& target){ + target.connected=true;target.frame=true; + uint8_t frame[176];assert(interface_manager.checkRecvFrame(frame)==1); + assert(interface_manager.captureReplyRoute()==&target); +} +int main(){ +#if defined(BLE_PIN_CODE) + 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.enable();route(bluetooth_interface); + assert(command("get bluetooth")&&strstr(reply,"bluetooth on")); + for(const char* invalid : {"set ble","set bluetooth on force","set ble off force extra", + "set ble off forced","set bluetooth off junk","set ble yes"}){ + assert(command(invalid)&&strstr(reply,"Error:")); + assert(bluetooth_interface.disabled==0&&companion_bluetooth_off_at==0); + } + assert(!command("get bluetooth.mac")); + 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); + // 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:")); + wifi_interface.enabled=true;wifi_interface.connected=false; + assert(command("set ble off")&&strstr(reply,"Error:")); + // A real TCP client allows a delayed BLE response. Losing it cancels shutdown. + wifi_interface.connected=true; + assert(command("set ble off")&&strstr(reply,"requested")); + assert(command("get ble")&&strstr(reply,"off pending")); + 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); + // 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(); + assert(bluetooth_interface.enabled); + now_ms+=1749;serviceCompanionBluetoothControl();assert(bluetooth_interface.enabled); + ++now_ms;serviceCompanionBluetoothControl(); + assert(!bluetooth_interface.enabled&&cancelled==1); + 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); + // Another native CDC session counts; HWCDC/bridge presence alone doesn't. + usb_open=true;usb_serial_interface.connected=true; + assert(command("set ble off")); +#if defined(NRF52_PLATFORM) || (defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 0) + assert(strstr(reply,"requested")); + usb_open=false;usb_serial_interface.connected=false; + now_ms+=250;serviceCompanionBluetoothControl();assert(bluetooth_interface.enabled); +#else + assert(strstr(reply,"Error:"));usb_open=false;usb_serial_interface.connected=false; +#endif + // USB MOTA ownership cannot count as a separate management terminal. + usb_mota_mode=true;usb_open=true; + assert(command("set ble off")&&strstr(reply,"Error:")); + 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(!bluetooth_interface.enabled&&interface_manager.captureReplyRoute()==&usb_serial_interface); + assert(command("set ble on")&&bluetooth_interface.enabled); + // ASCII terminal commands cannot inherit the previous framed BLE route. + route(bluetooth_interface); + assert(command("set ble off",CompanionBluetoothCommandSource::Terminal)); + assert(!bluetooth_interface.enabled); + assert(command("set ble on"));route(bluetooth_interface); + // Active browser/TCP terminal and Ethernet sessions each qualify. + network_terminal=true; + assert(command("set ble off")&&strstr(reply,"requested")); + assert(command("set ble on")&&!companion_bluetooth_off_at); + now_ms+=300;serviceCompanionBluetoothControl();assert(bluetooth_interface.enabled); + network_terminal=false;ethernet_interface.connected=true; + 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; + // 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")); + now_ms=start+249;serviceCompanionBluetoothControl();assert(bluetooth_interface.enabled); + now_ms=start+251;serviceCompanionBluetoothControl();assert(!bluetooth_interface.enabled); + assert(command("set ble on"));route(bluetooth_interface); + } + companion_bluetooth_initialized=false; + assert(command("set ble on")&&strstr(reply,"unavailable")); + assert(command("set ble off force")&&strstr(reply,"unavailable")); + 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")); +#else + assert(command("get ble")&&strstr(reply,"not supported")); + assert(command("set ble off force")&&strstr(reply,"not supported")); +#endif + assert(!handleCompanionBluetoothCommand(nullptr,reply,sizeof(reply))); + assert(!handleCompanionBluetoothCommand("get ble",nullptr,sizeof(reply))); + assert(!handleCompanionBluetoothCommand("set ble off force",reply,0)); +} +''' + + +class BluetoothControlTests(unittest.TestCase): + def test_actual_commands_and_connection_guards(self): + guards = '\n'.join(method(MAIN, signature) for signature in ( + 'static bool hasCompanionNonBluetoothClient(', + 'static void disableCompanionBluetoothForCli(', + 'static void serviceCompanionBluetoothControl(')) + methods = '#if defined(BLE_PIN_CODE)\n' + guards + '\n#endif\n' + method( + MAIN, 'bool handleCompanionBluetoothCommand(') + configurations = { + 'no_ble': [], + 'nrf52': ['BLE_PIN_CODE=123456', 'NRF52_PLATFORM=1'], + 'esp32_tinyusb': ['BLE_PIN_CODE=123456', 'ESP32=1', + 'ARDUINO_USB_CDC_ON_BOOT=1', 'ARDUINO_USB_MODE=0'], + 'esp32_hwcdc': ['BLE_PIN_CODE=123456', 'ESP32=1', + 'ARDUINO_USB_CDC_ON_BOOT=1', 'ARDUINO_USB_MODE=1'], + 'esp32_uart': ['BLE_PIN_CODE=123456', 'ESP32=1'], + } + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + (folder/'Arduino.h').write_text( + '#pragma once\n#include \n#include \n#include \n') + source = folder/'bluetooth.cpp' + source.write_text(HARNESS.replace('@METHODS@', methods)) + for name, defines in configurations.items(): + with self.subTest(platform=name): + binary = folder/name + built = subprocess.run([ + os.environ.get('CXX','g++'), '-std=c++17', '-Wall', '-Wextra', + '-I',str(folder),'-I',str(ROOT),'-I',str(ROOT/'src'), + '-DENABLE_USB_INTERFACE=1', '-DCOMPANION_FEATURE_TEXT_TERMINAL=1', + '-DCOMPANION_FEATURE_NETWORK_TERMINAL=1', + '-DCOMPANION_FEATURE_USB_MOTA_SOURCE=1', + *['-D'+flag for flag in defines],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(') + framed = method(mesh,'void MyMesh::handleCmdFrame(') + local = method(mesh,'bool MyMesh::handleLocalControlCommand(') + self.assertIn('CompanionBluetoothCommandSource::Terminal',terminal) + self.assertIn('CompanionBluetoothCommandSource::Framed',framed) + self.assertIn('handleCompanionBluetoothCommand(command, reply, reply_size)',local) + loop = method(MAIN,'\nvoid loop()') + self.assertIn('serviceCompanionBluetoothControl();',loop) + + +if __name__ == '__main__': + unittest.main()