From 7f1bd8b1d21bebed4e56b6ecc9920ff8ff39d0d6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 8 Sep 2026 09:30:31 -0700 Subject: [PATCH] Add opt-in NimBLE Full Companion trials for V4 and XIAO S3 --- docs/nimble_companion_trial.md | 111 ++++++++++ platformio.nimble.ini | 30 +++ src/helpers/esp32/SerialBLEInterface.cpp | 195 ++++++++++++++++-- src/helpers/esp32/SerialBLEInterface.h | 38 +++- .../fixtures/nimble_companion/mocks/Arduino.h | 7 + .../nimble_companion/mocks/NimBLEDevice.h | 170 +++++++++++++++ .../fixtures/nimble_companion/mocks/esp_mac.h | 8 + .../mocks/freertos/FreeRTOS.h | 4 + .../nimble_companion/mocks/freertos/queue.h | 31 +++ .../test_nimble_companion.cpp | 192 +++++++++++++++++ test/test_nimble_companion.py | 30 +++ 11 files changed, 789 insertions(+), 27 deletions(-) create mode 100644 docs/nimble_companion_trial.md create mode 100644 platformio.nimble.ini create mode 100644 test/fixtures/nimble_companion/mocks/Arduino.h create mode 100644 test/fixtures/nimble_companion/mocks/NimBLEDevice.h create mode 100644 test/fixtures/nimble_companion/mocks/esp_mac.h create mode 100644 test/fixtures/nimble_companion/mocks/freertos/FreeRTOS.h create mode 100644 test/fixtures/nimble_companion/mocks/freertos/queue.h create mode 100644 test/fixtures/nimble_companion/test_nimble_companion.cpp create mode 100644 test/test_nimble_companion.py diff --git a/docs/nimble_companion_trial.md b/docs/nimble_companion_trial.md new file mode 100644 index 00000000..bd23a3a2 --- /dev/null +++ b/docs/nimble_companion_trial.md @@ -0,0 +1,111 @@ +# ESP32-S3 NimBLE Full Companion trial + +These optional builds use NimBLE-Arduino 2.5.1 with the existing ESP32 Arduino +2.0.17 toolchain. They are hardware qualification builds, outside the normal +release matrix and firmware picker. + +| Hardware | Trial environment | Contacts | Channels | Offline queue | +| --- | --- | ---: | ---: | ---: | +| Heltec V4.2/V4.3 OLED, FEM on | `heltec_v4_2_v4_3_companion_radio_full_femon_nimble` | 350 | 40 | 512 | +| XIAO ESP32-S3 with WIO SX1262 | `Xiao_S3_WIO_companion_radio_full_nimble` | 350 | 40 | 256 | + +The XIAO profile uses WIO radio pins CS 41, DIO1 39, BUSY 40 and RESET 42. +It is not the generic XIAO profile for separately wired radio modules. Both +boards retain their parent Full Companion features, USB mOTA sender, WiFi +update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT. + +## Build + +Enable the optional configuration in the ignored `platformio.local.ini`: + +```ini +[platformio] +extra_configs = + variants/*/platformio.ini + platformio.nimble.ini +``` + +If that file already contains local settings, merge this list with them. Run +one PlatformIO/build.sh command at a time. Both commands below use USA Cascade: + +```sh +OUTPUT_DIR=.releases/nimble-v4 bash build.sh build-firmware \ + heltec_v4_2_v4_3_companion_radio_full_femon_nimble \ + --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \ + --radio-preset usa-cascadia --profile cascade --standard --require-ota + +OUTPUT_DIR=.releases/nimble-xiao bash build.sh build-firmware \ + Xiao_S3_WIO_companion_radio_full_nimble \ + --firmware-version v1.17.1.5-halo-keymind-cascade-nimble-test \ + --radio-preset usa-cascadia --profile cascade --standard --require-ota +``` + +Use the merged image at address 0 for a USB installation. The V4 uses 16 MB +flash; the XIAO uses 8 MB. Both use DIO flash mode. Use the application-only +`.bin` for an existing, matching WiFi OTA layout. Remove the optional config +entry when finished to return to the ordinary release matrix. + +## Bluetooth identity and pairing + +All existing MAC settings remain available from the ASCII USB terminal and +the app's authenticated local CLI. A setting takes effect after reboot. + +| Command | Behavior | +| --- | --- | +| `get bluetooth.mac` | Show the saved MAC policy. | +| `set bluetooth.mac C2:17:15:04:00:01` | Use this custom random-static address. | +| `set bluetooth.mac random` | Generate one address and retain it across boots. | +| `set bluetooth.mac random-every-boot` | Generate a new address on each boot. | +| `set bluetooth.mac random-after-connect` | Rotate on the next boot after an authenticated connection; otherwise retain the address. | +| `set bluetooth.mac default` | Restore the factory Bluetooth address. | +| `set bluetooth.stealth on` | Pair once, then accept the saved bonded peer. | +| `set bluetooth.stealth off` | Restore ordinary discovery without changing the MAC policy. | +| `get bluetooth.stealth` | Show pairing or bonded-peer-only state. | + +The `ble.mac` and `ble.stealth` aliases also work. A literal custom address must +be a valid BLE random-static address (first byte C0-FF). Forget the old entry +in the phone's Bluetooth settings and pair again after an address change or +switching Bluetooth libraries. Existing Bluedroid bonds are not migrated. + +Stealth does not disable the selected rotation policy. If rotation changes +the address on reboot, first pairing opens again for the new identity. + +Pairing requires encryption, PIN authentication and a stored bond. NimBLE's +controller byte order is converted explicitly, so a custom address appears +over the air in the order entered. Failed identity setup stops advertising +and retries initialization instead of advertising the wrong address. + +## V4 WiFi setup QR + +The active WiFi setup page displays a compact QR code with a one-pixel white +border. The normal open setup network fits a 44 x 44 pixel code, with 2 x 2 +pixel modules. The SSID, portal address and `HOLD STOP` hint remain beside it. +Short-click to leave the page; hold the button on this page to start or stop +the setup AP. From USB, `start webconfig ap` starts the same setup session. + +## Regression checks + +```sh +python3 -B test/test_nimble_companion.py +python3 -B test/test_bluetooth_mac_contract.py +python3 -B test/test_bluetooth_pairing_ui.py +python3 -B test/test_heltec_v4_wifi_setup_page.py +python3 -B test/test_firmware_ram.py +python3 -B test/test_esp32_dram.py +python3 -B test/test_shared_mota_queue.py +pio test -e native -f test_companion_node_prefs \ + -f test_ble_tx_stall_watchdog -f test_companion_frame_queue \ + -f test_display_driver +``` + +The adapter test compiles the actual transport under address/undefined-behavior +sanitizers with API doubles. It injects allocation, identity and bond failures; +checks PIN authentication and stealth; and exercises 176-byte frames, MTU and +notification subscription gating, retry and duplicate prevention. Embedded +builds compile against the pinned real library. RAM reports retain the existing +wireless budget; linked headroom is not a measurement of live free heap. + +Hardware qualification must additionally verify pairing and reconnection, +advertised addresses for every MAC policy, contact synchronization, WiFi/BLE +coexistence, and uptime under load. BlueZ results do not establish iOS or +Android camera/pairing compatibility. diff --git a/platformio.nimble.ini b/platformio.nimble.ini new file mode 100644 index 00000000..68470bc0 --- /dev/null +++ b/platformio.nimble.ini @@ -0,0 +1,30 @@ +; Opt-in hardware qualification builds. This file is not part of the normal +; release matrix. See docs/nimble_companion_trial.md for activation and tests. +[companion_nimble_trial] +build_flags = + -D MESH_USE_NIMBLE_ARDUINO=1 + -D CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + -D CONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED=1 + -D CONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED=1 +lib_deps = h2zero/NimBLE-Arduino @ 2.5.1 +lib_ignore = ESP32 BLE Arduino + +[env:heltec_v4_2_v4_3_companion_radio_full_femon_nimble] +extends = env:heltec_v4_2_v4_3_companion_radio_full_femon +build_flags = + ${env:heltec_v4_2_v4_3_companion_radio_full_femon.build_flags} + ${companion_nimble_trial.build_flags} +lib_deps = + ${env:heltec_v4_companion_radio_wifi_mqtt_femon.lib_deps} + ${companion_nimble_trial.lib_deps} +lib_ignore = ${companion_nimble_trial.lib_ignore} + +[env:Xiao_S3_WIO_companion_radio_full_nimble] +extends = env:Xiao_S3_WIO_companion_radio_full +build_flags = + ${env:Xiao_S3_WIO_companion_radio_full.build_flags} + ${companion_nimble_trial.build_flags} +lib_deps = + ${env:Xiao_S3_WIO_companion_radio_wifi.lib_deps} + ${companion_nimble_trial.lib_deps} +lib_ignore = ${companion_nimble_trial.lib_ignore} diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 2b4459f8..e07655d8 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -3,10 +3,10 @@ #include "../CompanionFrameQueue.h" #include "esp_mac.h" #include -#if defined(CONFIG_BLUEDROID_ENABLED) +#if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO) #include "esp_gap_ble_api.h" #endif -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE && !defined(MESH_USE_NIMBLE_ARDUINO) #include #endif @@ -29,8 +29,17 @@ extern "C" bool bleInUse(void) { #define ADVERT_RESTART_DELAY 1000 // millis #define BLE_BOND_PERSIST_TIMEOUT_MS 15000 +static void cleanupFailedBluetoothStart() { +#if defined(MESH_USE_NIMBLE_ARDUINO) + BLEDevice::deinit(true); +#else + BLEDevice::deinit(false); +#endif +} + +#if !defined(MESH_USE_NIMBLE_ARDUINO) static void clearStoredBluetoothBonds() { -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE const int result = ble_store_clear(); if (result != 0) { BLE_DEBUG_PRINTLN("Could not clear NimBLE bonds: %d", result); @@ -59,6 +68,8 @@ static void clearStoredBluetoothBonds() { #endif } +#endif + static bool bluetoothPeersEqual( const mesh::companion::BluetoothPeerIdentity& lhs, const mesh::companion::BluetoothPeerIdentity& rhs) { @@ -81,7 +92,7 @@ void SerialBLEInterface::noteSuccessfulConnection( } } -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE static void bluetoothPeerFromNimbleAddress( const ble_addr_t& address, mesh::companion::BluetoothPeerIdentity& peer) { @@ -110,6 +121,9 @@ static ble_addr_t nimbleAddressFromBluetoothPeer( static bool nimbleBondExistsForPeer( const mesh::companion::BluetoothPeerIdentity& peer) { +#if defined(MESH_USE_NIMBLE_ARDUINO) + return BLEDevice::isBonded(BLEAddress(nimbleAddressFromBluetoothPeer(peer))); +#else ble_addr_t bonded_peers[8]; int count = 0; if (ble_store_util_bonded_peers( @@ -123,6 +137,7 @@ static bool nimbleBondExistsForPeer( if (bluetoothPeersEqual(candidate, peer)) return true; } return false; +#endif } #else static void bluetoothPeerFromBluedroidBond( @@ -181,7 +196,7 @@ static bool findBluedroidBondedPeer( bool SerialBLEInterface::resolveSuccessfulPeer( mesh::companion::BluetoothPeerIdentity& peer) const { -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE if (!mesh::companion::isValidBluetoothPeerIdentity(_successfulPeer) || !nimbleBondExistsForPeer(_successfulPeer)) { return false; @@ -204,7 +219,7 @@ bool SerialBLEInterface::configureBondedOnlyAdvertising( return false; } -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE if (require_stored_bond && !nimbleBondExistsForPeer(peer)) { requestBondedOnlyRecovery("saved peer bond is unavailable"); return false; @@ -250,8 +265,13 @@ bool SerialBLEInterface::configureBondedOnlyAdvertising( } advertising->stop(); BLEAdvertisementData minimal_advertisement; +#if defined(MESH_USE_NIMBLE_ARDUINO) + minimal_advertisement.setFlags(BLE_HS_ADV_F_BREDR_UNSUP); + advertising->enableScanResponse(false); +#else minimal_advertisement.setFlags(ESP_BLE_ADV_FLAG_BREDR_NOT_SPT); advertising->setScanResponse(false); +#endif advertising->setScanFilter(true, true); advertising->setAdvertisementData(minimal_advertisement); @@ -325,7 +345,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, } // Create the BLE Device -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE if (!BLEDevice::init(dev_name)) { BLE_DEBUG_PRINTLN("BLEDevice::init failed"); return false; @@ -336,9 +356,17 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, BLEDevice::init(dev_name); #endif +#if defined(MESH_USE_NIMBLE_ARDUINO) + if (clear_bonds && !BLEDevice::deleteAllBonds()) { + BLE_DEBUG_PRINTLN("Could not clear NimBLE bonds for the new identity"); + cleanupFailedBluetoothStart(); + return false; + } +#else if (clear_bonds) clearStoredBluetoothBonds(); +#endif -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE if (custom_address != nullptr) { uint8_t native_address[mesh::companion::BLUETOOTH_MAC_BYTES]; for (size_t i = 0; i < mesh::companion::BLUETOOTH_MAC_BYTES; i++) { @@ -348,19 +376,32 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, if (!BLEDevice::setOwnAddr(native_address) || !BLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM)) { BLE_DEBUG_PRINTLN("Custom Bluetooth MAC setup failed"); - BLEDevice::deinit(false); + cleanupFailedBluetoothStart(); return false; } } +#if defined(MESH_USE_NIMBLE_ARDUINO) + else if (!BLEDevice::setOwnAddrType(BLE_OWN_ADDR_PUBLIC)) { + cleanupFailedBluetoothStart(); + return false; + } +#endif #endif +#if !defined(MESH_USE_NIMBLE_ARDUINO) BLEDevice::setSecurityCallbacks(this); +#endif // ATT notifications consume three bytes of the negotiated MTU. Reserve // that overhead so a MAX_FRAME_SIZE protocol frame fits without truncation. BLEDevice::setMTU(MAX_FRAME_SIZE + 3); +#if defined(MESH_USE_NIMBLE_ARDUINO) + BLEDevice::setSecurityPasskey(pin_code); + BLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_ONLY); + BLEDevice::setSecurityAuth(true, true, true); +#else BLESecurity sec; -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE sec.setPassKey(true, pin_code); // A passkey alone does not provide MITM protection when the controller's // default capability is NoInputNoOutput: the peers can silently fall back @@ -373,6 +414,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, sec.setStaticPIN(pin_code); sec.setCapability(ESP_IO_CAP_OUT); sec.setAuthenticationMode(ESP_LE_AUTH_REQ_SC_MITM_BOND); +#endif #endif //BLEDevice::setPower(ESP_PWR_LVL_N8); @@ -381,12 +423,18 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, pServer = BLEDevice::createServer(); if (pServer == NULL) { BLE_DEBUG_PRINTLN("BLEDevice::createServer failed"); - BLEDevice::deinit(false); + cleanupFailedBluetoothStart(); return false; } +#if defined(MESH_USE_NIMBLE_ARDUINO) + // The interface is owned by the application, not by the NimBLE server. + pServer->setCallbacks(this, false); + pServer->advertiseOnDisconnect(false); +#else pServer->setCallbacks(this); +#endif -#if !defined(CONFIG_NIMBLE_ENABLED) +#if !MESH_BLE_USES_NIMBLE if (custom_address != nullptr) { esp_bd_addr_t native_address; memcpy(native_address, custom_address, sizeof(native_address)); @@ -402,33 +450,41 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, pService = pServer->createService(SERVICE_UUID); if (pService == NULL) { BLE_DEBUG_PRINTLN("BLEServer::createService failed"); - BLEDevice::deinit(false); + cleanupFailedBluetoothStart(); pServer = NULL; return false; } // Create a BLE Characteristic +#if defined(MESH_USE_NIMBLE_ARDUINO) + uint32_t tx_properties = NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY | + NIMBLE_PROPERTY::READ_AUTHEN; + uint32_t rx_properties = NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_AUTHEN; +#else uint32_t tx_properties = BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY; uint32_t rx_properties = BLECharacteristic::PROPERTY_WRITE; -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE // NimBLE ignores setAccessPermissions(). Authentication requirements must // be part of the characteristic properties instead. tx_properties |= BLECharacteristic::PROPERTY_READ_AUTHEN; rx_properties |= BLECharacteristic::PROPERTY_WRITE_AUTHEN; +#endif #endif pTxCharacteristic = pService->createCharacteristic( CHARACTERISTIC_UUID_TX, tx_properties); if (pTxCharacteristic == NULL) { BLE_DEBUG_PRINTLN("BLEService::createCharacteristic(TX) failed"); - BLEDevice::deinit(false); + cleanupFailedBluetoothStart(); pServer = NULL; pService = NULL; return false; } +#if !defined(MESH_USE_NIMBLE_ARDUINO) pTxCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENC_MITM); +#endif pTxCharacteristic->setCallbacks(this); -#if !defined(CONFIG_NIMBLE_ENABLED) +#if !MESH_BLE_USES_NIMBLE // NimBLE creates and owns the 0x2902 descriptor automatically. pTxDescriptor = new BLE2902(); // Make notification setup start/finish pairing before the client begins its @@ -444,16 +500,24 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, CHARACTERISTIC_UUID_RX, rx_properties); if (pRxCharacteristic == NULL) { BLE_DEBUG_PRINTLN("BLEService::createCharacteristic(RX) failed"); - BLEDevice::deinit(false); + cleanupFailedBluetoothStart(); pServer = NULL; pService = NULL; pTxCharacteristic = NULL; +#if !defined(MESH_USE_NIMBLE_ARDUINO) pTxDescriptor = NULL; +#endif return false; } +#if !defined(MESH_USE_NIMBLE_ARDUINO) pRxCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENC_MITM); +#endif pRxCharacteristic->setCallbacks(this); +#if defined(MESH_USE_NIMBLE_ARDUINO) + pServer->getAdvertising()->setName(dev_name); + pServer->getAdvertising()->enableScanResponse(true); +#endif pServer->getAdvertising()->addServiceUUID(SERVICE_UUID); if (bonded_only_peer != nullptr) { configureBondedOnlyAdvertising(*bonded_only_peer, true); @@ -463,6 +527,27 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, // -------- BLESecurityCallbacks methods +#if defined(MESH_USE_NIMBLE_ARDUINO) +uint32_t SerialBLEInterface::onPassKeyDisplay() { + _pairingRequestPending.store(true, std::memory_order_release); + return _pin_code; +} + +void SerialBLEInterface::onAuthenticationComplete(NimBLEConnInfo& info) { + if (info.isEncrypted() && info.isAuthenticated() && info.isBonded()) { + deviceConnected = true; + mesh::companion::BluetoothPeerIdentity peer; + bluetoothPeerFromNimbleAddress(*info.getIdAddress().getBase(), peer); + noteSuccessfulConnection(peer); + } else { + BLEDevice::deleteBond(info.getIdAddress()); + deviceConnected = false; + if (_bonded_only) requestBondedOnlyRecovery("bond authentication failed"); + pServer->disconnect(info.getConnHandle()); + if (advertisingAllowed()) scheduleAdvertisingRestart((uint32_t)millis()); + } +} +#else uint32_t SerialBLEInterface::onPassKeyRequest() { BLE_DEBUG_PRINTLN("onPassKeyRequest()"); _pairingRequestPending.store(true, std::memory_order_release); @@ -485,7 +570,7 @@ bool SerialBLEInterface::onSecurityRequest() { return true; // allow } -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE void SerialBLEInterface::onAuthenticationComplete(ble_gap_conn_desc* desc) { const bool success = desc != NULL && desc->sec_state.encrypted && desc->sec_state.authenticated @@ -558,12 +643,31 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) { } #endif +#endif + // -------- BLEServerCallbacks methods +#if defined(MESH_USE_NIMBLE_ARDUINO) +void SerialBLEInterface::onConnect(BLEServer* pServer, NimBLEConnInfo& info) { + last_conn_id = info.getConnHandle(); + deviceConnected = false; + oldDeviceConnected = false; + notifySucceeded = false; + notificationsEnabled.store(false, std::memory_order_release); + xQueueReset(recv_queue); + _tx_reset_pending.store(true, std::memory_order_release); + _adv_restart_pending = false; +} + +void SerialBLEInterface::onMTUChange(uint16_t mtu, NimBLEConnInfo& info) { + (void)info; + BLE_DEBUG_PRINTLN("onMtuChanged(), mtu=%d", mtu); +} +#else void SerialBLEInterface::onConnect(BLEServer* pServer) { } -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE void SerialBLEInterface::onConnect(BLEServer* pServer, ble_gap_conn_desc* desc) { BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", desc->conn_handle, pServer->getPeerMTU(desc->conn_handle)); @@ -597,7 +701,9 @@ void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t xQueueReset(recv_queue); _tx_reset_pending.store(true, std::memory_order_release); _adv_restart_pending = false; +#if !defined(MESH_USE_NIMBLE_ARDUINO) if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false); +#endif } void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) { @@ -605,14 +711,25 @@ void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param } #endif +#endif + +#if defined(MESH_USE_NIMBLE_ARDUINO) +void SerialBLEInterface::onDisconnect(BLEServer* pServer, NimBLEConnInfo& info, + int reason) { + (void)info; + (void)reason; +#else void SerialBLEInterface::onDisconnect(BLEServer* pServer) { +#endif BLE_DEBUG_PRINTLN("onDisconnect()"); deviceConnected = false; notifySucceeded = false; notificationsEnabled.store(false, std::memory_order_release); xQueueReset(recv_queue); _tx_reset_pending.store(true, std::memory_order_release); +#if !defined(MESH_USE_NIMBLE_ARDUINO) if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false); +#endif if (advertisingAllowed()) { scheduleAdvertisingRestart((uint32_t)millis()); } @@ -620,7 +737,11 @@ void SerialBLEInterface::onDisconnect(BLEServer* pServer) { // -------- BLECharacteristicCallbacks methods -#if defined(CONFIG_NIMBLE_ENABLED) +#if defined(MESH_USE_NIMBLE_ARDUINO) +void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, + NimBLEConnInfo& info) { + if (!info.isEncrypted() || !info.isAuthenticated() || !info.isBonded()) return; +#elif MESH_BLE_USES_NIMBLE void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, ble_gap_conn_desc* desc) { (void)desc; @@ -634,8 +755,14 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, return; } +#if defined(MESH_USE_NIMBLE_ARDUINO) + const auto value = pCharacteristic->getValue(); + const uint8_t* rxValue = value.data(); + const int len = value.size(); +#else uint8_t* rxValue = pCharacteristic->getData(); int len = pCharacteristic->getLength(); +#endif if (len > MAX_FRAME_SIZE) { BLE_DEBUG_PRINTLN("ERROR: onWrite(), frame too big, len=%d", len); @@ -650,11 +777,17 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, } } -#if defined(CONFIG_NIMBLE_ENABLED) +#if defined(MESH_USE_NIMBLE_ARDUINO) +void SerialBLEInterface::onSubscribe(BLECharacteristic* pCharacteristic, + NimBLEConnInfo& info, uint16_t subValue) { + (void)info; +#elif MESH_BLE_USES_NIMBLE void SerialBLEInterface::onSubscribe(BLECharacteristic* pCharacteristic, ble_gap_conn_desc* desc, uint16_t subValue) { (void)desc; +#endif +#if MESH_BLE_USES_NIMBLE if (pCharacteristic == pTxCharacteristic) { // NimBLE uses bit 0 for notification subscriptions. notificationsEnabled.store((subValue & 0x0001u) != 0, @@ -663,6 +796,7 @@ void SerialBLEInterface::onSubscribe(BLECharacteristic* pCharacteristic, } #endif +#if !defined(MESH_USE_NIMBLE_ARDUINO) void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) { (void)pCharacteristic; notifySucceeded = status == SUCCESS_NOTIFY; @@ -672,6 +806,8 @@ void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status sta } } +#endif + // ---------- public methods bool SerialBLEInterface::takeSuccessfulConnection( @@ -784,7 +920,14 @@ void SerialBLEInterface::enable() { clearBuffers(); // Start the service +#if defined(MESH_USE_NIMBLE_ARDUINO) + if (!pServer->start()) { + _isEnabled = false; + return; + } +#else pService->start(); +#endif // Start advertising @@ -804,7 +947,9 @@ void SerialBLEInterface::disable() { pServer->getAdvertising()->stop(); pServer->disconnect(last_conn_id); +#if !defined(MESH_USE_NIMBLE_ARDUINO) pService->stop(); +#endif oldDeviceConnected = deviceConnected = false; clearBuffers(); _adv_restart_pending = false; @@ -852,7 +997,7 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { BLE_WRITE_MIN_INTERVAL) // space the writes apart ) { const uint16_t peer_mtu = pServer->getPeerMTU(last_conn_id); -#if defined(CONFIG_NIMBLE_ENABLED) +#if MESH_BLE_USES_NIMBLE const bool notifications_ready = notificationsEnabled.load(std::memory_order_acquire); #else @@ -870,7 +1015,13 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { _last_write = now; notifySucceeded = false; pTxCharacteristic->setValue(send_queue[0].buf, send_queue[0].len); +#if defined(MESH_USE_NIMBLE_ARDUINO) + // NimBLE reports queue acceptance directly. Its status callback may run + // later; do not retain or duplicate an already accepted frame awaiting it. + notifySucceeded = pTxCharacteristic->notify(last_conn_id); +#else pTxCharacteristic->notify(); +#endif if (notifySucceeded) { BLE_DEBUG_PRINTLN("writeBytes: sz=%d, hdr=%d", (uint32_t)send_queue[0].len, (uint32_t) send_queue[0].buf[0]); diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 41faf4a1..7b1a06bf 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -4,19 +4,34 @@ #include "../BluetoothMac.h" #include "../BleTxStallWatchdog.h" #include "../UsbLogging.h" -#include -#include -#include -#include +#if defined(MESH_USE_NIMBLE_ARDUINO) + #include +#else + #include + #include + #include + #include +#endif +#if defined(MESH_USE_NIMBLE_ARDUINO) || defined(CONFIG_NIMBLE_ENABLED) + #define MESH_BLE_USES_NIMBLE 1 +#else + #define MESH_BLE_USES_NIMBLE 0 +#endif #include #include #include -class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLEServerCallbacks, BLECharacteristicCallbacks { +class SerialBLEInterface : public BaseSerialInterface, +#if !defined(MESH_USE_NIMBLE_ARDUINO) + public BLESecurityCallbacks, +#endif + public BLEServerCallbacks, public BLECharacteristicCallbacks { BLEServer *pServer; BLEService *pService; BLECharacteristic * pTxCharacteristic; +#if !defined(MESH_USE_NIMBLE_ARDUINO) BLE2902 *pTxDescriptor; +#endif bool deviceConnected; bool oldDeviceConnected; bool notifySucceeded; @@ -67,6 +82,16 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE bool advertisingAllowed() const; protected: +#if defined(MESH_USE_NIMBLE_ARDUINO) + uint32_t onPassKeyDisplay() override; + void onAuthenticationComplete(NimBLEConnInfo& info) override; + void onConnect(BLEServer* server, NimBLEConnInfo& info) override; + void onDisconnect(BLEServer* server, NimBLEConnInfo& info, int reason) override; + void onMTUChange(uint16_t mtu, NimBLEConnInfo& info) override; + void onWrite(BLECharacteristic* characteristic, NimBLEConnInfo& info) override; + void onSubscribe(BLECharacteristic* characteristic, NimBLEConnInfo& info, + uint16_t value) override; +#else // BLESecurityCallbacks methods uint32_t onPassKeyRequest() override; void onPassKeyNotify(uint32_t pass_key) override; @@ -98,13 +123,16 @@ protected: void onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) override; #endif void onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) override; +#endif public: SerialBLEInterface() { pServer = NULL; pService = NULL; pTxCharacteristic = NULL; +#if !defined(MESH_USE_NIMBLE_ARDUINO) pTxDescriptor = NULL; +#endif deviceConnected = false; oldDeviceConnected = false; notifySucceeded = false; diff --git a/test/fixtures/nimble_companion/mocks/Arduino.h b/test/fixtures/nimble_companion/mocks/Arduino.h new file mode 100644 index 00000000..2d295a98 --- /dev/null +++ b/test/fixtures/nimble_companion/mocks/Arduino.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include +#include +#include +inline uint32_t test_millis = 100; +inline unsigned long millis() { return test_millis; } diff --git a/test/fixtures/nimble_companion/mocks/NimBLEDevice.h b/test/fixtures/nimble_companion/mocks/NimBLEDevice.h new file mode 100644 index 00000000..951077f1 --- /dev/null +++ b/test/fixtures/nimble_companion/mocks/NimBLEDevice.h @@ -0,0 +1,170 @@ +#pragma once +// Test doubles for the pinned NimBLE-Arduino 2.5.1 API. Firmware builds check +// the real headers/library; this harness injects controller and bonding faults +// into the actual SerialBLEInterface implementation. +#include +#include +#include +#include +#include +#include + +constexpr uint8_t BLE_ADDR_PUBLIC = 0, BLE_ADDR_RANDOM = 1; +constexpr uint8_t BLE_OWN_ADDR_PUBLIC = 0, BLE_OWN_ADDR_RANDOM = 1; +constexpr uint8_t BLE_HS_IO_DISPLAY_ONLY = 0, BLE_HS_ADV_F_BREDR_UNSUP = 4; +struct ble_addr_t { uint8_t type = 0; uint8_t val[6] = {}; }; +namespace NIMBLE_PROPERTY { +constexpr uint32_t READ = 1, NOTIFY = 2, READ_AUTHEN = 4, WRITE = 8, WRITE_AUTHEN = 16; +} +struct NimBLEAddress { + ble_addr_t value; + explicit NimBLEAddress(const ble_addr_t& address) : value(address) {} + const ble_addr_t* getBase() const { return &value; } +}; +struct NimBLEConnInfo { + ble_addr_t identity = {BLE_ADDR_PUBLIC, {0x8e, 0x6e, 0xf9, 0xeb, 0x27, 0xb8}}; + bool encrypted = true, authenticated = true, bonded = true; + uint16_t handle = 7; + bool isEncrypted() const { return encrypted; } + bool isAuthenticated() const { return authenticated; } + bool isBonded() const { return bonded; } + uint16_t getConnHandle() const { return handle; } + NimBLEAddress getIdAddress() const { return NimBLEAddress(identity); } +}; +class NimBLEServer; +class NimBLECharacteristic; +struct NimBLEServerCallbacks { + virtual ~NimBLEServerCallbacks() = default; + virtual uint32_t onPassKeyDisplay() { return 0; } + virtual void onAuthenticationComplete(NimBLEConnInfo&) {} + virtual void onConnect(NimBLEServer*, NimBLEConnInfo&) {} + virtual void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) {} + virtual void onMTUChange(uint16_t, NimBLEConnInfo&) {} +}; +struct NimBLECharacteristicCallbacks { + virtual ~NimBLECharacteristicCallbacks() = default; + virtual void onWrite(NimBLECharacteristic*, NimBLEConnInfo&) {} + virtual void onSubscribe(NimBLECharacteristic*, NimBLEConnInfo&, uint16_t) {} +}; +struct NimBLEAdvertisementData { + uint8_t flags = 0; + bool setFlags(uint8_t value) { flags = value; return true; } +}; +namespace fake_nimble { +inline bool initialized = false, persisted = true, accept_notification = true; +inline std::string failure; +inline std::vector calls; +inline uint8_t address[6] = {}, own_type = BLE_OWN_ADDR_PUBLIC, io_cap = 255; +inline ble_addr_t allowlisted; +inline uint16_t mtu = 179; +inline uint32_t pin = 0; +inline bool bonding = false, mitm = false, sc = false; +inline std::vector> transmitted; +inline unsigned disconnects = 0; +} +inline int ble_gap_wl_set(const ble_addr_t* peers, uint8_t count) { + assert(count == 1); + fake_nimble::allowlisted = *peers; + return fake_nimble::failure == "allowlist" ? 1 : 0; +} +struct NimBLEAdvertising { + bool active = false, scan_response = false, scan_filter = false, connect_filter = false; + std::string name, service; + bool start(uint32_t = 0, const NimBLEAddress* = nullptr) { + fake_nimble::calls.push_back("advertise"); active = true; return true; + } + bool stop() { active = false; return true; } + void enableScanResponse(bool value) { scan_response = value; } + void setScanFilter(bool scan, bool connect) { scan_filter = scan; connect_filter = connect; } + bool setAdvertisementData(const NimBLEAdvertisementData&) { name.clear(); service.clear(); return true; } + bool setName(const std::string& value) { name = value; return true; } + bool addServiceUUID(const char* value) { service = value; return true; } +}; +struct NimBLECharacteristic { + std::string uuid; + uint32_t properties; + NimBLECharacteristicCallbacks* callbacks = nullptr; + std::vector value; + NimBLECharacteristic(const char* id, uint32_t props) : uuid(id), properties(props) {} + void setCallbacks(NimBLECharacteristicCallbacks* cb) { callbacks = cb; } + const std::vector getValue() const { return value; } + void setValue(const uint8_t* data, size_t size) { value.assign(data, data + size); } + bool notify(uint16_t handle = 0xffff) const { + assert(handle == 7); + if (!fake_nimble::accept_notification) return false; + fake_nimble::transmitted.push_back(value); + return true; + } +}; +struct NimBLEService { + std::vector> characteristics; + NimBLECharacteristic* createCharacteristic(const char* id, uint32_t props) { + if (fake_nimble::failure == (characteristics.empty() ? "tx" : "rx")) return nullptr; + characteristics.emplace_back(new NimBLECharacteristic(id, props)); + return characteristics.back().get(); + } +}; +struct NimBLEServer { + NimBLEAdvertising advertising; + std::unique_ptr service; + NimBLEServerCallbacks* callbacks = nullptr; + bool owns_callbacks = true, auto_advertise = true, connected = false; + ~NimBLEServer() { if (owns_callbacks) delete callbacks; } + void setCallbacks(NimBLEServerCallbacks* cb, bool take_ownership = true) { + callbacks = cb; owns_callbacks = take_ownership; + } + void advertiseOnDisconnect(bool value) { auto_advertise = value; } + bool start() { return true; } + uint8_t getConnectedCount() const { return connected; } + uint16_t getPeerMTU(uint16_t) const { return fake_nimble::mtu; } + bool disconnect(uint16_t) const { ++fake_nimble::disconnects; return true; } + NimBLEAdvertising* getAdvertising() { return &advertising; } + NimBLEService* createService(const char*) { + if (fake_nimble::failure == "service") return nullptr; + service.reset(new NimBLEService()); return service.get(); + } +}; +struct NimBLEDevice { + inline static std::unique_ptr server; + static bool init(const std::string&) { + fake_nimble::calls.push_back("init"); + return fake_nimble::initialized = fake_nimble::failure != "init"; + } + static bool deinit(bool clear = false) { + fake_nimble::initialized = false; + if (clear) server.reset(); + return true; + } + static bool deleteAllBonds() { fake_nimble::calls.push_back("clear-bonds"); return fake_nimble::failure != "clear-bonds"; } + static bool deleteBond(const NimBLEAddress&) { return true; } + static bool isBonded(const NimBLEAddress&) { return fake_nimble::persisted; } + static bool setOwnAddr(const uint8_t* address) { + fake_nimble::calls.push_back("address"); + memcpy(fake_nimble::address, address, 6); + return fake_nimble::failure != "address"; + } + static bool setOwnAddrType(uint8_t type) { + fake_nimble::calls.push_back("address-type"); fake_nimble::own_type = type; + return fake_nimble::failure != "address-type"; + } + static bool setMTU(uint16_t mtu) { fake_nimble::mtu = mtu; return true; } + static void setSecurityPasskey(uint32_t pin) { fake_nimble::pin = pin; } + static void setSecurityIOCap(uint8_t value) { fake_nimble::io_cap = value; } + static void setSecurityAuth(bool bond, bool mitm, bool sc) { + fake_nimble::bonding = bond; fake_nimble::mitm = mitm; fake_nimble::sc = sc; + } + static NimBLEServer* createServer() { + if (fake_nimble::failure == "server") return nullptr; + if (!server) server.reset(new NimBLEServer()); + return server.get(); + } +}; +#define BLEDevice NimBLEDevice +#define BLEServer NimBLEServer +#define BLEService NimBLEService +#define BLECharacteristic NimBLECharacteristic +#define BLEServerCallbacks NimBLEServerCallbacks +#define BLECharacteristicCallbacks NimBLECharacteristicCallbacks +#define BLEAddress NimBLEAddress +#define BLEAdvertising NimBLEAdvertising +#define BLEAdvertisementData NimBLEAdvertisementData diff --git a/test/fixtures/nimble_companion/mocks/esp_mac.h b/test/fixtures/nimble_companion/mocks/esp_mac.h new file mode 100644 index 00000000..750308ff --- /dev/null +++ b/test/fixtures/nimble_companion/mocks/esp_mac.h @@ -0,0 +1,8 @@ +#pragma once +#include +#include +inline int esp_efuse_mac_get_default(uint8_t* address) { + const uint8_t factory[] = {0x44, 0x1b, 0xf6, 0x69, 0xcf, 0x98}; + memcpy(address, factory, sizeof(factory)); + return 0; +} diff --git a/test/fixtures/nimble_companion/mocks/freertos/FreeRTOS.h b/test/fixtures/nimble_companion/mocks/freertos/FreeRTOS.h new file mode 100644 index 00000000..69caca18 --- /dev/null +++ b/test/fixtures/nimble_companion/mocks/freertos/FreeRTOS.h @@ -0,0 +1,4 @@ +#pragma once +#include +using BaseType_t = int; +constexpr BaseType_t pdTRUE = 1; diff --git a/test/fixtures/nimble_companion/mocks/freertos/queue.h b/test/fixtures/nimble_companion/mocks/freertos/queue.h new file mode 100644 index 00000000..87854432 --- /dev/null +++ b/test/fixtures/nimble_companion/mocks/freertos/queue.h @@ -0,0 +1,31 @@ +#pragma once +#include "FreeRTOS.h" +#include +#include +#include +struct StaticQueue_t { + size_t capacity = 0, size = 0; + std::deque> frames; +}; +using QueueHandle_t = StaticQueue_t*; +inline QueueHandle_t xQueueCreateStatic(size_t capacity, size_t size, + uint8_t*, StaticQueue_t* queue) { + queue->capacity = capacity; + queue->size = size; + queue->frames.clear(); + return queue; +} +inline size_t uxQueueMessagesWaiting(QueueHandle_t queue) { return queue->frames.size(); } +inline int xQueueReset(QueueHandle_t queue) { queue->frames.clear(); return pdTRUE; } +inline int xQueueSend(QueueHandle_t queue, const void* value, int) { + if (queue->frames.size() == queue->capacity) return 0; + const auto* bytes = static_cast(value); + queue->frames.emplace_back(bytes, bytes + queue->size); + return pdTRUE; +} +inline int xQueueReceive(QueueHandle_t queue, void* value, int) { + if (queue->frames.empty()) return 0; + memcpy(value, queue->frames.front().data(), queue->size); + queue->frames.pop_front(); + return pdTRUE; +} diff --git a/test/fixtures/nimble_companion/test_nimble_companion.cpp b/test/fixtures/nimble_companion/test_nimble_companion.cpp new file mode 100644 index 00000000..f17d2ea4 --- /dev/null +++ b/test/fixtures/nimble_companion/test_nimble_companion.cpp @@ -0,0 +1,192 @@ +#include +#include +#include +#include +#include + +using mesh::companion::BluetoothPeerIdentity; +using namespace fake_nimble; + +static void reset() { + NimBLEDevice::deinit(true); + failure.clear(); calls.clear(); transmitted.clear(); + persisted = true; accept_notification = true; disconnects = 0; + test_millis = 100; +} + +static void test_identity_modes() { + // The common MAC policy supplies human-order addresses for all four + // custom/random modes. The actual adapter must hand the controller exactly + // those bytes in NimBLE order before any advertisement can start. + const std::array, 4> addresses = {{ + {{0xc2, 0x12, 0x34, 0x56, 0x78, 0x9a}}, + {{0xd3, 0xff, 0x00, 0x7e, 0x01, 0x02}}, + {{0xe4, 0x10, 0x20, 0x30, 0x40, 0x50}}, + {{0xf5, 0x11, 0x22, 0x33, 0x44, 0x55}}, + }}; + for (const auto& chosen : addresses) { + reset(); + SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "trial", 246810, chosen.data(), true)); + assert(own_type == BLE_OWN_ADDR_RANDOM); + for (size_t i = 0; i < 6; ++i) assert(address[i] == chosen[5 - i]); + auto* server = NimBLEDevice::server.get(); + assert(!server->owns_callbacks && !server->auto_advertise); + assert(!server->advertising.active); + assert(pin == 246810 && bonding && mitm && sc && io_cap == BLE_HS_IO_DISPLAY_ONLY); + interface.enable(); + assert(server->advertising.active); + assert(std::find(calls.begin(), calls.end(), "address-type") < + std::find(calls.begin(), calls.end(), "advertise")); + assert(server->advertising.name == "MeshCore-trial"); + assert(server->advertising.service == "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"); + assert(server->callbacks->onPassKeyDisplay() == 246810); + assert(interface.takePairingRequest()); + assert(!interface.takePairingRequest()); + NimBLEDevice::deinit(true); // Must never delete the application interface. + } + + reset(); + own_type = BLE_OWN_ADDR_RANDOM; + SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "default", 123456)); + assert(own_type == BLE_OWN_ADDR_PUBLIC); + assert(std::find(calls.begin(), calls.end(), "address") == calls.end()); + NimBLEDevice::deinit(true); +} + +static void test_startup_failures() { + const uint8_t custom[] = {0xc2, 0x12, 0x34, 0x56, 0x78, 0x9a}; + for (const char* step : {"clear-bonds", "address", "address-type", "server", "service", "tx", "rx"}) { + reset(); failure = step; + SerialBLEInterface interface; + assert(!interface.begin("MeshCore-", "trial", 123456, custom, true)); + assert(!initialized && !NimBLEDevice::server); + assert(std::find(calls.begin(), calls.end(), "advertise") == calls.end()); + failure.clear(); + assert(interface.begin("MeshCore-", "trial", 123456, custom, true)); + for (size_t i = 0; i < 6; ++i) assert(address[i] == custom[5 - i]); + NimBLEDevice::deinit(true); + } + reset(); + SerialBLEInterface interface; + const uint8_t invalid[] = {0x44, 1, 2, 3, 4, 5}; + assert(!interface.begin("MeshCore-", "trial", 123456, invalid)); + assert(calls.empty()); +} + +static void authenticate(SerialBLEInterface& interface, NimBLEConnInfo& info) { + auto* server = NimBLEDevice::server.get(); + server->connected = true; + server->callbacks->onConnect(server, info); + server->callbacks->onAuthenticationComplete(info); + uint8_t discarded[MAX_FRAME_SIZE]; + interface.checkRecvFrame(discarded); +} + +static void test_authentication_and_bond_lifecycle() { + for (unsigned missing = 0; missing < 3; ++missing) { + reset(); SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "trial", 123456)); + NimBLEConnInfo info; + if (missing == 0) info.encrypted = false; + if (missing == 1) info.authenticated = false; + if (missing == 2) info.bonded = false; + authenticate(interface, info); + assert(!interface.isConnected() && !interface.takeSuccessfulConnection()); + assert(disconnects == 1); + NimBLEDevice::deinit(true); + } + + reset(); SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "trial", 123456, nullptr, false, true)); + interface.enable(); + NimBLEConnInfo info; + // Check an identity address, not a temporary over-the-air private address. + info.identity.type = BLE_ADDR_RANDOM; + info.identity.val[5] = 0xd6; + authenticate(interface, info); + assert(interface.isConnected()); + BluetoothPeerIdentity peer; + persisted = false; + assert(!interface.takeSuccessfulConnection(&peer)); + persisted = true; + assert(interface.takeSuccessfulConnection(&peer)); + assert(peer.type == mesh::companion::BLUETOOTH_PEER_ADDRESS_RANDOM); + for (size_t i = 0; i < 6; ++i) assert(peer.address[i] == info.identity.val[5 - i]); + assert(!interface.takeSuccessfulConnection(&peer)); + assert(interface.enableBondedOnlyAdvertising(peer)); + assert(allowlisted.type == BLE_ADDR_RANDOM); + assert(memcmp(allowlisted.val, info.identity.val, 6) == 0); + auto* server = NimBLEDevice::server.get(); + assert(server->advertising.scan_filter && server->advertising.connect_filter); + assert(!server->advertising.scan_response && server->advertising.name.empty()); + assert(interface.isConnected()); + NimBLEDevice::deinit(true); + + reset(); SerialBLEInterface rebooted; + persisted = false; + assert(rebooted.begin("MeshCore-", "trial", 123456, nullptr, false, false, &peer)); + rebooted.enable(); + assert(!NimBLEDevice::server->advertising.active); + assert(rebooted.takeBondedOnlyRecovery()); + NimBLEDevice::deinit(true); +} + +static void test_real_transport_frames() { + reset(); SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "trial", 123456)); + interface.enable(); + NimBLEConnInfo info; + authenticate(interface, info); + auto* server = NimBLEDevice::server.get(); + auto* tx = server->service->characteristics[0].get(); + auto* rx = server->service->characteristics[1].get(); + assert(tx->properties & NIMBLE_PROPERTY::READ_AUTHEN); + assert(rx->properties & NIMBLE_PROPERTY::WRITE_AUTHEN); + uint8_t frame[MAX_FRAME_SIZE]; + for (size_t i = 0; i < sizeof(frame); ++i) frame[i] = i; + frame[0] = 0x0d; + uint8_t received[MAX_FRAME_SIZE] = {}; + assert(interface.writeFrame(frame, sizeof(frame)) == sizeof(frame)); + interface.checkRecvFrame(received); + assert(transmitted.empty()); // Subscription is still pending. + tx->callbacks->onSubscribe(tx, info, 1); + mtu = 23; + interface.checkRecvFrame(received); + assert(transmitted.empty()); // Never truncate a full protocol frame. + mtu = 179; + accept_notification = false; + interface.checkRecvFrame(received); + assert(interface.hasPendingIO() && transmitted.empty()); + test_millis += 61; + accept_notification = true; + interface.checkRecvFrame(received); + assert(transmitted.size() == 1 && transmitted[0].size() == sizeof(frame)); + assert(memcmp(transmitted[0].data(), frame, sizeof(frame)) == 0); + test_millis += 61; + interface.checkRecvFrame(received); + assert(transmitted.size() == 1); // No late-status duplicate. + + rx->setValue(frame, sizeof(frame)); + info.authenticated = false; + rx->callbacks->onWrite(rx, info); + assert(interface.checkRecvFrame(received) == 0); + info.authenticated = true; + rx->callbacks->onWrite(rx, info); + assert(interface.checkRecvFrame(received) == sizeof(frame)); + assert(memcmp(frame, received, sizeof(frame)) == 0); + uint8_t oversized[MAX_FRAME_SIZE + 1] = {}; + rx->setValue(oversized, sizeof(oversized)); + rx->callbacks->onWrite(rx, info); + assert(interface.checkRecvFrame(received) == 0); + NimBLEDevice::deinit(true); +} + +int main() { + test_identity_modes(); + test_startup_failures(); + test_authentication_and_bond_lifecycle(); + test_real_transport_frames(); + puts("NimBLE adapter: identity, faults, bonding, stealth and full frames PASS"); +} diff --git a/test/test_nimble_companion.py b/test/test_nimble_companion.py new file mode 100644 index 00000000..8b965ce6 --- /dev/null +++ b/test/test_nimble_companion.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Exercise the real ESP32 Companion adapter against the NimBLE 2.5 API.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class NimbleCompanionTests(unittest.TestCase): + def test_identity_security_and_data_path_under_sanitizers(self): + with tempfile.TemporaryDirectory(prefix="meshcore-nimble-") as temp: + binary = Path(temp) / "nimble-companion" + result = subprocess.run([ + "c++", "-std=c++17", "-g", "-fsanitize=address,undefined", + "-DMESH_USE_NIMBLE_ARDUINO=1", + "-I", str(ROOT / "test/fixtures/nimble_companion/mocks"), + "-I", str(ROOT / "src"), + str(ROOT / "src/helpers/esp32/SerialBLEInterface.cpp"), + str(ROOT / "test/fixtures/nimble_companion/test_nimble_companion.cpp"), + "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main()