diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index e10e85f3c3..8280c19d56 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -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(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(data_len) : static_cast(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; } diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 9685b9294e..af7feddd26 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -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 scan_listeners_; // Report ring: the BTstack packet handler (async-context IRQ) allocates a diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 2ae53cfe30..5f29fece8a 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -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) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index b2c25a8d0e..28c5c927ea 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -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(this->scan_interval_), - static_cast(this->scan_window_))) + if (!this->parent_->scan_start(static_cast(this->scan_interval_), static_cast(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 diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 808e84a70f..bb2a6862af 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -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 diff --git a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py index e3226eaa41..bbf4953953 100644 --- a/tests/component_tests/ble_device_base/test_scan_parameter_validation.py +++ b/tests/component_tests/ble_device_base/test_scan_parameter_validation.py @@ -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: diff --git a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml index 251c83a92f..401a18c0de 100644 --- a/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml +++ b/tests/components/rp2040_ble/test-scan.rp2040-ard.yaml @@ -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(); } diff --git a/tests/components/rp2_ble_tracker/common-boundary.yaml b/tests/components/rp2_ble_tracker/common-boundary.yaml index c8b558e226..91e010b121 100644 --- a/tests/components/rp2_ble_tracker/common-boundary.yaml +++ b/tests/components/rp2_ble_tracker/common-boundary.yaml @@ -8,4 +8,5 @@ rp2_ble_tracker: interval: 5000us window: 2500us duration: 5min + active: false continuous: false diff --git a/tests/components/rp2_ble_tracker/common.yaml b/tests/components/rp2_ble_tracker/common.yaml index 12482a6124..633a4e1d0f 100644 --- a/tests/components/rp2_ble_tracker/common.yaml +++ b/tests/components/rp2_ble_tracker/common.yaml @@ -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