[rp2_ble_tracker] Add active scanning support (#18035)

This commit is contained in:
J. Nick Koston
2026-08-03 20:51:17 -05:00
committed by GitHub
parent 735c64d138
commit 11c3c06b12
9 changed files with 59 additions and 33 deletions
+6 -4
View File
@@ -183,6 +183,7 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect
global_ble->enqueue_scan_report_(mac_lsb, static_cast<int8_t>(gap_event_advertising_report_get_rssi(packet)),
gap_event_advertising_report_get_address_type(packet),
gap_event_advertising_report_get_advertising_event_type(packet),
gap_event_advertising_report_get_data(packet),
gap_event_advertising_report_get_data_length(packet));
break;
@@ -196,8 +197,8 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet,
// pool is sized to the queue capacity (SIZE-1), so allocate() returns nullptr
// before push() can find the ring full.
// NOLINTBEGIN(clang-analyzer-unix.Malloc)
void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type,
uint8_t adv_event_type, const uint8_t *data, uint16_t data_len) {
BLEScanReport *report = this->report_pool_.allocate();
if (report == nullptr) {
// Pool exhausted — the queue is full; count and drop.
@@ -207,6 +208,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi,
memcpy(report->mac, mac_lsb_first, 6);
report->rssi = rssi;
report->addr_type = addr_type;
report->adv_event_type = adv_event_type;
report->data_len =
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
memcpy(report->data, data, report->data_len);
@@ -216,7 +218,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi,
void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); }
bool RP2040BLE::scan_start(uint16_t interval, uint16_t window) {
bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) {
if (!this->is_active()) {
// Power control stays with the user (enable_on_boot or an explicit
// enable() call) — auto-enabling here would defeat enable_on_boot: false
@@ -226,7 +228,7 @@ bool RP2040BLE::scan_start(uint16_t interval, uint16_t window) {
// Serialize with the BTstack background worker (arduino-pico's BluetoothHCI
// takes the same lock around its gap_* calls).
BluetoothLock lock;
gap_set_scan_params(0 /* passive */, interval, window, 0 /* accept all */);
gap_set_scan_params(active ? 1 : 0, interval, window, 0 /* accept all */);
gap_start_scan();
return true;
}
+12 -8
View File
@@ -28,10 +28,13 @@ struct BLEScanReport {
uint8_t mac[6]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t addr_type;
uint8_t data_len; // bytes valid in data[]
// Legacy advertisement (31) + scan response (31): passive scans fill at most
// 31 bytes today, but bluetooth_proxy support will flip to active scanning
// in a future PR and the API raw-advertisement contract carries 62.
uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart
uint8_t data_len; // bytes valid in data[]
// Legacy advertisement (31) + scan response (31). BTstack delivers the two
// as separate reports, so each report fills at most 31 bytes today; the 62
// matches the API raw-advertisement contract. adv_event_type is what lets a
// future merge point tell the two frames apart — carrying it beyond this
// struct (RawAdvertisement) is deferred until a consumer needs the merge.
uint8_t data[62];
// EventPool contract: nothing is heap-allocated inside a report.
@@ -79,14 +82,15 @@ class RP2040BLE final : public Component {
/// Register a consumer for scan reports (delivered on the main loop via loop()).
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
/// Start a passive controller scan. Interval/window are in BLE units
/// Start a controller scan; active sends scan requests and receives scan
/// responses as separate reports. Interval/window are in BLE units
/// (0.625 ms). Returns false until the stack is ACTIVE (callers retry — the
/// tracker's rate-limited retry loop); powering the stack on stays with the
/// user (enable_on_boot or an explicit enable() call). The controller keeps
/// no scan state: a disable()/enable() power cycle ends the scan, and the
/// caller must call scan_start() again once the stack is back to ACTIVE
/// (the tracker's loop() reconciliation does exactly that).
bool scan_start(uint16_t interval, uint16_t window);
bool scan_start(uint16_t interval, uint16_t window, bool active);
/// Stop the controller scan (no-op when not scanning).
void scan_stop();
@@ -95,8 +99,8 @@ class RP2040BLE final : public Component {
/// Buffer one controller report (BTstack packet handler, CYW43 async-context
/// IRQ — bounded copy into the lock-free queue, nothing else).
void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len);
void enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, uint8_t addr_type, uint8_t adv_event_type,
const uint8_t *data, uint16_t data_len);
std::vector<BLEScanListener *> scan_listeners_;
// Report ring: the BTstack packet handler (async-context IRQ) allocates a
+14 -3
View File
@@ -13,7 +13,13 @@ import esphome.codegen as cg
from esphome.components import ble_device_base, ota, rp2040_ble
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL
from esphome.const import (
CONF_ACTIVE,
CONF_CONTINUOUS,
CONF_DURATION,
CONF_ID,
CONF_INTERVAL,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
@@ -32,8 +38,12 @@ RP2BLETracker = rp2_ble_tracker_ns.class_(
# interval defaults to 100 ms with the shared 30 ms window, a 30 % duty cycle —
# the same defaults as bk72xx_ble_tracker, leaving the radio mostly free for
# WiFi on the shared CYW43. Converted to the controller's 0.625 ms BLE units in
# to_code().
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms")
# to_code(). `active` defaults on for esp32_ble_tracker parity; it adds scan
# request TX and roughly doubles the reports through the queue, so
# `active: false` is the lighter choice when scan response data is not needed.
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"100ms", supports_active=True
)
CONFIG_SCHEMA = cv.Schema(
{
@@ -68,6 +78,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
CORE.add_job(_emit_listener_count)
@@ -111,10 +111,12 @@ void RP2BLETracker::dump_config() {
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n"
" Scan Type: %s\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_,
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, YESNO(this->scan_continuous_));
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_,
this->scan_active_ ? LOG_STR_LITERAL("ACTIVE") : LOG_STR_LITERAL("PASSIVE"),
YESNO(this->scan_continuous_));
}
void RP2BLETracker::on_scan_report(const rp2040_ble::BLEScanReport &report) {
@@ -167,16 +169,17 @@ void RP2BLETracker::start_scan_() {
// covers a failed start that came through the public start_scan().
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_)))
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_), static_cast<uint16_t>(this->scan_window_),
this->scan_active_))
return;
this->scan_running_ = true;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * BLE_SCAN_UNIT_MS,
this->scan_interval_ * BLE_SCAN_UNIT_MS);
ESP_LOGD(TAG, "Scan started (%s, window=%.0fms, interval=%.0fms)",
this->scan_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("passive"),
this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS);
// Re-anchor the scan period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
@@ -42,6 +42,7 @@ class RP2BLETracker : public Component,
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
// ---- Public scan control ----
@@ -59,19 +60,17 @@ class RP2BLETracker : public Component,
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
// BTstack on the CYW43 supports active scanning and GATT, but this tracker
// drives the controller passively (scan_type 0) and exposes no GATT path
// yet — capabilities describe what this component delivers, so all three
// stay false until those paths are implemented. Consumers relying on
// BTstack delivers scan responses as separate advertisement reports rather
// than merging them into the advertisement — consumers relying on
// scan-response fields (device names) get them only where the receiver
// merges per address (Home Assistant does).
return {.active_scan = false, .merges_scan_response = false, .gatt = false};
// merges per address (Home Assistant does). No GATT path yet.
return {.active_scan = true, .merges_scan_response = false, .gatt = false};
}
// The controller stores the address in printable (MSB-first) order, which is
// exactly what the contract wants.
void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); }
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return false; } // passive-only (initial implementation)
bool scan_active() override { return this->scan_active_; }
// ---- rp2040_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main loop — the
@@ -91,6 +90,7 @@ class RP2BLETracker : public Component,
uint32_t last_scan_start_attempt_{0}; // loop time of last start_scan_() attempt; rate-limits retries
uint32_t scan_period_start_{0}; // loop time at start of current scan period; rate-limits on_scan_end()
bool scan_running_{false};
bool scan_active_{true};
bool scan_continuous_{true};
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
@@ -62,11 +62,11 @@ def test_esp32_defaults_are_valid() -> None:
def test_rp2_defaults_are_valid() -> None:
"""rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the
shared CYW43 radio mostly free for WiFi."""
shared CYW43 radio mostly free for WiFi — and exposes active (default on)."""
config = RP2_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
assert "active" not in config
assert config["active"] is True
def test_esp32_active_can_disable() -> None:
@@ -1,12 +1,16 @@
# Exercises the controller scan API from a lambda: passive scan start with
# interval/window in 0.625 ms BLE units, stop, and the adapter MAC accessor.
# Exercises the controller scan API from a lambda: scan start with
# interval/window in 0.625 ms BLE units and the active flag, stop, and the
# adapter MAC accessor.
esphome:
on_boot:
then:
- lambda: |-
uint8_t mac[6];
id(ble).get_mac_msb_first(mac);
if (id(ble).scan_start(160, 48)) {
if (id(ble).scan_start(160, 48, false)) {
id(ble).scan_stop();
}
if (id(ble).scan_start(160, 48, true)) {
id(ble).scan_stop();
}
@@ -8,4 +8,5 @@ rp2_ble_tracker:
interval: 5000us
window: 2500us
duration: 5min
active: false
continuous: false
@@ -4,6 +4,7 @@ rp2_ble_tracker:
interval: 100ms
window: 30ms
duration: 5min
active: true
continuous: true
# Pulls in USE_OTA_STATE_LISTENER so the OTA scan-pause path compiles in CI