From 3e9808162bcaf9ccd97638a1a67060826dff05a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 00:31:18 -0500 Subject: [PATCH] Deliver scanner state through a hub push callback --- esphome/components/ble_device_base/ble_hub.h | 28 +++++++++++++ .../components/bluetooth_proxy/__init__.py | 6 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 41 +++++++++---------- .../bluetooth_proxy/bluetooth_proxy.h | 17 ++------ .../esp32_ble_tracker/esp32_ble_tracker.cpp | 7 ++++ .../ble_device_base/test_slot_counter.py | 8 ++-- 6 files changed, 64 insertions(+), 43 deletions(-) diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index d9a7731504..fdee047185 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -49,6 +49,27 @@ 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. Hubs that cannot push simply never invoke it 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). @@ -78,6 +99,10 @@ class BLEHub { /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; + /// Push subscriber for scanner-state transitions (stored here; hubs invoke + /// scanner_state_callback_ where their state changes, if they can push). + void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; } + virtual HubCapabilities get_capabilities() const = 0; /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. @@ -97,6 +122,9 @@ class BLEHub { /// HubCapabilities::scan_mode_switch, so consumers can gate features on the /// switch without probing. virtual bool request_scan_mode(bool active) { return false; } + + protected: + ScannerStateCallback scanner_state_callback_{}; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4aa4195ff9..ce20dc6cb5 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, [])) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 3b50b845a8..7907d00b68 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -24,15 +24,18 @@ 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::STOPPED) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPED); -void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { +void BluetoothProxy::on_scanner_state_(ble_device_base::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) { api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -40,30 +43,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. @@ -76,6 +70,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)->on_scanner_state_(state); + }}); } // The hub delivers raw advertisements on the ESPHome main loop. @@ -507,7 +504,7 @@ void BluetoothProxy::loop() { // The hub has no scanner-state listener interface; poll and report on change. if (this->hub_->scan_running() != this->last_scan_running_) { - this->send_bluetooth_scanner_state_(); + this->send_polled_scanner_state_(); } this->flush_pending_advertisements_(); @@ -595,7 +592,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_(); } } @@ -616,9 +613,9 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection } this->api_connection_ = api_connection; #ifdef USE_ESP32 - this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); + this->send_bluetooth_scanner_state_(static_cast(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 8f437b15f2..bf15fdabf5 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; @@ -138,11 +134,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; @@ -216,10 +207,10 @@ 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_(); + void on_scanner_state_(ble_device_base::ScannerState 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..1c04043ad7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -419,9 +419,16 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i #endif } +// The neutral enum mirrors this tracker's wire-aligned values. +static_assert(static_cast(ScannerState::IDLE) == static_cast(ble_device_base::ScannerState::IDLE)); +static_assert(static_cast(ScannerState::STOPPING) == static_cast(ble_device_base::ScannerState::STOPPING)); + void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; + if (this->scanner_state_callback_.is_set()) { + this->scanner_state_callback_.invoke(static_cast(state)); + } #ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); 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..866c7bce7e 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -107,13 +107,13 @@ def test_esp32_bluetooth_proxy_requests_scanner_state_slot( 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"