Skip to content

Commit a5e88fc

Browse files
committed
Add a timer for scan responses
This ensures that the callback will be called within the configured time (in ms) when devices fail to respond to a scan response request within that time. * Adds stats when debug logging to help tune the scan response timeout/scan parameters.
1 parent b5110a2 commit a5e88fc

4 files changed

Lines changed: 334 additions & 16 deletions

File tree

src/NimBLEAdvertisedDevice.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ NimBLEAdvertisedDevice::NimBLEAdvertisedDevice(const ble_gap_event* event, uint8
5252
m_advLength{event->disc.length_data},
5353
m_payload(event->disc.data, event->disc.data + event->disc.length_data) {
5454
# endif
55+
m_pNextWaiting = this; // initialize sentinel: self-pointer means "not in list"
5556
} // NimBLEAdvertisedDevice
5657

5758
/**

src/NimBLEAdvertisedDevice.h

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,13 @@ class NimBLEAdvertisedDevice {
158158
uint8_t findAdvField(uint8_t type, uint8_t index = 0, size_t* data_loc = nullptr) const;
159159
size_t findServiceData(uint8_t index, uint8_t* bytes) const;
160160

161-
NimBLEAddress m_address{};
162-
uint8_t m_advType{};
163-
int8_t m_rssi{};
164-
uint8_t m_callbackSent{};
165-
uint16_t m_advLength{};
161+
NimBLEAddress m_address{};
162+
uint8_t m_advType{};
163+
int8_t m_rssi{};
164+
uint8_t m_callbackSent{};
165+
uint16_t m_advLength{};
166+
ble_npl_time_t m_time{};
167+
NimBLEAdvertisedDevice* m_pNextWaiting{}; // intrusive list node; self-pointer means "not in list", set in ctor
166168

167169
# if MYNEWT_VAL(BLE_EXT_ADV)
168170
bool m_isLegacyAdv{};

src/NimBLEScan.cpp

Lines changed: 227 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,50 @@
2020

2121
# include "NimBLEDevice.h"
2222
# include "NimBLELog.h"
23+
# if defined(CONFIG_NIMBLE_CPP_IDF)
24+
# include "nimble/nimble_port.h"
25+
# else
26+
# include "nimble/porting/nimble/include/nimble/nimble_port.h"
27+
# endif
2328

2429
# include <string>
2530
# include <climits>
2631

32+
# define DEFAULT_SCAN_RESP_TIMEOUT_MS 10240 // max advertising interval (10.24s)
33+
2734
static const char* LOG_TAG = "NimBLEScan";
2835
static NimBLEScanCallbacks defaultScanCallbacks;
2936

37+
/**
38+
* @brief This handles an event run in the host task when the scan response timeout for the head of
39+
* the waiting list is triggered and directly invokes the onResult callback with the current device.
40+
*/
41+
void NimBLEScan::srTimerCb(ble_npl_event* event) {
42+
auto pScan = NimBLEDevice::getScan();
43+
auto pDev = pScan->m_pWaitingListHead;
44+
45+
if (pDev == nullptr) {
46+
ble_npl_callout_stop(&pScan->m_srTimer);
47+
return;
48+
}
49+
50+
if (ble_npl_time_get() - pDev->m_time < pScan->m_srTimeoutTicks) {
51+
// This can happen if a scan response was received and the device was removed from the waiting list
52+
// after this was put in the queue. In this case, just reset the timer for this device.
53+
pScan->resetWaitingTimer();
54+
return;
55+
}
56+
57+
NIMBLE_LOGI(LOG_TAG, "Scan response timeout for: %s", pDev->getAddress().toString().c_str());
58+
pScan->m_stats.incMissedSrCount();
59+
pScan->removeWaitingDevice(pDev);
60+
pDev->m_callbackSent = 2;
61+
pScan->m_pScanCallbacks->onResult(pDev);
62+
if (pScan->m_maxResults == 0) {
63+
pScan->erase(pDev);
64+
}
65+
}
66+
3067
/**
3168
* @brief Scan constructor.
3269
*/
@@ -35,17 +72,129 @@ NimBLEScan::NimBLEScan()
3572
// default interval + window, no whitelist scan filter,not limited scan, no scan response, filter_duplicates
3673
m_scanParams{0, 0, BLE_HCI_SCAN_FILT_NO_WL, 0, 1, 1},
3774
m_pTaskData{nullptr},
38-
m_maxResults{0xFF} {}
75+
m_maxResults{0xFF} {
76+
ble_npl_callout_init(&m_srTimer, nimble_port_get_dflt_eventq(), NimBLEScan::srTimerCb, nullptr);
77+
ble_npl_time_ms_to_ticks(DEFAULT_SCAN_RESP_TIMEOUT_MS, &m_srTimeoutTicks);
78+
} // NimBLEScan::NimBLEScan
3979

4080
/**
4181
* @brief Scan destructor, release any allocated resources.
4282
*/
4383
NimBLEScan::~NimBLEScan() {
84+
ble_npl_callout_deinit(&m_srTimer);
85+
4486
for (const auto& dev : m_scanResults.m_deviceVec) {
4587
delete dev;
4688
}
4789
}
4890

91+
/**
92+
* @brief Add a device to the waiting list for scan responses.
93+
* @param [in] pDev The device to add to the list.
94+
*/
95+
void NimBLEScan::addWaitingDevice(NimBLEAdvertisedDevice* pDev) {
96+
if (pDev == nullptr) {
97+
return;
98+
}
99+
100+
ble_npl_hw_enter_critical();
101+
102+
// Self-pointer is the "not in list" sentinel; anything else means already in list.
103+
if (pDev->m_pNextWaiting != pDev) {
104+
ble_npl_hw_exit_critical(0);
105+
return;
106+
}
107+
108+
// Initialize link field before inserting into the list.
109+
pDev->m_pNextWaiting = nullptr;
110+
if (m_pWaitingListTail == nullptr) {
111+
m_pWaitingListHead = pDev;
112+
m_pWaitingListTail = pDev;
113+
ble_npl_hw_exit_critical(0);
114+
return;
115+
}
116+
117+
m_pWaitingListTail->m_pNextWaiting = pDev;
118+
m_pWaitingListTail = pDev;
119+
ble_npl_hw_exit_critical(0);
120+
}
121+
122+
/**
123+
* @brief Remove a device from the waiting list.
124+
* @param [in] pDev The device to remove from the list.
125+
*/
126+
void NimBLEScan::removeWaitingDevice(NimBLEAdvertisedDevice* pDev) {
127+
if (pDev == nullptr) {
128+
return;
129+
}
130+
131+
if (pDev->m_pNextWaiting == pDev) {
132+
return; // Not in the list
133+
}
134+
135+
bool resetTimer = false;
136+
ble_npl_hw_enter_critical();
137+
if (m_pWaitingListHead == pDev) {
138+
m_pWaitingListHead = pDev->m_pNextWaiting;
139+
if (m_pWaitingListHead == nullptr) {
140+
m_pWaitingListTail = nullptr;
141+
} else {
142+
resetTimer = true;
143+
}
144+
} else {
145+
NimBLEAdvertisedDevice* current = m_pWaitingListHead;
146+
while (current != nullptr) {
147+
if (current->m_pNextWaiting == pDev) {
148+
current->m_pNextWaiting = pDev->m_pNextWaiting;
149+
if (m_pWaitingListTail == pDev) {
150+
m_pWaitingListTail = current;
151+
}
152+
break;
153+
}
154+
current = current->m_pNextWaiting;
155+
}
156+
}
157+
ble_npl_hw_exit_critical(0);
158+
pDev->m_pNextWaiting = pDev; // Restore sentinel: self-pointer means "not in list"
159+
if (resetTimer) {
160+
resetWaitingTimer();
161+
}
162+
}
163+
164+
/**
165+
* @brief Clear all devices from the waiting list.
166+
*/
167+
void NimBLEScan::clearWaitingList() {
168+
// Stop the timer and remove any pending timeout events since we're clearing
169+
// the list and won't be processing any more timeouts for these devices
170+
ble_npl_callout_stop(&m_srTimer);
171+
ble_npl_hw_enter_critical();
172+
NimBLEAdvertisedDevice* current = m_pWaitingListHead;
173+
while (current != nullptr) {
174+
NimBLEAdvertisedDevice* next = current->m_pNextWaiting;
175+
current->m_pNextWaiting = current; // Restore sentinel
176+
current = next;
177+
}
178+
m_pWaitingListHead = nullptr;
179+
m_pWaitingListTail = nullptr;
180+
ble_npl_hw_exit_critical(0);
181+
}
182+
183+
/**
184+
* @brief Reset the timer for the next waiting device at the head of the FIFO list.
185+
*/
186+
void NimBLEScan::resetWaitingTimer() {
187+
if (m_srTimeoutTicks == 0 || m_pWaitingListHead == nullptr) {
188+
ble_npl_callout_stop(&m_srTimer);
189+
return;
190+
}
191+
192+
ble_npl_time_t now = ble_npl_time_get();
193+
ble_npl_time_t elapsed = now - m_pWaitingListHead->m_time;
194+
ble_npl_time_t nextTime = elapsed >= m_srTimeoutTicks ? 1 : m_srTimeoutTicks - elapsed;
195+
ble_npl_callout_reset(&m_srTimer, nextTime);
196+
}
197+
49198
/**
50199
* @brief Handle GAP events related to scans.
51200
* @param [in] event The event type for this event.
@@ -101,6 +250,8 @@ int NimBLEScan::handleGapEvent(ble_gap_event* event, void* arg) {
101250
// If we haven't seen this device before; create a new instance and insert it in the vector.
102251
// Otherwise just update the relevant parameters of the already known device.
103252
if (advertisedDevice == nullptr) {
253+
pScan->m_stats.incDevCount();
254+
104255
// Check if we have reach the scan results limit, ignore this one if so.
105256
// We still need to store each device when maxResults is 0 to be able to append the scan results
106257
if (pScan->m_maxResults > 0 && pScan->m_maxResults < 0xFF &&
@@ -109,19 +260,39 @@ int NimBLEScan::handleGapEvent(ble_gap_event* event, void* arg) {
109260
}
110261

111262
if (isLegacyAdv && event_type == BLE_HCI_ADV_RPT_EVTYPE_SCAN_RSP) {
263+
pScan->m_stats.incOrphanedSrCount();
112264
NIMBLE_LOGI(LOG_TAG, "Scan response without advertisement: %s", advertisedAddress.toString().c_str());
113265
}
114266

115267
advertisedDevice = new NimBLEAdvertisedDevice(event, event_type);
116268
pScan->m_scanResults.m_deviceVec.push_back(advertisedDevice);
269+
advertisedDevice->m_time = ble_npl_time_get();
117270
NIMBLE_LOGI(LOG_TAG, "New advertiser: %s", advertisedAddress.toString().c_str());
118271
} else {
119272
advertisedDevice->update(event, event_type);
120273
if (isLegacyAdv) {
121274
if (event_type == BLE_HCI_ADV_RPT_EVTYPE_SCAN_RSP) {
275+
pScan->m_stats.recordSrTime(ble_npl_time_get() - advertisedDevice->m_time);
122276
NIMBLE_LOGI(LOG_TAG, "Scan response from: %s", advertisedAddress.toString().c_str());
277+
// Remove device from waiting list since we got the response
278+
pScan->removeWaitingDevice(advertisedDevice);
123279
} else {
280+
pScan->m_stats.incDupCount();
124281
NIMBLE_LOGI(LOG_TAG, "Duplicate; updated: %s", advertisedAddress.toString().c_str());
282+
// Restart scan-response timeout when we see a new non-scan-response
283+
// legacy advertisement during active scanning for a scannable device.
284+
advertisedDevice->m_time = ble_npl_time_get();
285+
// Re-add to the tail so FIFO timeout order matches advertisement order.
286+
if (advertisedDevice->isScannable()) {
287+
pScan->removeWaitingDevice(advertisedDevice);
288+
pScan->addWaitingDevice(advertisedDevice);
289+
}
290+
291+
// If we're not filtering duplicates, we need to reset the callbackSent count
292+
// so that callbacks will be triggered again for this device
293+
if (!pScan->m_scanParams.filter_duplicates) {
294+
advertisedDevice->m_callbackSent = 0;
295+
}
125296
}
126297
}
127298
}
@@ -147,6 +318,12 @@ int NimBLEScan::handleGapEvent(ble_gap_event* event, void* arg) {
147318
advertisedDevice->m_callbackSent++;
148319
// got the scan response report the full data.
149320
pScan->m_pScanCallbacks->onResult(advertisedDevice);
321+
} else if (isLegacyAdv && advertisedDevice->isScannable()) {
322+
// Add to waiting list for scan response and start the timer
323+
pScan->addWaitingDevice(advertisedDevice);
324+
if (pScan->m_pWaitingListHead == advertisedDevice) {
325+
pScan->resetWaitingTimer();
326+
}
150327
}
151328

152329
// If not storing results and we have invoked the callback, delete the device.
@@ -158,12 +335,26 @@ int NimBLEScan::handleGapEvent(ble_gap_event* event, void* arg) {
158335
}
159336

160337
case BLE_GAP_EVENT_DISC_COMPLETE: {
161-
NIMBLE_LOGD(LOG_TAG, "discovery complete; reason=%d", event->disc_complete.reason);
338+
ble_npl_callout_stop(&pScan->m_srTimer);
339+
340+
// If we have any scannable devices that haven't received a scan response,
341+
// we should trigger the callback with whatever data we have since the scan is complete
342+
// and we won't be getting any more updates for these devices.
343+
while (pScan->m_pWaitingListHead != nullptr) {
344+
auto pDev = pScan->m_pWaitingListHead;
345+
pScan->m_stats.incMissedSrCount();
346+
pScan->removeWaitingDevice(pDev);
347+
pDev->m_callbackSent = 2;
348+
pScan->m_pScanCallbacks->onResult(pDev);
349+
}
162350

163351
if (pScan->m_maxResults == 0) {
164352
pScan->clearResults();
165353
}
166354

355+
NIMBLE_LOGD(LOG_TAG, "discovery complete; reason=%d", event->disc_complete.reason);
356+
NIMBLE_LOGD(LOG_TAG, "%s", pScan->getStatsString().c_str());
357+
167358
pScan->m_pScanCallbacks->onScanEnd(pScan->m_scanResults, event->disc_complete.reason);
168359

169360
if (pScan->m_pTaskData != nullptr) {
@@ -178,6 +369,27 @@ int NimBLEScan::handleGapEvent(ble_gap_event* event, void* arg) {
178369
}
179370
} // handleGapEvent
180371

372+
/**
373+
* @brief Set the scan response timeout.
374+
* @param [in] timeoutMs The timeout in milliseconds to wait for a scan response, default: max advertising interval (10.24s)
375+
* @details If a scan response is not received within the timeout period,
376+
* the pending device will be reported to the scan result callback with whatever
377+
* data was present in the advertisement; no synthetic scan-response event is generated.
378+
* If set to 0, the scan result callback will only be triggered when a scan response
379+
* is received from the advertiser or when the scan completes, at which point any
380+
* pending scannable devices will be reported with the advertisement data only.
381+
*/
382+
void NimBLEScan::setScanResponseTimeout(uint32_t timeoutMs) {
383+
if (timeoutMs == 0) {
384+
ble_npl_callout_stop(&m_srTimer);
385+
m_srTimeoutTicks = 0;
386+
return;
387+
}
388+
389+
ble_npl_time_ms_to_ticks(timeoutMs, &m_srTimeoutTicks);
390+
resetWaitingTimer();
391+
} // setScanResponseTimeout
392+
181393
/**
182394
* @brief Should we perform an active or passive scan?
183395
* The default is a passive scan. An active scan means that we will request a scan response.
@@ -208,7 +420,7 @@ void NimBLEScan::setDuplicateFilter(uint8_t enabled) {
208420
*/
209421
void NimBLEScan::setLimitedOnly(bool enabled) {
210422
m_scanParams.limited = enabled;
211-
} // setLimited
423+
} // setLimitedOnly
212424

213425
/**
214426
* @brief Sets the scan filter policy.
@@ -323,11 +535,13 @@ bool NimBLEScan::start(uint32_t duration, bool isContinue, bool restart) {
323535

324536
if (!isContinue) {
325537
clearResults();
538+
m_stats.reset();
326539
}
327540
}
328541
} else { // Don't clear results while scanning is active
329542
if (!isContinue) {
330543
clearResults();
544+
m_stats.reset();
331545
}
332546
}
333547

@@ -394,6 +608,8 @@ bool NimBLEScan::stop() {
394608
return false;
395609
}
396610

611+
clearWaitingList();
612+
397613
if (m_maxResults == 0) {
398614
clearResults();
399615
}
@@ -414,6 +630,7 @@ void NimBLEScan::erase(const NimBLEAddress& address) {
414630
NIMBLE_LOGD(LOG_TAG, "erase device: %s", address.toString().c_str());
415631
for (auto it = m_scanResults.m_deviceVec.begin(); it != m_scanResults.m_deviceVec.end(); ++it) {
416632
if ((*it)->getAddress() == address) {
633+
removeWaitingDevice(*it);
417634
delete *it;
418635
m_scanResults.m_deviceVec.erase(it);
419636
break;
@@ -429,6 +646,7 @@ void NimBLEScan::erase(const NimBLEAdvertisedDevice* device) {
429646
NIMBLE_LOGD(LOG_TAG, "erase device: %s", device->getAddress().toString().c_str());
430647
for (auto it = m_scanResults.m_deviceVec.begin(); it != m_scanResults.m_deviceVec.end(); ++it) {
431648
if ((*it) == device) {
649+
removeWaitingDevice(*it);
432650
delete *it;
433651
m_scanResults.m_deviceVec.erase(it);
434652
break;
@@ -483,6 +701,12 @@ NimBLEScanResults NimBLEScan::getResults() {
483701
* @brief Clear the stored results of the scan.
484702
*/
485703
void NimBLEScan::clearResults() {
704+
if (isScanning()) {
705+
NIMBLE_LOGW(LOG_TAG, "Cannot clear results while scan is active");
706+
return;
707+
}
708+
709+
clearWaitingList();
486710
if (m_scanResults.m_deviceVec.size()) {
487711
std::vector<NimBLEAdvertisedDevice*> vSwap{};
488712
ble_npl_hw_enter_critical();

0 commit comments

Comments
 (0)