From e9f428983ea3b8faa31c400cfc453ee17883b747 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 08:57:31 -0500 Subject: [PATCH] [ble_device_base] Bind BLEHub to the build's tracker at compile time (#18181) --- .../components/bk72xx_ble_tracker/__init__.py | 3 + .../bk72xx_ble_tracker/bk72xx_ble_tracker.h | 15 ++-- .../components/ble_device_base/__init__.py | 28 +++--- .../components/ble_device_base/automation.h | 11 +-- esphome/components/ble_device_base/ble_hub.h | 88 ++++++++----------- .../components/ble_device_base/ble_hub_impl.h | 35 ++++++++ .../components/bluetooth_proxy/__init__.py | 10 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 31 ++++--- .../bluetooth_proxy/bluetooth_proxy.h | 30 ++----- .../components/esp32_ble_tracker/__init__.py | 3 + .../esp32_ble_tracker/esp32_ble_tracker.h | 24 +++-- .../components/ln882h_ble_tracker/__init__.py | 3 + .../ln882h_ble_tracker/ln882h_ble_tracker.h | 15 ++-- .../components/rp2_ble_tracker/__init__.py | 3 + .../rp2_ble_tracker/rp2_ble_tracker.h | 15 ++-- esphome/core/defines.h | 18 +++- .../config/ln882h_tracker.yaml | 7 ++ .../ble_device_base/test_hub_binding.py | 29 +++++- .../ble_device_base/test_raw_callback.cpp | 11 +-- .../test_scan_mode_request.cpp | 76 ---------------- 20 files changed, 228 insertions(+), 227 deletions(-) create mode 100644 esphome/components/ble_device_base/ble_hub_impl.h create mode 100644 tests/component_tests/ble_device_base/config/ln882h_tracker.yaml delete mode 100644 tests/components/ble_device_base/test_scan_mode_request.cpp diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index e7f8ed92ba8..7fefb310cd9 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -144,6 +144,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_BK72XX_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 67e4467c77e..cc51918da58 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -45,7 +45,6 @@ namespace esphome::bk72xx_ble_tracker { // --------------------------------------------------------------------------- class BK72xxBLETracker : public Component, - public ble_device_base::BLEHub, public bk72xx_ble::BLEScanListener, public Parented #ifdef USE_OTA_STATE_LISTENER @@ -93,15 +92,15 @@ class BK72xxBLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // The Beken BDK exposes no active-scan path (passive scanning only), so the // controller never solicits scan responses and never merges them; consumers // relying on scan-response fields (device names) get them only where the @@ -110,21 +109,21 @@ class BK72xxBLETracker : public Component, // path there is no mode to switch to. return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; } - bool request_scan_mode(bool active) override { + bool request_scan_mode(bool active) { // Passive-only controller: a passive request is already honored, an active // one cannot be. return !active; } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) override { + void get_adapter_mac(uint8_t out[6]) { uint8_t mac[6]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return false; } // BK72xx scan is passive-only + bool scan_running() { return this->scan_running_; } + bool scan_active() { return false; } // BK72xx scan is passive-only // ---- bk72xx_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index a52286f4566..fa664488674 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -3,19 +3,22 @@ ble_device_base — the platform-neutral BLE layer. Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract -(BLEHub, in ble_hub.h) on every platform. +(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h) +on every platform. BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared -subclass, so there is no platform table here and no dependency in either -direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an -explicit ble_hub_id: is a declared key even on strict schemas) and calls -register_ble_device() in to_code; a tracker component subclasses BLEHub (C++ -and codegen class) and MUST call register_hub_provider() at import time — -without it _require_hub rejects configs that bind through the generated id -(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip -requires only a new in-tree tracker component; out-of-tree BLE hubs are -not supported. +subclass, so there is no Python platform table here and no dependency in +either direction (C++-side, the compile-time alias header ble_hub_impl.h and the +defines.h mirror are the deliberate exceptions). A sensor extends +BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a +declared key even on strict schemas) and calls register_ble_device() in +to_code; a tracker component declares BLEHub as its codegen-class parent and +MUST call register_hub_provider() at import time — without it _require_hub +rejects configs that bind through the generated id (an explicit ble_hub_id: +bypasses the registry). Adding a new BLE chip requires a new in-tree tracker +component plus its alias arm and define (see above); out-of-tree BLE hubs +are not supported. AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. @@ -48,8 +51,9 @@ LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") -# The neutral tracker contract. Every tracker's codegen class declares this as a -# parent, which is what lets cv.use_id(BLEHub) resolve any of them. +# The neutral tracker contract. Every tracker's codegen class declares this as +# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python +# only: C++-side the name is a per-platform alias (ble_hub_impl.h). BLEHub = ble_device_base_ns.class_("BLEHub") # The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h index 507b3278d94..ba3128c0eed 100644 --- a/esphome/components/ble_device_base/automation.h +++ b/esphome/components/ble_device_base/automation.h @@ -1,11 +1,12 @@ // Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses // registered on a BLEHub, exposed by each tracker under its own automation // names. parse_device()'s return feeds the "Found device" suppression. +// Constructors are templated on the hub type so this header also builds with +// no tracker present (host unit tests). #pragma once #include "ble_device.h" -#include "ble_hub.h" #include "esphome/core/automation.h" #include "esphome/core/helpers.h" @@ -18,7 +19,7 @@ namespace esphome::ble_device_base { // on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } @@ -39,7 +40,7 @@ class ESPBTAdvertiseTrigger final : public Trigger, public // data for the given UUID. Optional single-MAC filter. class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } @@ -73,7 +74,7 @@ class BLEServiceDataAdvertiseTrigger final : public Trigger, // manufacturer data for the given ID. Optional single-MAC filter. class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } @@ -108,7 +109,7 @@ class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: - explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); } + template explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); } bool parse_device(const ESPBTDevice &device) override { return false; } void on_scan_end() override { this->trigger(); } diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index aa813d03dbb..8e4c710bb19 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -1,13 +1,10 @@ // ble_hub.h // -// BLEHub — the platform-neutral BLE tracker contract. -// -// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, -// ln882h_ble_tracker, future chips) implements this interface; every BLE -// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via -// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares. -// Adding a new BLE chip therefore requires only a new tracker component that -// implements BLEHub: no consumer, registry, or base changes. +// The platform-neutral BLE tracker contract: shared types plus the method +// surface every tracker provides (documented below). Exactly one tracker +// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not +// an abstract interface — no vtable, every hub call inlinable. Consumers +// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub). // // Chip differences are expressed as data (HubCapabilities), never as // platform conditionals in consumers. @@ -15,7 +12,9 @@ #pragma once #include "ble_device.h" +#include "esphome/core/defines.h" +#include #include namespace esphome::ble_device_base { @@ -61,9 +60,8 @@ enum class ScannerState : uint8_t { }; /// 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(). +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs +/// that push provide the setter; consumers of the rest poll scan_running(). struct ScannerStateCallback { void *instance{nullptr}; void (*fn)(void *instance, ScannerState state){nullptr}; @@ -91,48 +89,34 @@ struct HubCapabilities { bool scan_mode_switch; }; -class BLEHub { - public: - virtual ~BLEHub() = default; - - /// Register a parsed-advertisement consumer (BLE sensors, automation triggers). - virtual void register_listener(ESPBTDeviceListener *listener) = 0; - - /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. - virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; - +// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias. +// Semantics beyond the signatures: +// - register_listener: parsed-advertisement consumers (sensors, triggers). +// - set_raw_advertisement_callback: raw stream, one consumer at a time. +// - get_adapter_mac: printable order, out[0] = MSB. +// - scan_active: the current/configured mode sends scan requests. +// - request_scan_mode: false = cannot honor, state untouched (the caller +// reports the real state back); true = applied immediately, restarting a +// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch. +// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback) +// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept +// requires both exactly when that define is set. A push hub must emit a +// transition for every accepted or refused mode request - consumers skip +// their own mode report on push builds. +template +concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback, + uint8_t *mac) { + hub.register_listener(listener); + hub.set_raw_advertisement_callback(raw_callback); + { T::get_capabilities() } -> std::same_as; + hub.get_adapter_mac(mac); + { hub.scan_running() } -> std::same_as; + { hub.scan_active() } -> std::same_as; + { hub.request_scan_mode(true) } -> std::same_as; #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. - virtual void get_adapter_mac(uint8_t out[6]) = 0; - - virtual bool scan_running() = 0; - /// True when the current/configured scan mode is active (scan requests sent). - virtual bool scan_active() = 0; - /// Request a scan-mode change (active = send scan requests). Returns false - /// when the hub cannot honor the request; the caller reports the real state - /// back to its subscriber. A hub that returns true applies the mode - /// immediately: a running scan is restarted with the new mode, an idle one - /// picks it up on its next start. The default cannot-change keeps hubs - /// without a mode switch (and out-of-tree trackers) building unchanged. - /// Independent of HubCapabilities::active_scan: that bit describes what the - /// CONTROLLER can do; whether this method honors requests is advertised by - /// HubCapabilities::scan_mode_switch, so consumers can gate features on the - /// switch without probing. - virtual bool request_scan_mode(bool active) { return false; } + hub.set_scanner_state_callback(ScannerStateCallback{}); + { hub.get_scanner_state() } -> std::same_as; +#endif }; } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub_impl.h b/esphome/components/ble_device_base/ble_hub_impl.h new file mode 100644 index 00000000000..87214ca7f72 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub_impl.h @@ -0,0 +1,35 @@ +// ble_hub_impl.h +// +// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's +// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header, +// trackers include ble_hub.h (the contract). + +#pragma once + +#include "ble_hub.h" +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE_TRACKER) +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker +#elif defined(USE_RP2_BLE_TRACKER) +#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker +#elif defined(USE_BK72XX_BLE_TRACKER) +#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker +#elif defined(USE_LN882H_BLE_TRACKER) +#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker +#endif +// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub. + +namespace esphome::ble_device_base { + +#ifdef ESPHOME_BLE_HUB_TYPE +using BLEHub = ESPHOME_BLE_HUB_TYPE; +static_assert(BLEHubContract, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)"); +#undef ESPHOME_BLE_HUB_TYPE +#endif + +} // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 8d9aa88bd8e..a28c8abc711 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -374,10 +374,12 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - # 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)) + cg.add(var.set_ble_hub(tracker)) + + # Compiles the scanner-state push slot into the tracker and the matching + # registration into the proxy; the other hubs are polled instead. + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) @@ -426,5 +428,3 @@ 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 3f44adbef4b..88d8cc18856 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -51,7 +51,7 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK 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 @@ -62,7 +62,7 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_ESP32 +#endif // !USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. @@ -75,9 +75,12 @@ 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); }}); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { static_cast(self)->send_bluetooth_scanner_state_(state); }}); +#endif } // The hub delivers raw advertisements on the ESPHome main loop. @@ -469,13 +472,15 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #ifdef USE_ESP32 void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_()->get_scan_active() == active) { + // esp32 only: BLEHub is the concrete tracker here, so these calls reach + // tracker-native methods beyond the neutral contract. + if (this->hub_->get_scan_active() == active) { return; } ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_()->set_scan_active(active); - this->parent_()->stop_scan(); - this->parent_()->set_scan_continuous( + this->hub_->set_scan_active(active); + this->hub_->stop_scan(); + this->hub_->set_scan_continuous( true); // Set this to true to automatically start scanning again when it has cleaned up. } @@ -511,11 +516,13 @@ void BluetoothProxy::loop() { return; } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK // This hub doesn't push scanner-state transitions; poll and report on - // change. A hub gaining push must also refresh last_scan_running_ here. + // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { this->send_polled_scanner_state_(); } +#endif this->flush_pending_advertisements_(); } @@ -598,12 +605,15 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); } } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK if (this->api_connection_ != nullptr) { // 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. + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. this->send_polled_scanner_state_(); } +#endif } #endif // USE_ESP32 @@ -622,8 +632,9 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; -#ifdef USE_ESP32 - this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // get_scanner_state() is part of the push-hub surface (see BLEHubContract). + this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); #else 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 9fc975680ea..d7150617d38 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -15,17 +15,13 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection.h" +#include "esphome/components/ble_device_base/ble_hub_impl.h" + #ifdef USE_ESP32 -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" - -#else -#include "esphome/components/ble_device_base/ble_hub.h" -#ifdef USE_BLE_GATT_CLIENT +#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" #endif -#endif // USE_ESP32 namespace esphome::bluetooth_proxy { @@ -75,11 +71,7 @@ class BluetoothProxy final : public Component { #endif public: BluetoothProxy(); -#ifdef USE_ESP32 - // Advertisements arrive through the hub's raw callback; parent_() below - // recovers the tracker type for the esp32-only scan-mode calls. - void set_parent(esp32_ble_tracker::ESP32BLETracker *parent) { this->hub_ = parent; } -#endif // USE_ESP32 + void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } void dump_config() override; void setup() override; void loop() override; @@ -88,7 +80,6 @@ class BluetoothProxy final : public Component { void register_connection(BluetoothConnection *connection); #endif // BLUETOOTH_CONNECTION_HAS_GATT #ifndef USE_ESP32 - void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and // the BLEHub contract does not promise those are settled any earlier than @@ -152,7 +143,7 @@ class BluetoothProxy final : public Component { // scan_mode_switch is the capability bit for exactly that (#18079) — // active_scan alone is not enough, a hub may support active scanning yet // refuse the runtime switch. - if (this->hub_->get_capabilities().scan_mode_switch) { + if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) { flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; } #endif @@ -188,7 +179,7 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -258,13 +249,6 @@ class BluetoothProxy final : public Component { std::array connections_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; -#ifdef USE_ESP32 - // set_parent() is the only writer of hub_ on esp32, so the downcast is - // exact; ESP32BLETracker derives from BLEHub non-virtually. - esp32_ble_tracker::ESP32BLETracker *parent_() { - return static_cast(this->hub_); - } -#endif // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; @@ -279,7 +263,7 @@ class BluetoothProxy final : public Component { bool active_; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_ESP32 +#ifndef USE_BLE_SCANNER_STATE_CALLBACK bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index b1ad07dfdd7..84f43fb54bf 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -205,6 +205,9 @@ async def to_code(config): # available on esp32 (sensors with irk: worked without opting in). ble_device_base.request_irk_support() + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_ESP32_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 9031d86c977..30b85b54173 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -161,7 +161,6 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker final : public Component, - public ble_device_base::BLEHub, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -186,19 +185,27 @@ class ESP32BLETracker final : public Component, void register_client(ESPBTClient *client); // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) { + this->scanner_state_callback_ = callback; + } +#endif + static constexpr ble_device_base::HubCapabilities get_capabilities() { // scan_mode_switch is false: the mode is driven through this tracker's own // API (set_scan_active + restart), not the neutral request_scan_mode(). return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } - bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } - bool scan_active() override { return this->scan_active_; } + void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } + bool scan_active() { return this->scan_active_; } + // The mode is driven through this tracker's own API (see get_capabilities); + // the neutral request refuses without changing any state. + bool request_scan_mode(bool active) { return false; } #ifdef USE_ESP32_BLE_DEVICE void print_bt_device_info(const ESPBTDevice &device); @@ -288,6 +295,9 @@ class ESP32BLETracker final : public Component, StaticVector neutral_listeners_; #endif ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + ble_device_base::ScannerStateCallback scanner_state_callback_{}; +#endif #ifdef USE_ESP32_BLE_DEVICE /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) ble_device_base::DiscoveredDeviceLog discovered_log_; diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index ceb2aeffec7..45f1b95164d 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -127,6 +127,9 @@ async def stop_scan_action_to_code( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_LN882H_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 1ad36c40d4f..9c0e0b2f1a8 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -26,7 +26,6 @@ namespace esphome::ln882h_ble_tracker { // --------------------------------------------------------------------------- class LN882HBLETracker : public Component, - public ble_device_base::BLEHub, public Parented, public ln882h_ble::BLEScanListener #ifdef USE_OTA_STATE_LISTENER @@ -75,15 +74,15 @@ class LN882HBLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // The LN882H controller supports active scanning; adv + scan response arrive // as separate reports and are merged by this tracker (Bluedroid semantics). // The SDK's GATT client is not exposed. @@ -92,15 +91,15 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) override { + void get_adapter_mac(uint8_t out[6]) { uint8_t mac[6]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; } - bool scan_running() override { return this->scan_running_; } - bool scan_active() override { return this->scan_active_; } - bool request_scan_mode(bool active) override; + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); // ---- ln882h_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main task — the diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 58401857687..15c1229a856 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -55,6 +55,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_RP2_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 8106763489d..054f6a65d21 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -17,7 +17,6 @@ namespace esphome::rp2_ble_tracker { class RP2BLETracker : public Component, - public ble_device_base::BLEHub, public rp2040_ble::BLEScanListener, public Parented #ifdef USE_OTA_STATE_LISTENER @@ -51,15 +50,15 @@ class RP2BLETracker : public Component, void stop_scan(); // ---- ble_device_base::BLEHub contract ---- - void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->listeners_.push_back(listener); #endif } - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { + static constexpr ble_device_base::HubCapabilities get_capabilities() { // 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 @@ -74,10 +73,10 @@ class RP2BLETracker : public Component, } // 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 this->scan_active_; } - bool request_scan_mode(bool active) override; + void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); // ---- rp2040_ble::BLEScanListener ---- // Delivered by the controller's loop() on the ESPHome main loop — the diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fd351356df3..ad24d273690 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,12 +252,12 @@ // 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 -// std::array instantiation a real build produces. +// count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits +// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis +// checks the same instantiations a real build produces. #ifdef USE_ESP32 +#define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #elif defined(USE_RP2) #define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 @@ -305,6 +305,7 @@ #define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT +#define USE_ESP32_BLE_TRACKER #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 @@ -465,6 +466,7 @@ #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE +#define USE_RP2_BLE_TRACKER #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_BLE_GATT_CLIENT @@ -489,6 +491,14 @@ #define BK72XX_BLE_SCAN_LISTENER_COUNT 1 #define USE_LN882H_BLE #define LN882H_BLE_SCAN_LISTENER_COUNT 1 +// One tracker arm per build: ln882x gets its real hub; bk72xx also stands in +// for hub-less LibreTiny chips (rtl87xx) so bluetooth_proxy.h has a BLEHub +// to parse against. +#ifdef USE_LN882X +#define USE_LN882H_BLE_TRACKER +#else +#define USE_BK72XX_BLE_TRACKER +#endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK diff --git a/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml new file mode 100644 index 00000000000..891d65ecf60 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/ln882h_tracker.yaml @@ -0,0 +1,7 @@ +esphome: + name: slotcount-ln882h-tracker + +ln882x: + board: generic-ln882h + +ln882h_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_hub_binding.py b/tests/component_tests/ble_device_base/test_hub_binding.py index 3dc71daf587..59aecc461ba 100644 --- a/tests/component_tests/ble_device_base/test_hub_binding.py +++ b/tests/component_tests/ble_device_base/test_hub_binding.py @@ -1,6 +1,6 @@ """Tests for the BLE hub provider registry and the missing-hub diagnostics.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from importlib import import_module from pathlib import Path @@ -202,3 +202,30 @@ def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) - assert "0x00,0xff,0xee,0xdd" in emitted[2] with pytest.raises(ValueError, match="Unsupported UUID format"): ble_device_base.add_service_uuid(var, "123") + + +@pytest.mark.parametrize( + ("config_name", "define"), + [ + ("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"), + ("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"), + ("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"), + ("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"), + ], +) +def test_every_tracker_emits_its_alias_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_name: str, + define: str, +) -> None: + """Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the + ble_hub_impl.h alias ladder selects on it. Checked through real codegen + (the other two legs of the invariant, the ladder arm and the defines.h + mirror, are compile-enforced: a missing arm fails any build containing a + BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses + on every tracker platform - and clang-tidy compiles each arm's + static_assert).""" + generate_main(component_config_path(config_name)) + + assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen" diff --git a/tests/components/ble_device_base/test_raw_callback.cpp b/tests/components/ble_device_base/test_raw_callback.cpp index 4d72c8fb180..62a9aebb81c 100644 --- a/tests/components/ble_device_base/test_raw_callback.cpp +++ b/tests/components/ble_device_base/test_raw_callback.cpp @@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing { // // The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against // the Beken SDK and cannot run host-side, so the guard-and-fire semantics are -// pinned here through a minimal host BLEHub implementation instead. +// pinned here through a minimal host hub carrying only the slot under test. namespace { -class FakeHub : public BLEHub { +class FakeHub { public: - void register_listener(ESPBTDeviceListener *listener) override {} - void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; } - HubCapabilities get_capabilities() const override { return {false, false, false}; } - void get_adapter_mac(uint8_t out[6]) override {} - bool scan_running() override { return false; } - bool scan_active() override { return false; } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; } /// The emit path every tracker implements: fire only when a subscriber is set. void emit(const RawAdvertisement &adv) { diff --git a/tests/components/ble_device_base/test_scan_mode_request.cpp b/tests/components/ble_device_base/test_scan_mode_request.cpp deleted file mode 100644 index 9125eb6f2fc..00000000000 --- a/tests/components/ble_device_base/test_scan_mode_request.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include - -#include - -#include "esphome/components/ble_device_base/ble_hub.h" - -namespace esphome::ble_device_base::testing { - -// Pins the request_scan_mode() contract: the base default refuses (so hubs -// without a mode switch — and out-of-tree trackers — keep building and -// callers report the real state), while an overriding hub both honors the -// request and applies it. -namespace { - -class DefaultHub : public BLEHub { - public: - void register_listener(ESPBTDeviceListener *listener) override {} - void set_raw_advertisement_callback(RawAdvertisementCallback callback) override {} - HubCapabilities get_capabilities() const override { return {false, false, false}; } - void get_adapter_mac(uint8_t out[6]) override {} - bool scan_running() override { return false; } - // Backed by real state so "changes nothing" is observable: a base default - // that silently mutated the hub would flip this and fail the assertion. - bool scan_active() override { return this->active_; } - - protected: - bool active_{true}; -}; - -class SwitchingHub : public DefaultHub { - public: - HubCapabilities get_capabilities() const override { return {true, false, false, /* scan_mode_switch = */ true}; } - bool request_scan_mode(bool active) override { - this->active_ = active; - return true; - } -}; - -// The esp32 shape: the controller supports active scanning but the hub keeps -// the refusing default (mode is driven through its own tracker API). -class CapableRefusingHub : public DefaultHub { - public: - HubCapabilities get_capabilities() const override { return {true, false, false}; } -}; - -} // namespace - -TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) { - DefaultHub hub; - EXPECT_TRUE(hub.scan_active()); - EXPECT_FALSE(hub.request_scan_mode(false)); - // Refused, not applied-and-reported-false: the state is untouched. - EXPECT_TRUE(hub.scan_active()); - EXPECT_FALSE(hub.request_scan_mode(true)); - EXPECT_TRUE(hub.scan_active()); -} - -TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) { - SwitchingHub hub; - EXPECT_TRUE(hub.get_capabilities().scan_mode_switch); - EXPECT_TRUE(hub.request_scan_mode(true)); - EXPECT_TRUE(hub.scan_active()); - EXPECT_TRUE(hub.request_scan_mode(false)); - EXPECT_FALSE(hub.scan_active()); -} - -TEST(BLEHubScanModeRequest, CapabilityAndSwitchAreIndependent) { - CapableRefusingHub hub; - EXPECT_TRUE(hub.get_capabilities().active_scan); - // The esp32 shape advertises no runtime switch, and the request refuses. - EXPECT_FALSE(hub.get_capabilities().scan_mode_switch); - EXPECT_FALSE(hub.request_scan_mode(false)); - EXPECT_TRUE(hub.scan_active()); -} - -} // namespace esphome::ble_device_base::testing