From 8c74e3d5efa3387ca45f0d2ad3b134d9e0017ecc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:49 -0500 Subject: [PATCH] [bluetooth_proxy] Deliver scanner state through the hub callback (#18175) --- esphome/components/ble_device_base/ble_hub.h | 35 ++++++++++++ .../components/bluetooth_proxy/__init__.py | 8 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 56 ++++++++++--------- .../bluetooth_proxy/bluetooth_proxy.h | 16 +----- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++ .../esp32_ble_tracker/esp32_ble_tracker.h | 14 +---- esphome/core/defines.h | 1 + .../ble_device_base/test_slot_counter.py | 10 ++-- .../test_scanner_state_callback.cpp | 52 +++++++++++++++++ 9 files changed, 136 insertions(+), 61 deletions(-) create mode 100644 tests/components/ble_device_base/test_scanner_state_callback.cpp diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index f4ad051430..aa813d03db 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -49,6 +49,28 @@ struct RawAdvertisementCallback { void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } }; +/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast +/// directly (pinned by static_asserts at the cast sites). +enum class ScannerState : uint8_t { + IDLE = 0, + STARTING = 1, + RUNNING = 2, + FAILED = 3, + STOPPING = 4, + STOPPED = 5, +}; + +/// Subscriber slot for scanner-state transitions; same shape as +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Hubs that +/// cannot push drop the registration and the consumer falls back to polling +/// scan_running(). +struct ScannerStateCallback { + void *instance{nullptr}; + void (*fn)(void *instance, ScannerState state){nullptr}; + bool is_set() const { return this->fn != nullptr; } + void invoke(ScannerState state) const { this->fn(this->instance, state); } +}; + /// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. struct HubCapabilities { /// Controller can send scan requests (active scanning). @@ -79,6 +101,19 @@ class BLEHub { /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + /// Push subscriber for scanner-state transitions; hubs that can push + /// invoke scanner_state_callback_ where their state changes. Compiled only + /// when a subscriber exists (bluetooth_proxy emits the define), so + /// subscriber-less builds carry no storage. + void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; } + + protected: + ScannerStateCallback scanner_state_callback_{}; + + public: +#endif // USE_BLE_SCANNER_STATE_CALLBACK + virtual HubCapabilities get_capabilities() const = 0; /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4aa4195ff9..8d9aa88bd8 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,12 +374,10 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - # Advertisements arrive through the hub raw callback (installed in - # setup()); only the scanner-state listener still registers with the - # tracker directly. + # Advertisements and scanner state arrive through the hub callbacks + # (installed in setup()); the tracker stays typed for scan-mode calls. tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) cg.add(var.set_parent(tracker)) - await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) @@ -428,3 +426,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("USE_BLUETOOTH_PROXY") + # Compiles the scanner-state push slot into the hub (see ble_hub.h). + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 56b79fe1b4..3f44adbef4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -25,15 +25,22 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } -#ifdef USE_ESP32 +// The neutral enum's values are the wire values. +static_assert(static_cast(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE); +static_assert(static_cast(ble_device_base::ScannerState::STARTING) == + api::enums::BLUETOOTH_SCANNER_STATE_STARTING); +static_assert(static_cast(ble_device_base::ScannerState::RUNNING) == + api::enums::BLUETOOTH_SCANNER_STATE_RUNNING); +static_assert(static_cast(ble_device_base::ScannerState::FAILED) == + api::enums::BLUETOOTH_SCANNER_STATE_FAILED); +static_assert(static_cast(ble_device_base::ScannerState::STOPPING) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPING); +static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPED); -void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } -} - -void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { +bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { + if (this->api_connection_ == nullptr) + return false; api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -41,30 +48,21 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + return this->api_connection_->send_message(resp); } -#else // !USE_ESP32 - -void BluetoothProxy::send_bluetooth_scanner_state_() { +#ifndef USE_ESP32 +void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a // full TX buffer) is retried from loop() instead of leaving a stale state. const bool running = this->hub_->scan_running(); - api::BluetoothScannerStateResponse resp; - resp.state = running ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING - : api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE; - resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - resp.configured_mode = this->configured_scan_active_ - ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - if (this->api_connection_->send_message(resp)) { + if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING + : ble_device_base::ScannerState::IDLE)) { this->last_scan_running_ = running; } } - -#endif // USE_ESP32 +#endif // !USE_ESP32 void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -77,6 +75,9 @@ void BluetoothProxy::setup() { this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { static_cast(self)->on_raw_advertisement_(adv); }}); + this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { + static_cast(self)->send_bluetooth_scanner_state_(state); + }}); } // The hub delivers raw advertisements on the ESPHome main loop. @@ -510,9 +511,10 @@ void BluetoothProxy::loop() { return; } - // The hub has no scanner-state listener interface; poll and report on change. + // This hub doesn't push scanner-state transitions; poll and report on + // change. A hub gaining push must also refresh last_scan_running_ here. if (this->hub_->scan_running() != this->last_scan_running_) { - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } this->flush_pending_advertisements_(); @@ -600,7 +602,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { // Reports the mode change; the sender also refreshes last_scan_running_, so // a failed restart (scan_running_ dropped by the tracker) is not reported // again by loop() on the next tick. - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } } @@ -623,7 +625,7 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection #ifdef USE_ESP32 this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); #else - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); #endif } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index ed39a697aa..86d45c144a 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -72,11 +72,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -#ifdef USE_ESP32 -class BluetoothProxy final : public esp32_ble_tracker::BLEScannerStateListener, public Component { -#else class BluetoothProxy final : public Component { -#endif #ifdef BLUETOOTH_CONNECTION_HAS_GATT // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; @@ -135,11 +131,6 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } -#ifdef USE_ESP32 - /// BLEScannerStateListener interface - void on_scanner_state(esp32_ble_tracker::ScannerState state) override; -#endif - uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -213,10 +204,9 @@ class BluetoothProxy final : public Component { } protected: -#ifdef USE_ESP32 - void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); -#else - void send_bluetooth_scanner_state_(); + bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); +#ifndef USE_ESP32 + void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index cec2f230f8..e51b293bfe 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -422,6 +422,11 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + if (this->scanner_state_callback_.is_set()) { + this->scanner_state_callback_.invoke(state); + } +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 88642fff6b..c570c28122 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -95,18 +95,8 @@ using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; using ble_device_base::client_state_to_string; -enum class ScannerState { - // Scanner is idle, init state - IDLE, - // Scanner is starting - STARTING, - // Scanner is running - RUNNING, - // Scanner failed to start - FAILED, - // Scanner is stopping - STOPPING, -}; +// Neutral scanner lifecycle re-exported for backward compatibility. +using ScannerState = ble_device_base::ScannerState; /** Listener interface for BLE scanner state changes. * diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1685467a4b..7ddc607c5c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,6 +252,7 @@ // platforms whose API/network types the proxy header cannot assume. #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY +#define USE_BLE_SCANNER_STATE_CALLBACK // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on // advertisement-only hubs) — so static analysis checks the same diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index 1c1499cb2d..daa2884588 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -103,17 +103,17 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None -def test_esp32_bluetooth_proxy_requests_scanner_state_slot( +def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The proxy requests one scanner state slot and a client slot per - connection (three by default with active: true); advertisements arrive - through the hub raw callback, so no listener slot exists.""" + """The proxy requests a client slot per connection (three by default with + active: true); advertisements and scanner state arrive through the hub + callbacks, so no listener or scanner-state slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert ( get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - == "1" + is None ) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" diff --git a/tests/components/ble_device_base/test_scanner_state_callback.cpp b/tests/components/ble_device_base/test_scanner_state_callback.cpp new file mode 100644 index 0000000000..7515b2f38e --- /dev/null +++ b/tests/components/ble_device_base/test_scanner_state_callback.cpp @@ -0,0 +1,52 @@ +#include + +#include + +#include "esphome/components/ble_device_base/ble_hub.h" + +namespace esphome::ble_device_base::testing { + +// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback: +// a default-constructed slot is "no subscriber", a set slot delivers the +// state, and a new registration replaces the old. +namespace { + +struct CapturingSubscriber { + ScannerState last{ScannerState::IDLE}; + int calls{0}; + + static void trampoline(void *self, ScannerState state) { + auto *sub = static_cast(self); + sub->last = state; + sub->calls++; + } +}; + +} // namespace + +TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) { + const ScannerStateCallback callback{}; + EXPECT_FALSE(callback.is_set()); +} + +TEST(ScannerStateCallback, SubscriberSeesState) { + CapturingSubscriber subscriber; + ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline}; + ASSERT_TRUE(callback.is_set()); + callback.invoke(ScannerState::RUNNING); + EXPECT_EQ(subscriber.calls, 1); + EXPECT_EQ(subscriber.last, ScannerState::RUNNING); +} + +TEST(ScannerStateCallback, NewSubscriberReplacesOld) { + CapturingSubscriber first; + CapturingSubscriber second; + ScannerStateCallback callback{&first, CapturingSubscriber::trampoline}; + callback = {&second, CapturingSubscriber::trampoline}; + callback.invoke(ScannerState::STOPPED); + EXPECT_EQ(first.calls, 0); + EXPECT_EQ(second.calls, 1); + EXPECT_EQ(second.last, ScannerState::STOPPED); +} + +} // namespace esphome::ble_device_base::testing