Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 87 additions & 20 deletions src/sp140/ble/ble_core.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,18 +37,26 @@ void stopPairingModeTimer() {
}
}

void clearWhiteList() {
while (NimBLEDevice::getWhiteListCount() > 0) {
NimBLEDevice::whiteListRemove(NimBLEDevice::getWhiteListAddress(0));
}
}

size_t syncWhiteListFromBonds() {
clearWhiteList();
// 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) {
NimBLEDevice::whiteListAdd(NimBLEDevice::getBondedAddress(i));
const NimBLEAddress addr = NimBLEDevice::getBondedAddress(i);
if (!NimBLEDevice::onWhiteList(addr)) {
NimBLEDevice::whiteListAdd(addr);
}
}

return NimBLEDevice::getWhiteListCount();
Comment on lines 40 to 62
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncWhiteListFromBonds() only ever adds bonded addresses; it never removes whitelist entries for bonds that were deleted (e.g., after enterBLEPairingMode() clears bonds). With scan-filter whitelisting enabled (setScanFilter(false, true)), stale whitelist entries can still be allowed to connect, undermining the single-bond model and potentially enabling repeated reconnect/DoS until reboot. Consider explicitly clearing/pruning the whitelist to match current bonds (e.g., clear whitelist when bonds are cleared, or remove any whitelist addresses that are no longer present in the bond list using bounded retries to avoid the prior infinite loop).

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -77,21 +86,27 @@ 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;
}

const size_t bondCount = static_cast<size_t>(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;
Expand Down Expand Up @@ -122,8 +137,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);
Expand All @@ -133,11 +146,6 @@ bool startAdvertising(NimBLEServer *server) {
static_cast<unsigned>(bondCount), static_cast<unsigned>(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) {
Expand Down Expand Up @@ -224,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 {
Expand All @@ -245,6 +255,26 @@ 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 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 id=%s peer=%s (phone-side forget)\n",
identityAddr.toString().c_str(), peerAddr.toString().c_str());
}

if (pServer != nullptr) {
pServer->disconnect(connInfo.getConnHandle());
}
Expand Down Expand Up @@ -337,7 +367,44 @@ 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 &&
connectedHandle != BLE_HS_CONN_HANDLE_NONE) {
pServer->disconnect(connectedHandle);
vTaskDelay(pdMS_TO_TICKS(100));
}
Comment on lines +374 to +378
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enterBLEPairingMode() disconnects first, but onDisconnect() immediately calls startAdvertising(server). Since pairingModeActive isn’t set until after bond deletion, there’s a window where advertising can restart in BONDED mode with the old bond/whitelist still present, allowing the previous peer to reconnect before bonds are cleared. Consider adding a pairing-transition flag to suppress the onDisconnect advertising restart (or otherwise defer advertising) until after bonds/whitelist are cleared and pairing mode is fully active.

Copilot uses AI. Check for mistakes.

// 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;
pairingModeTransitionActive = false;

if (gPairingTimer == nullptr) {
gPairingTimer = xTimerCreate("blePair", kPairingTimeoutTicks, pdFALSE,
Expand Down
40 changes: 5 additions & 35 deletions src/sp140/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -851,7 +852,6 @@ void buttonHandlerTask(void *parameter) {
uint32_t lastDebounceTime = 0;
bool lastButtonState = HIGH;
bool buttonState;
bool unbondHoldHandled = false;
bool pairingHoldHandled = false;

while (true) {
Expand All @@ -865,7 +865,6 @@ void buttonHandlerTask(void *parameter) {

if (buttonState == LOW) { // Button pressed
buttonPressed = true;
unbondHoldHandled = false;
pairingHoldHandled = false;
buttonPressStartTime = currentTime;
USBSerial.println("Button pressed");
Expand All @@ -883,8 +882,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 < BLE_PAIRING_HOLD_MS && !pairingHoldHandled) {
perfModeSwitch();
lastButtonState = buttonState;
continue;
Expand Down Expand Up @@ -928,38 +926,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;
}

if (currentState == DISARMED && currentHoldTime >= 10000 &&
// Long hold while disarmed: 10s = enter BLE pairing mode
// Clears existing bonds and opens advertising for 60s.
if (currentState == DISARMED && currentHoldTime >= BLE_PAIRING_HOLD_MS &&
!pairingHoldHandled) {
// Tier 1: Enter pairing mode (open advertising for 60s)
enterBLEPairingMode();
pulseVibeMotor();
USBSerial.println("[BLE] Pairing mode activated via button hold");
Expand Down