From 789a6b10aecd37b5347745a90123b6a8337378bd Mon Sep 17 00:00:00 2001 From: PaulDWhite Date: Thu, 26 Mar 2026 12:43:30 -0400 Subject: [PATCH 1/3] ble single device connection only. --- src/sp140/ble/ble_core.cpp | 79 +++++++++++++++++++++++++++++--------- src/sp140/main.cpp | 37 ++---------------- 2 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/sp140/ble/ble_core.cpp b/src/sp140/ble/ble_core.cpp index 9ea844b..f444307 100644 --- a/src/sp140/ble/ble_core.cpp +++ b/src/sp140/ble/ble_core.cpp @@ -36,18 +36,18 @@ void stopPairingModeTimer() { } } -void clearWhiteList() { - while (NimBLEDevice::getWhiteListCount() > 0) { - NimBLEDevice::whiteListRemove(NimBLEDevice::getWhiteListAddress(0)); - } -} - size_t syncWhiteListFromBonds() { - clearWhiteList(); - + // Add bonded addresses that aren't already present. Skip addresses already + // on the whitelist because NimBLE's whiteListAdd() internally calls + // whiteListRemove() first for duplicates, and whiteListRemove() fails with + // BLE_HS_EBUSY (rc=524) whenever the controller is advertising — even after + // an advertising->stop(), which is asynchronous at the HCI level. const int bondCount = NimBLEDevice::getNumBonds(); for (int i = 0; i < bondCount; ++i) { - NimBLEDevice::whiteListAdd(NimBLEDevice::getBondedAddress(i)); + const NimBLEAddress addr = NimBLEDevice::getBondedAddress(i); + if (!NimBLEDevice::onWhiteList(addr)) { + NimBLEDevice::whiteListAdd(addr); + } } return NimBLEDevice::getWhiteListCount(); @@ -87,11 +87,16 @@ bool startAdvertising(NimBLEServer *server) { const size_t bondCount = static_cast(NimBLEDevice::getNumBonds()); const bool allowOpenAdvertising = pairingModeActive; + + // Stop advertising BEFORE modifying the whitelist — the BLE controller + // rejects whitelist changes while advertising with a whitelist filter + // (rc=524 / BLE_HS_EBUSY), which previously caused an infinite loop. + auto *advertising = server->getAdvertising(); + advertising->stop(); + const size_t whiteListCount = syncWhiteListFromBonds(); if (!allowOpenAdvertising && bondCount == 0) { - auto *advertising = server->getAdvertising(); - advertising->stop(); USBSerial.println( "[BLE] No bonds present and pairing mode inactive; advertising stopped"); return false; @@ -122,8 +127,6 @@ bool startAdvertising(NimBLEServer *server) { // Flutter app's `startScan()` filters for CONFIG_SERVICE_UUID. adv.addServiceUUID(NimBLEUUID(CONFIG_SERVICE_UUID)); - auto *advertising = server->getAdvertising(); - advertising->stop(); advertising->removeAll(); const bool configured = advertising->setInstanceData(kExtAdvInstance, adv); const bool started = configured && advertising->start(kExtAdvInstance); @@ -133,11 +136,6 @@ bool startAdvertising(NimBLEServer *server) { static_cast(bondCount), static_cast(whiteListCount)); return started; #else - auto *advertising = server->getAdvertising(); - - // Stop before reconfiguring (safe even if not running) - advertising->stop(); - // Configure payload once — NimBLE accumulates addServiceUUID calls static bool payloadConfigured = false; if (!payloadConfigured) { @@ -245,6 +243,17 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks { if (!connInfo.isEncrypted() || !connInfo.isBonded()) { USBSerial.println("[BLE] Rejecting untrusted BLE session"); + + // If this address had a bond in NVS the phone must have forgotten its + // side of the keys. Remove the stale bond so the controller stops + // advertising to a device that can never reconnect. + const NimBLEAddress addr = connInfo.getAddress(); + if (NimBLEDevice::deleteBond(addr)) { + USBSerial.printf( + "[BLE] Removed stale bond for %s (phone-side forget)\n", + addr.toString().c_str()); + } + if (pServer != nullptr) { pServer->disconnect(connInfo.getConnHandle()); } @@ -337,6 +346,40 @@ void restartBLEAdvertising() { } void enterBLEPairingMode() { + // Single-bond model: clear any existing bond so the next device that + // connects becomes the sole bonded peer. + if (deviceConnected && pServer != nullptr && + connectedHandle != BLE_HS_CONN_HANDLE_NONE) { + pServer->disconnect(connectedHandle); + vTaskDelay(pdMS_TO_TICKS(100)); + } + + // Stop advertising before modifying bonds — NimBLE's bond deletion can + // fail (EBUSY) if the controller is actively advertising. + if (pServer != nullptr) { + auto *advertising = pServer->getAdvertising(); + if (advertising != nullptr) { + advertising->stop(); + } + } + vTaskDelay(pdMS_TO_TICKS(50)); + + const int bondCount = NimBLEDevice::getNumBonds(); + bool cleared = false; + if (bondCount > 0) { + cleared = NimBLEDevice::deleteAllBonds(); + if (!cleared) { + // Retry individual bond deletion as fallback + for (int i = NimBLEDevice::getNumBonds() - 1; i >= 0; --i) { + NimBLEDevice::deleteBond(NimBLEDevice::getBondedAddress(i)); + } + cleared = NimBLEDevice::getNumBonds() == 0; + } + } + USBSerial.printf("[BLE] Cleared bonds: %s (was %d, now %d)\n", + cleared ? "OK" : (bondCount == 0 ? "NONE" : "FAILED"), + bondCount, NimBLEDevice::getNumBonds()); + pairingModeActive = true; if (gPairingTimer == nullptr) { diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index d9fdd00..2991787 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -851,7 +851,6 @@ void buttonHandlerTask(void *parameter) { uint32_t lastDebounceTime = 0; bool lastButtonState = HIGH; bool buttonState; - bool unbondHoldHandled = false; bool pairingHoldHandled = false; while (true) { @@ -865,7 +864,6 @@ void buttonHandlerTask(void *parameter) { if (buttonState == LOW) { // Button pressed buttonPressed = true; - unbondHoldHandled = false; pairingHoldHandled = false; buttonPressStartTime = currentTime; USBSerial.println("Button pressed"); @@ -883,8 +881,7 @@ void buttonHandlerTask(void *parameter) { // Disarmed long hold toggles performance mode on release. if (currentState == DISARMED && holdDuration >= PERFORMANCE_MODE_HOLD_MS && - holdDuration < 10000 && !unbondHoldHandled && - !pairingHoldHandled) { + holdDuration < 10000 && !pairingHoldHandled) { perfModeSwitch(); lastButtonState = buttonState; continue; @@ -928,38 +925,10 @@ void buttonHandlerTask(void *parameter) { } else if (buttonPressed) { // Only handle other button actions if we're not in an arm sequence uint32_t currentHoldTime = currentTime - buttonPressStartTime; - // Tiered long hold while disarmed: - // 10s = enter BLE pairing mode (single vibration) - // 20s = delete all bonds (double vibration + reboot) - if (currentState == DISARMED && currentHoldTime >= 20000 && - !unbondHoldHandled) { - // Tier 2: Delete all bonds - const bool deleted = NimBLEDevice::deleteAllBonds(); - USBSerial.printf("[BLE] Delete all bonds: %s\n", - deleted ? "OK" : "FAILED"); - if (deviceConnected && pServer != nullptr && - connectedHandle != BLE_HS_CONN_HANDLE_NONE) { - pServer->disconnect(connectedHandle); - vTaskDelay(pdMS_TO_TICKS(40)); - } - pulseVibeMotor(); - vTaskDelay(pdMS_TO_TICKS(300)); - pulseVibeMotor(); - USBSerial.println("[BLE] Bonds cleared. Rebooting with BLE locked " - "until pairing mode is reopened..."); - vTaskDelay(pdMS_TO_TICKS(500)); - diagnosticsMarkPlannedRestart( - PlannedRestartReason::BLE_UNBOND_REBOOT); - ESP.restart(); - unbondHoldHandled = true; - buttonPressed = false; - buttonPressStartTime = currentTime; - continue; - } - + // Long hold while disarmed: 10s = enter BLE pairing mode + // Clears existing bonds and opens advertising for 60s. if (currentState == DISARMED && currentHoldTime >= 10000 && !pairingHoldHandled) { - // Tier 1: Enter pairing mode (open advertising for 60s) enterBLEPairingMode(); pulseVibeMotor(); USBSerial.println("[BLE] Pairing mode activated via button hold"); From ff784019c1d00f64c5e3ff0edefec27c4002969b Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Thu, 26 Mar 2026 17:04:30 -0400 Subject: [PATCH 2/3] Add BLE_PAIRING_HOLD_MS and use it Introduce BLE_PAIRING_HOLD_MS (10000 ms) and replace hard-coded 10000ms checks with this constant. The new macro is used to limit the performance-mode long-hold detection and to trigger BLE pairing mode when disarmed, centralizing the pairing hold duration and removing duplicate magic numbers. --- src/sp140/main.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index 2991787..ecea3d7 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -75,6 +75,7 @@ int8_t bmsCS = MCP_CS; #define CRUISE_HOLD_TIME_MS 2000 #define BUTTON_SEQUENCE_TIMEOUT_MS 1500 // Time window for arm/disarm sequence #define PERFORMANCE_MODE_HOLD_MS 3000 // Longer hold time for performance mode +#define BLE_PAIRING_HOLD_MS 10000 // Throttle control constants moved to inc/sp140/throttle.h #define CRUISE_MAX_PERCENTAGE \ @@ -881,7 +882,7 @@ void buttonHandlerTask(void *parameter) { // Disarmed long hold toggles performance mode on release. if (currentState == DISARMED && holdDuration >= PERFORMANCE_MODE_HOLD_MS && - holdDuration < 10000 && !pairingHoldHandled) { + holdDuration < BLE_PAIRING_HOLD_MS && !pairingHoldHandled) { perfModeSwitch(); lastButtonState = buttonState; continue; @@ -927,7 +928,7 @@ void buttonHandlerTask(void *parameter) { // Long hold while disarmed: 10s = enter BLE pairing mode // Clears existing bonds and opens advertising for 60s. - if (currentState == DISARMED && currentHoldTime >= 10000 && + if (currentState == DISARMED && currentHoldTime >= BLE_PAIRING_HOLD_MS && !pairingHoldHandled) { enterBLEPairingMode(); pulseVibeMotor(); From 28b78e0a22446f6665081749c35853da5853eb30 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Thu, 26 Mar 2026 17:07:09 -0400 Subject: [PATCH 3/3] Handle pairing transition and whitelist cleanup Introduce pairingModeTransitionActive to block advertising/startAdvertising during pairing state transitions. Reconcile the controller whitelist by first pruning stale entries (avoids BLE_HS_EBUSY) and then adding bonded addresses, with comments clarifying the rationale. Improve stale-bond removal to try both identity and peer addresses, remove them from the whitelist when deleted, and update the debug log to include both IDs. Also guard startAdvertising and disconnect restart paths against running during the pairing transition, and set/clear the transition flag in enterBLEPairingMode. --- src/sp140/ble/ble_core.cpp | 48 ++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/sp140/ble/ble_core.cpp b/src/sp140/ble/ble_core.cpp index f444307..72d4ae0 100644 --- a/src/sp140/ble/ble_core.cpp +++ b/src/sp140/ble/ble_core.cpp @@ -26,6 +26,7 @@ TimerHandle_t gConnTuneTimer = nullptr; TimerHandle_t gPairingTimer = nullptr; TimerHandle_t gAdvertisingWatchdogTimer = nullptr; bool pairingModeActive = false; +bool pairingModeTransitionActive = false; // Store the active connection handle for conn param updates uint16_t activeConnHandle = 0; @@ -37,11 +38,19 @@ void stopPairingModeTimer() { } size_t syncWhiteListFromBonds() { - // Add bonded addresses that aren't already present. Skip addresses already - // on the whitelist because NimBLE's whiteListAdd() internally calls - // whiteListRemove() first for duplicates, and whiteListRemove() fails with - // BLE_HS_EBUSY (rc=524) whenever the controller is advertising — even after - // an advertising->stop(), which is asynchronous at the HCI level. + // Reconcile the whitelist to the current bond store. The caller stops + // advertising before entering here so we can safely prune stale entries + // without the unbounded remove loop that previously hit BLE_HS_EBUSY. + for (size_t i = NimBLEDevice::getWhiteListCount(); i > 0; --i) { + const NimBLEAddress addr = NimBLEDevice::getWhiteListAddress(i - 1); + if (!NimBLEDevice::isBonded(addr)) { + NimBLEDevice::whiteListRemove(addr); + } + } + + // Add bonded addresses that aren't already present. Skip addresses already + // on the whitelist because NimBLE's whiteListAdd() touches the controller + // whitelist immediately. const int bondCount = NimBLEDevice::getNumBonds(); for (int i = 0; i < bondCount; ++i) { const NimBLEAddress addr = NimBLEDevice::getBondedAddress(i); @@ -77,11 +86,12 @@ void applyPreferredLinkParams(TimerHandle_t timer) { } bool shouldAdvertiseWhilePowered() { - return pairingModeActive || NimBLEDevice::getNumBonds() > 0; + return !pairingModeTransitionActive && + (pairingModeActive || NimBLEDevice::getNumBonds() > 0); } bool startAdvertising(NimBLEServer *server) { - if (server == nullptr) { + if (server == nullptr || pairingModeTransitionActive) { return false; } @@ -222,7 +232,9 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks { // Restart immediately, then let the watchdog keep retrying forever if this // first post-disconnect start does not stick. - startAdvertising(server); + if (!pairingModeTransitionActive) { + startAdvertising(server); + } } void onAuthenticationComplete(NimBLEConnInfo &connInfo) override { @@ -247,11 +259,20 @@ class BleServerConnectionCallbacks : public NimBLEServerCallbacks { // If this address had a bond in NVS the phone must have forgotten its // side of the keys. Remove the stale bond so the controller stops // advertising to a device that can never reconnect. - const NimBLEAddress addr = connInfo.getAddress(); - if (NimBLEDevice::deleteBond(addr)) { + const NimBLEAddress identityAddr = connInfo.getIdAddress(); + const NimBLEAddress peerAddr = connInfo.getAddress(); + bool removedBond = NimBLEDevice::deleteBond(identityAddr); + if (!removedBond && identityAddr != peerAddr) { + removedBond = NimBLEDevice::deleteBond(peerAddr); + } + if (removedBond) { + NimBLEDevice::whiteListRemove(identityAddr); + if (identityAddr != peerAddr) { + NimBLEDevice::whiteListRemove(peerAddr); + } USBSerial.printf( - "[BLE] Removed stale bond for %s (phone-side forget)\n", - addr.toString().c_str()); + "[BLE] Removed stale bond for id=%s peer=%s (phone-side forget)\n", + identityAddr.toString().c_str(), peerAddr.toString().c_str()); } if (pServer != nullptr) { @@ -346,6 +367,8 @@ void restartBLEAdvertising() { } void enterBLEPairingMode() { + pairingModeTransitionActive = true; + // Single-bond model: clear any existing bond so the next device that // connects becomes the sole bonded peer. if (deviceConnected && pServer != nullptr && @@ -381,6 +404,7 @@ void enterBLEPairingMode() { bondCount, NimBLEDevice::getNumBonds()); pairingModeActive = true; + pairingModeTransitionActive = false; if (gPairingTimer == nullptr) { gPairingTimer = xTimerCreate("blePair", kPairingTimeoutTicks, pdFALSE,