From 7218aa4803a7f38cdf8fc6bc2ea1903ab751ce3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 09:51:12 -0500 Subject: [PATCH 01/14] [bluetooth_connection] Explicit pairing for rp2 (#18166) --- .../ble_device_base/ble_gatt_client.h | 3 + .../bluetooth_connection.h | 15 +++- .../bluetooth_connection_hub.cpp | 11 +++ .../bluetooth_connection_hub.h | 5 ++ .../bluetooth_connection_rp2.cpp | 74 +++++++++++++++++++ .../bluetooth_connection_rp2.h | 5 ++ .../bluetooth_proxy/bluetooth_proxy.cpp | 18 +++-- .../esp32_ble_client/ble_client_base.h | 2 + .../test_gatt_client_contract.cpp | 10 +++ .../bluetooth_connection/__init__.py | 18 +++++ .../test_close_service_batch.cpp | 58 +++++++++++++++ 11 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 tests/components/bluetooth_connection/__init__.py create mode 100644 tests/components/bluetooth_connection/test_close_service_batch.cpp diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 1bcfcf99dc..37edc570ec 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -95,6 +95,7 @@ class GattClientEventListener { virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; /// Notification/indication data from the peer. data/len valid during the call. virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; + virtual void on_pairing_result(int status) {} }; /// One GATT client connection slot. Operations return 0 when accepted @@ -123,6 +124,8 @@ class BLEGattConnection { /// handle. Local registration only — the CCCD write is the API client's /// responsibility (it arrives as a plain write_descriptor). virtual int notify_characteristic(uint16_t handle, bool enable) = 0; + /// Initiate pairing on the live link. Completion: on_pairing_result(). + virtual int pair() { return GATT_ERR_NOT_CONNECTED; } virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) = 0; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 712251b157..2125d5b34f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -48,20 +48,29 @@ static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_C // What the platform's connection backend supports beyond GATT operations; // the proxy derives its feature flags and legacy version from these. -#ifdef USE_ESP32 +#if defined(USE_ESP32) static constexpr bool SUPPORTS_PAIRING = true; static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) +// The rp2 BTstack backend pairs (just works + bonding); it has no service +// cache to clear. Keyed on the backend, not the generic client define, so a +// future backend without pairing keeps the stub arm below. +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; #else static constexpr bool SUPPORTS_PAIRING = false; static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#ifdef USE_ESP32 +#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) conn_err_t unpair_device(uint64_t address); -conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +#endif +#ifdef USE_ESP32 +conn_err_t clear_gatt_cache(uint64_t address); +#else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 37d6b21dfe..b69a07fc31 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -74,6 +74,16 @@ void BluetoothConnection::check_disconnect_timeout_() { } } +void BluetoothConnection::on_pairing_result(int status) { + if (this->address_ == 0) { + // A drop before completion already answered: reset_connection_slot_ sends + // the connection response, which the client's pair watcher raises on. + return; + } + this->paired_ = status == 0; + this->proxy_->send_device_pairing(this->address_, status == 0, status); +} + void BluetoothConnection::reset_connection_(conn_err_t reason) { if (this->pending_error_ != 0) { reason = this->pending_error_; @@ -81,6 +91,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { } this->state_ = ClientState::IDLE; this->services_discovered_ = false; + this->paired_ = false; this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 83fbd24e4c..34e400ac01 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -49,6 +49,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->start_connect_(); } void disconnect(); + bool is_paired() const { return this->paired_; } + void set_unpaired() { this->paired_ = false; } + conn_err_t pair() { return this->backend_->pair(); } // A backend disconnect() is a single call that also cancels an in-progress // connect; there is no deferred-disconnect state to track. bool disconnect_pending() const { return false; } @@ -87,6 +90,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene void on_write_result(uint16_t handle, int error) override; void on_notify_state(uint16_t handle, bool enabled, int error) override; void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; protected: friend class bluetooth_proxy::BluetoothProxy; @@ -118,6 +122,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene // Group 5: 1-byte types ClientState state_{ClientState::IDLE}; + bool paired_{false}; ConnectionType connection_type_{ConnectionType::V1}; uint8_t remote_addr_type_{0}; uint8_t connection_index_{0}; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 5eb3da0263..cd7577e7f7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,4 +1,5 @@ #include "bluetooth_connection_rp2.h" +#include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -51,6 +52,7 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -88,6 +90,8 @@ void RP2GattClient::setup() { if (hci_event_registration.callback == nullptr) { hci_event_registration.callback = &RP2GattClient::hci_packet_handler; hci_add_event_handler(&hci_event_registration); + sm_event_registration.callback = &RP2GattClient::sm_packet_handler; + sm_add_event_handler(&sm_event_registration); } } @@ -157,6 +161,38 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * } } +void RP2GattClient::sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_JUST_WORKS_REQUEST: + // Confirming from the SM callback is the intended BTstack pattern. + // Unscoped on purpose: no peripheral role exists in-tree, and scoping + // would drop a request racing the queued CONNECTED event. + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_PAIRING_COMPLETE: { + RP2GattClient *inst = instance_for_con_handle(sm_event_pairing_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_pairing_complete_get_status(packet), 0); + } + break; + } + case SM_EVENT_REENCRYPTION_COMPLETE: { + // A bonded peer re-encrypts instead of pairing; BTstack emits only this + // event on that path, so it answers the PAIR request too. + RP2GattClient *inst = instance_for_con_handle(sm_event_reencryption_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_reencryption_complete_get_status(packet), 0); + } + break; + } + default: + break; + } +} + void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { if (type != HCI_EVENT_PACKET) { return; @@ -447,6 +483,11 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::WRITE_NO_RSP_DONE: this->finish_write_no_rsp_(event.status); break; + case RP2GattEvent::PAIRING_RESULT: + if (this->listener_ != nullptr) { + this->listener_->on_pairing_result(event.status); + } + break; } } @@ -1019,6 +1060,15 @@ int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16 return 0; } +int RP2GattClient::pair() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + sm_request_pairing(this->con_handle_); // void API; completion via SM events + return 0; +} + int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { if (this->state_ != EngineState::READY) { return GATT_ERR_NOT_CONNECTED; @@ -1064,6 +1114,30 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); } +conn_err_t unpair_device(uint64_t address) { + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + bool found = false; + BluetoothLock lock; + // Exhaustive: the db keys on (type, address), so stale entries can share + // the same address bytes under different types. + for (int i = 0; i < le_device_db_max_count(); i++) { + int addr_type = 0; + bd_addr_t addr; + le_device_db_info(i, &addr_type, addr, nullptr); + if (addr_type != BD_ADDR_TYPE_UNKNOWN && memcmp(addr, mac, sizeof(bd_addr_t)) == 0) { + le_device_db_remove(i); + found = true; + } + } + if (found) { + return CONN_OK; + } + // No bond for this address; the shared error domain has no closer code + // (esp32 parity: its remove-bond call also errors for an unknown address). + return GATT_NOT_CONNECTED; +} + } // namespace esphome::bluetooth_connection #endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 0c1bc95fe9..1a3671354d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -51,6 +51,7 @@ struct RP2GattEvent { MTU_EXCHANGED, // value = negotiated MTU QUERY_COMPLETE, // status = ATT status of the finished query WRITE_NO_RSP_DONE, // status = result of the deferred write + PAIRING_RESULT, // status = SM pairing status (0 = bonded) }; Type type; uint8_t status; @@ -89,6 +90,7 @@ class RP2GattClient final : public Component, int read_descriptor(uint16_t handle) override; int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; int notify_characteristic(uint16_t handle, bool enable) override; + int pair() override; int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) override; ble_device_base::GattServiceTable get_service_table() override; @@ -120,6 +122,7 @@ class RP2GattClient final : public Component, // BTstack packet handlers (IRQ context: copy-and-enqueue only). static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); @@ -205,6 +208,8 @@ class RP2GattClient final : public Component, static uint8_t instance_count; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static btstack_packet_callback_registration_t hci_event_registration; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t sm_event_registration; }; } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 19e894600e..56b79fe1b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -311,12 +311,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { -#ifdef USE_ESP32 + // Both connection classes expose the same pairing surface; success is + // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { auto err = connection->pair(); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_device_pairing(msg.address, false, err); } } else { @@ -326,15 +327,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest // Answer instead of leaving the client to time out. this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } -#else - // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); - // peripheral-initiated security still works through the platform's SM. - this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); -#endif break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + if (ret == CONN_OK) { + // The bond is gone; a live connection must not short-circuit the + // next PAIR as already paired. + auto *connection = this->get_connection_(msg.address, false); + if (connection != nullptr) { + connection->set_unpaired(); + } + } this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0902aad924..e4b9cd5100 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -92,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint16_t get_conn_id() const { return this->conn_id_; } uint64_t get_address() const { return this->address_; } bool is_paired() const { return this->paired_; } + // The proxy clears this when a bond is removed while the link is up. + void set_unpaired() { this->paired_ = false; } uint8_t get_connection_index() const { return this->connection_index_; } diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index fb743b2699..b4491295db 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -46,6 +46,16 @@ class MinimalConnection : public BLEGattConnection { void release_services() override {} }; +TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) { + // pair() defaults to not-connected and on_pairing_result() to a no-op, so + // a backend without pairing still answers the client through the dispatch. + MinimalConnection conn; + RecordingListener listener; + conn.set_listener(&listener); + EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED); + listener.on_pairing_result(0); // must not crash: default body +} + TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; RecordingListener listener; diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..eae98931ec --- /dev/null +++ b/tests/components/bluetooth_connection/__init__.py @@ -0,0 +1,18 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; + # emit the backend define so the host build exercises it. + async def to_code_testing(config): + # These defines are global to the merged host test binary; safe + # because no co-compiled test observes them. + cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) + + manifest.to_code = to_code_testing + # The batcher sizes api protobuf messages. + manifest.dependencies = manifest.dependencies + ["api"] diff --git a/tests/components/bluetooth_connection/test_close_service_batch.cpp b/tests/components/bluetooth_connection/test_close_service_batch.cpp new file mode 100644 index 0000000000..601ff9202b --- /dev/null +++ b/tests/components/bluetooth_connection/test_close_service_batch.cpp @@ -0,0 +1,58 @@ +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +#include "esphome/components/api/api_pb2.h" + +namespace esphome::bluetooth_connection { + +// The three cursor behaviors: a fitting service advances and continues, an +// overflowing batch with >1 service pops and retries it, and a single +// oversized service is force-advanced so the stream cannot wedge. + +static void add_service(api::BluetoothGATTGetServicesResponse &resp, uint16_t characteristics) { + resp.services.emplace_back(); + auto &svc = resp.services.back(); + svc.handle = resp.services.size(); + svc.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + svc.characteristics.init(characteristics); + for (uint16_t i = 0; i < characteristics; i++) { + auto &chr = svc.characteristics.emplace_back(); + chr.handle = 100 + i; + chr.properties = 0x12; + chr.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + } +} + +TEST(CloseServiceBatch, FittingServiceAdvancesAndContinues) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + size_t current_size = 0; + int16_t cursor = 0; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::CONTINUE); + EXPECT_EQ(cursor, 1); + EXPECT_GT(current_size, 0u); +} + +TEST(CloseServiceBatch, OverflowPopsAndRetriesWithoutAdvancing) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + add_service(resp, 1); + size_t current_size = MAX_PACKET_SIZE - 10; // any service is bigger than 10 bytes + int16_t cursor = 5; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(resp.services.size(), 1u); // popped for the next batch + EXPECT_EQ(cursor, 5); // not advanced: retried next batch +} + +TEST(CloseServiceBatch, SingleOversizedServiceForceAdvances) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 60); // ~30 bytes per characteristic, far past the budget + ASSERT_GT(resp.services.back().calculate_size(), MAX_PACKET_SIZE); + size_t current_size = 0; + int16_t cursor = 7; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(cursor, 8); // advanced despite not fitting, so the stream moves on +} + +} // namespace esphome::bluetooth_connection From 04384e0f5bcfbdba770f7133583eb5a425761ced Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:32 -0500 Subject: [PATCH 02/14] [ble_device_base] Bind the GATT backend at compile time (#18185) --- .../ble_device_base/ble_gatt_client.h | 118 ++++++++---------- esphome/components/ble_device_base/ble_hub.h | 5 +- .../bluetooth_connection_gatt_backend.h | 64 ++++++++++ .../bluetooth_connection_hub.cpp | 2 +- .../bluetooth_connection_hub.h | 32 ++--- .../bluetooth_connection_rp2.cpp | 2 + .../bluetooth_connection_rp2.h | 47 +++---- .../test_gatt_client_contract.cpp | 65 +++++----- .../bluetooth_connection/__init__.py | 1 + 9 files changed, 199 insertions(+), 137 deletions(-) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 37edc570ec..74548f578f 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -2,12 +2,13 @@ // // Platform-neutral GATT client connection contract. // -// A platform's GATT client backend (bluetooth_connection/esp32, -// bluetooth_connection/rp2) implements BLEGattConnection; consumers -// (bluetooth_proxy) drive it through this interface and receive -// completions through GattClientEventListener. All listener callbacks are -// delivered on the ESPHome main loop; borrowed data pointers are valid only -// for the duration of the call. +// Exactly one GATT backend exists per build, so BLEGattConnection is a +// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract +// interface. +// The hub BluetoothConnection wrapper drives it and receives completions +// through its event-sink methods, which the backend calls directly. All sink +// calls are delivered on the ESPHome main loop; borrowed data pointers are +// valid only for the duration of the call. // // Error domain (plain int, forwarded to the API without translation): // 0 success @@ -29,6 +30,7 @@ #include "ble_client_state.h" #include "ble_device.h" +#include #include namespace esphome::ble_device_base { @@ -76,67 +78,53 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; -/// Completion/event sink for a GATT connection. Implemented by the consumer -/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop. -class GattClientEventListener { - public: - virtual ~GattClientEventListener() = default; - - /// Connected (with negotiated MTU) or disconnected/connect-failed - /// (error = HCI status or disconnect reason). - virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0; - /// Service discovery finished; on success the service table is populated. - virtual void on_service_discovery_done(int error) = 0; - /// Characteristic or descriptor read finished. data/len valid during the call. - virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0; - /// Characteristic write-with-response or descriptor write finished. - virtual void on_write_result(uint16_t handle, int error) = 0; - /// Notification/indication registration state changed. - virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; - /// Notification/indication data from the peer. data/len valid during the call. - virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; - virtual void on_pairing_result(int status) {} +// The BLEGattConnection op surface, asserted where the alias binds +// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives +// through the sink) or a synchronous error (busy, not connected, stack +// rejection); one operation may be outstanding at a time. Semantics beyond +// the signatures: +// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). +// - disconnect: also cancels a connect in progress. +// - notify_characteristic: local registration only; the CCCD write is the +// API client's responsibility (a plain write_descriptor). +// - get_service_table/release_services: backend-owned transient storage, +// released after streaming (release is idempotent). +// - completions: connect and disconnect land in on_connection_state, +// discover_services in on_service_discovery_done, pair in +// on_pairing_result, reads in on_read_result, notify_characteristic in +// on_notify_state, characteristic writes with response and descriptor +// writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { + conn.set_listener(sink); + { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; + { conn.disconnect() } -> std::same_as; + { conn.discover_services() } -> std::same_as; + { conn.read_characteristic(uint16_t{}) } -> std::same_as; + { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; + { conn.read_descriptor(uint16_t{}) } -> std::same_as; + { conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { conn.notify_characteristic(uint16_t{}, true) } -> std::same_as; + { conn.pair() } -> std::same_as; + { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; + { conn.get_service_table() } -> std::same_as; + { conn.release_services() } -> std::same_as; }; -/// One GATT client connection slot. Operations return 0 when accepted -/// (completion arrives via the listener) or a synchronous error code -/// (busy, not connected, stack rejection). One operation may be outstanding -/// at a time; callers see a synchronous error otherwise. -class BLEGattConnection { - public: - virtual ~BLEGattConnection() = default; - - void set_listener(GattClientEventListener *listener) { this->listener_ = listener; } - - /// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant - /// (ble_device.h). Completion: on_connection_state(). - virtual int connect(uint64_t address, uint8_t addr_type) = 0; - /// Disconnect (or cancel a connect in progress). Completion: on_connection_state(). - virtual int disconnect() = 0; - /// Discover the peer's services/characteristics/descriptors into the - /// service table. Completion: on_service_discovery_done(). - virtual int discover_services() = 0; - virtual int read_characteristic(uint16_t handle) = 0; - virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0; - virtual int read_descriptor(uint16_t handle) = 0; - virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0; - /// Enable/disable delivery of on_notify_data() for a characteristic value - /// handle. Local registration only — the CCCD write is the API client's - /// responsibility (it arrives as a plain write_descriptor). - virtual int notify_characteristic(uint16_t handle, bool enable) = 0; - /// Initiate pairing on the live link. Completion: on_pairing_result(). - virtual int pair() { return GATT_ERR_NOT_CONNECTED; } - virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) = 0; - - /// Backend-owned service table (see GattServiceTable lifetime). - virtual GattServiceTable get_service_table() = 0; - /// Free the transient service table storage. Call after streaming; - /// idempotent (a call with no table held is a no-op). - virtual void release_services() = 0; - - protected: - GattClientEventListener *listener_{nullptr}; +// The event sink the backend calls directly (the hub BluetoothConnection +// wrapper), asserted where the wrapper is defined: on_connection_state +// carries the negotiated MTU and an HCI status/disconnect reason. The +// requirements check call validity, not exact parameter types; keep sink +// parameters at the documented widths (uint16_t handles and lengths). +template +concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { + { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; + { sink.on_service_discovery_done(int{}) } -> std::same_as; + { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; + { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; + { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; + { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { sink.on_pairing_result(int{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index d9a7731504..f4ad051430 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -58,8 +58,9 @@ struct HubCapabilities { /// may only see them where the receiver merges per address (Home Assistant does). bool merges_scan_response; /// GATT client connections are available: the platform has a - /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection - /// (ble_gatt_client.h). Today: esp32 and rp2. + /// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in + /// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client). + /// Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h new file mode 100644 index 0000000000..d8792b88c1 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -0,0 +1,64 @@ +// bluetooth_connection_gatt_backend.h +// +// Binds ble_device_base::BLEGattConnection to the build's one GATT backend. +// Backend and consumer both live in this component, so the ladder does too; +// backends implement ble_gatt_client.h (the neutral contract). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#if defined(USE_RP2040_BLE) +#include "bluetooth_connection_rp2.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) +// Emitted only by the host unit-test manifest: the tests compile the hub +// wrapper standalone, so bind a do-nothing backend. Every other backend-less +// build hits the #error below. +namespace esphome::bluetooth_connection { + +class BluetoothConnection; + +class StubGattBackend { + public: + void set_listener(BluetoothConnection *listener) {} + int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services() {} +}; + +} // namespace esphome::bluetooth_connection +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend +#else +#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here" +#endif + +namespace esphome::ble_device_base { + +using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; +static_assert(BLEGattConnectionContract, + "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); +#undef ESPHOME_BLE_GATT_CONNECTION_TYPE + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index b69a07fc31..ec03f18e1d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -96,7 +96,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- GattClientEventListener ---- +// ---- backend event sink ---- void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { if (connected && this->address_ == 0) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 34e400ac01..e79ee9e7a8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,6 +1,6 @@ -// Hub-platform BluetoothConnection: drives a platform GATT client backend -// through the neutral ble_device_base::BLEGattConnection interface and -// translates its events into the same API messages the esp32 class emits. +// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// ble_device_base::BLEGattConnection alias) and translates its events into +// the same API messages the esp32 class emits. // Presents the identical method surface, so the proxy's GATT dispatch // compiles against either class unchanged. @@ -13,7 +13,7 @@ #include "bluetooth_connection.h" #include "esphome/components/ble_device_base/ble_client_state.h" -#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" namespace esphome::bluetooth_proxy { @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final : public ble_device_base::GattClientEventListener { +class BluetoothConnection final { public: /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { @@ -83,14 +83,14 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->check_disconnect_timeout_(); } - // ---- ble_device_base::GattClientEventListener ---- - void on_connection_state(bool connected, uint16_t mtu, int error) override; - void on_service_discovery_done(int error) override; - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; - void on_write_result(uint16_t handle, int error) override; - void on_notify_state(uint16_t handle, bool enabled, int error) override; - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; - void on_pairing_result(int status) override; + // ---- backend event sink (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error); + void on_service_discovery_done(int error); + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); + void on_write_result(uint16_t handle, int error); + void on_notify_state(uint16_t handle, bool enabled, int error); + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); + void on_pairing_result(int status); protected: friend class bluetooth_proxy::BluetoothProxy; @@ -102,8 +102,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); - // Memory optimized layout for 32-bit systems (a vptr precedes: pointers and - // 2-byte members first fill to an 8-byte boundary before address_) + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; ble_device_base::BLEGattConnection *backend_{nullptr}; @@ -129,6 +128,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene bool services_discovered_{false}; }; +static_assert(ble_device_base::GattClientEventSinkContract, + "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); + } // namespace esphome::bluetooth_connection #endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index cd7577e7f7..dc730659f5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,4 +1,6 @@ #include "bluetooth_connection_rp2.h" + +#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 1a3671354d..d5bf76e6ee 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -1,11 +1,10 @@ // RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. // -// Implements ble_device_base::BLEGattConnection for the hub BluetoothConnection -// wrapper. BTstack packet handlers run in the CYW43 async-context low-priority -// IRQ (or on the main-loop stack during BluetoothLock release), so handlers -// only copy into per-instance lock-free queues/storage; loop() drains them and -// drives the state machine. Every BTstack call issued from the main loop is -// wrapped in BluetoothLock. +// The build's ble_device_base::BLEGattConnection backend (bound by alias in +// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the +// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy +// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call +// issued from the main loop is wrapped in BluetoothLock. #pragma once @@ -27,6 +26,8 @@ namespace esphome::bluetooth_connection { +class BluetoothConnection; + // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -72,29 +73,28 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; // full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; -class RP2GattClient final : public Component, - public ble_device_base::BLEGattConnection, - public Parented { +class RP2GattClient final : public Component, public Parented { public: void setup() override; void loop() override; void dump_config() override; float get_setup_priority() const override; - // ---- ble_device_base::BLEGattConnection ---- - int connect(uint64_t address, uint8_t addr_type) override; - int disconnect() override; - int discover_services() override; - int read_characteristic(uint16_t handle) override; - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override; - int read_descriptor(uint16_t handle) override; - int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; - int notify_characteristic(uint16_t handle, bool enable) override; - int pair() override; - int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) override; - ble_device_base::GattServiceTable get_service_table() override; - void release_services() override; + void set_listener(BluetoothConnection *listener) { this->listener_ = listener; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + ble_device_base::GattServiceTable get_service_table(); + void release_services(); protected: // Link/engine state. Discovery and GATT ops have their own cursors below — @@ -150,6 +150,7 @@ class RP2GattClient final : public Component, } // Group 1: containers / large storage + BluetoothConnection *listener_{nullptr}; ServiceArena *arena_{nullptr}; esphome::LockFreeQueue event_queue_; esphome::EventPool event_pool_; diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index b4491295db..25b6cbf002 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -1,5 +1,8 @@ // The GATT client contract compiles in no real build until a hub backend is // configured; this TU pins it on the host so the header cannot rot unseen. +// The contract is a concept (BLEGattConnection is a per-platform alias), so +// the minimal backend here proves the concept stays satisfiable and routes +// events through the duck-typed sink the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -8,57 +11,57 @@ namespace esphome::ble_device_base::testing { -class RecordingListener : public GattClientEventListener { - public: - void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } - void on_service_discovery_done(int error) override { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override {} - void on_write_result(uint16_t handle, int error) override {} - void on_notify_state(uint16_t handle, bool enabled, int error) override {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override {} +struct RecordingSink { + void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } + void on_service_discovery_done(int error) { this->discovery_error_ = error; } + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + void on_write_result(uint16_t handle, int error) {} + void on_notify_state(uint16_t handle, bool enabled, int error) {} + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + void on_pairing_result(int status) {} bool connected_{false}; int discovery_error_{0}; }; -class MinimalConnection : public BLEGattConnection { +static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); + +class MinimalConnection { public: - int connect(uint64_t address, uint8_t addr_type) override { + void set_listener(RecordingSink *listener) { this->listener_ = listener; } + + int connect(uint64_t address, uint8_t addr_type) { if (this->listener_ != nullptr) this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() override { return 0; } - int discover_services() override { + int disconnect() { return 0; } + int discover_services() { if (this->listener_ != nullptr) this->listener_->on_service_discovery_done(0); return 0; } - int read_characteristic(uint16_t handle) override { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override { return 0; } - int read_descriptor(uint16_t handle) override { return 0; } - int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override { return 0; } - int notify_characteristic(uint16_t handle, bool enable) override { return 0; } - int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, - uint16_t timeout) override { + int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int read_descriptor(uint16_t handle) { return 0; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } + int notify_characteristic(uint16_t handle, bool enable) { return 0; } + int pair() { return GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { return 0; } - GattServiceTable get_service_table() override { return {}; } - void release_services() override {} + GattServiceTable get_service_table() { return {}; } + void release_services() {} + + protected: + RecordingSink *listener_{nullptr}; }; -TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) { - // pair() defaults to not-connected and on_pairing_result() to a no-op, so - // a backend without pairing still answers the client through the dispatch. - MinimalConnection conn; - RecordingListener listener; - conn.set_listener(&listener); - EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED); - listener.on_pairing_result(0); // must not crash: default body -} +static_assert(BLEGattConnectionContract, + "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingListener listener; + RecordingSink listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index eae98931ec..eb6e174c0c 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -9,6 +9,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # These defines are global to the merged host test binary; safe # because no co-compiled test observes them. cg.add_define("USE_BLE_GATT_CLIENT") + cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") cg.add_define("USE_BLUETOOTH_PROXY") cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) From 8c74e3d5efa3387ca45f0d2ad3b134d9e0017ecc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 15:59:49 -0500 Subject: [PATCH 03/14] [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 From b1e1b0d7c58025ec1cea2c5576a6e580d555837c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 00:36:22 -0500 Subject: [PATCH 04/14] Retire the raw listener path and parser-type enum from the tracker --- .../bluetooth_connection_esp32.cpp | 7 ++-- .../bluetooth_connection_esp32.h | 2 +- .../bluetooth_proxy/bluetooth_proxy.h | 15 ++------- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 33 ++++--------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 15 +++------ 5 files changed, 16 insertions(+), 56 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index be6fa4c6c5..24ff4d2b03 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -479,10 +479,9 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } -esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - // RAW keeps the tracker from building parsed ESPBTDevice objects for the - // proxy's connections (the proxy itself consumes the hub raw callback). - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; +bool BluetoothConnection::wants_parsed_advertisements() { + // The proxy's connections never consume parsed ESPBTDevice objects. + return false; } } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index 531ff311a7..e59c575fb2 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -21,7 +21,7 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; + bool wants_parsed_advertisements() override; esp_err_t read_characteristic(uint16_t handle); esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 86d45c144a..b840574c2f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -23,7 +23,6 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include #endif -#include #else #include "esphome/components/ble_device_base/ble_hub.h" #ifdef USE_BLE_GATT_CLIENT @@ -179,19 +178,10 @@ class BluetoothProxy final : public Component { } void get_bluetooth_mac_address_pretty(std::span output) { -#ifdef USE_ESP32 - const uint8_t *mac = esp_bt_dev_get_address(); - if (mac != nullptr) { - format_mac_addr_upper(mac, output.data()); - } else { - output[0] = '\0'; - } -#else uint8_t mac[6] = {}; this->hub_->get_adapter_mac(mac); - // Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs - // (rp2040's BTstack) only learn the address once the link layer is up, and - // report all-zero until then. + // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn + // the address once the link layer is up, and report all-zero until then. bool nonzero = false; for (uint8_t b : mac) nonzero |= b != 0; @@ -200,7 +190,6 @@ class BluetoothProxy final : public Component { } else { output[0] = '\0'; } -#endif } protected: diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e51b293bfe..43e02bf228 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -271,7 +271,7 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { // Safe because ESP32BLETracker (singleton) outlives all registered clients. client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); - this->recalculate_advertisement_parser_types(); + this->recalculate_parse_advertisements_(); #endif } @@ -294,36 +294,29 @@ void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); this->listeners_.push_back(listener); - this->recalculate_advertisement_parser_types(); + this->recalculate_parse_advertisements_(); #endif } -void ESP32BLETracker::recalculate_advertisement_parser_types() { - this->raw_advertisements_ = false; +void ESP32BLETracker::recalculate_parse_advertisements_() { this->parse_advertisements_ = false; #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT // Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in // listeners_; without this, any later esp32-path registration (e.g. the proxy's - // GATT clients) would recompute the flags and silently drop parsed dispatch. + // GATT clients) would recompute the flag and silently drop parsed dispatch. if (!this->neutral_listeners_.empty()) this->parse_advertisements_ = true; #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) { - if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { + if (listener->wants_parsed_advertisements()) this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } } #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { - if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { + if (client->wants_parsed_advertisements()) this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } } #endif } @@ -478,20 +471,6 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { this->raw_advertisement_callback_.invoke(adv); } - // Process raw advertisements - if (this->raw_advertisements_) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - listener->parse_devices(&scan_result, 1); - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->parse_devices(&scan_result, 1); - } -#endif - } - // Process parsed advertisements if (this->parse_advertisements_) { #ifdef USE_ESP32_BLE_DEVICE diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index c570c28122..3461764e9c 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -35,11 +35,6 @@ using namespace esp32_ble; using adv_data_t = ble_device_base::adv_data_t; -enum AdvertisementParserType { - PARSED_ADVERTISEMENTS, - RAW_ADVERTISEMENTS, -}; - #ifdef USE_ESP32_BLE_UUID using ServiceData = ble_device_base::ServiceData; #endif @@ -63,10 +58,9 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { // Raw-only build: no parsed-device support is compiled in. bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif - virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; - virtual AdvertisementParserType get_advertisement_parser_type() { - return AdvertisementParserType::PARSED_ADVERTISEMENTS; - }; + /// False keeps the tracker from building parsed ESPBTDevice objects on + /// this registrant's account (raw consumers use the hub callback). + virtual bool wants_parsed_advertisements() { return true; } void set_parent(ESP32BLETracker *parent) { parent_ = parent; } protected: @@ -199,7 +193,6 @@ class ESP32BLETracker final : public Component, // esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer). void register_listener(ESPBTDeviceListener *listener); void register_client(ESPBTClient *client); - void recalculate_advertisement_parser_types(); // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; @@ -257,6 +250,7 @@ class ESP32BLETracker final : public Component, void gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m); /// Called to set the scanner state. Will also call callbacks to let listeners know when state is changed. void set_scanner_state_(ScannerState state); + void recalculate_parse_advertisements_(); /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); /// Process a single scan result immediately @@ -355,7 +349,6 @@ class ESP32BLETracker final : public Component, bool scan_continuous_before_ota_{false}; #endif bool ble_was_disabled_{true}; - bool raw_advertisements_{false}; bool parse_advertisements_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE bool coex_prefer_ble_{false}; From 7ec366c1ba27ab5c1835bdd50bd48349355d63e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 00:53:31 -0500 Subject: [PATCH 05/14] Monotonic parse flag, client-scoped opt-out, drop dead include --- .../bluetooth_connection_esp32.cpp | 5 ---- .../bluetooth_connection_esp32.h | 3 +- .../bluetooth_proxy/bluetooth_proxy.h | 3 -- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 29 +++---------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 9 +++--- 5 files changed, 11 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 24ff4d2b03..f5c59ca43a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -479,11 +479,6 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } -bool BluetoothConnection::wants_parsed_advertisements() { - // The proxy's connections never consume parsed ESPBTDevice objects. - return false; -} - } // namespace esphome::bluetooth_connection #endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index e59c575fb2..fb60d93e9c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -21,7 +21,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - bool wants_parsed_advertisements() override; + // The proxy's connections never consume parsed ESPBTDevice objects. + bool wants_parsed_advertisements() override { return false; } esp_err_t read_characteristic(uint16_t handle); esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index b840574c2f..2777418f09 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -20,9 +20,6 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID -#include -#endif #else #include "esphome/components/ble_device_base/ble_hub.h" #ifdef USE_BLE_GATT_CLIENT diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 43e02bf228..785bc258b1 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -271,7 +271,9 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { // Safe because ESP32BLETracker (singleton) outlives all registered clients. client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); - this->recalculate_parse_advertisements_(); + // Registration is add-only, so the flag is a monotonic OR. + if (client->wants_parsed_advertisements()) + this->parse_advertisements_ = true; #endif } @@ -294,30 +296,7 @@ void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); this->listeners_.push_back(listener); - this->recalculate_parse_advertisements_(); -#endif -} - -void ESP32BLETracker::recalculate_parse_advertisements_() { - this->parse_advertisements_ = false; -#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - // Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in - // listeners_; without this, any later esp32-path registration (e.g. the proxy's - // GATT clients) would recompute the flag and silently drop parsed dispatch. - if (!this->neutral_listeners_.empty()) - this->parse_advertisements_ = true; -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (listener->wants_parsed_advertisements()) - this->parse_advertisements_ = true; - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - if (client->wants_parsed_advertisements()) - this->parse_advertisements_ = true; - } + this->parse_advertisements_ = true; #endif } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 3461764e9c..5d925ba383 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -58,9 +58,6 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { // Raw-only build: no parsed-device support is compiled in. bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif - /// False keeps the tracker from building parsed ESPBTDevice objects on - /// this registrant's account (raw consumers use the hub callback). - virtual bool wants_parsed_advertisements() { return true; } void set_parent(ESP32BLETracker *parent) { parent_ = parent; } protected: @@ -116,6 +113,11 @@ class BLEScannerStateListener { /// increment the counter through this pointer when their state changes. /// The pointer may be null if the client is not registered with a tracker. class ESPBTClient : public ESPBTDeviceListener { + public: + /// False keeps the tracker from building parsed ESPBTDevice objects on + /// this client's account (raw consumers use the hub callback). + virtual bool wants_parsed_advertisements() { return true; } + public: virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; @@ -250,7 +252,6 @@ class ESP32BLETracker final : public Component, void gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_scan_stop_cmpl_evt_param ¶m); /// Called to set the scanner state. Will also call callbacks to let listeners know when state is changed. void set_scanner_state_(ScannerState state); - void recalculate_parse_advertisements_(); /// Common cleanup logic when transitioning scanner to IDLE state void cleanup_scan_state_(bool is_stop_complete); /// Process a single scan result immediately From 98dae49440a6cf9e9fdb636423e6604f7ffc8d70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 01:02:39 -0500 Subject: [PATCH 06/14] Remove redundant access specifier --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 5d925ba383..960692404e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -118,7 +118,6 @@ class ESPBTClient : public ESPBTDeviceListener { /// this client's account (raw consumers use the hub callback). virtual bool wants_parsed_advertisements() { return true; } - public: virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; From d680200e5aca09f20c4833efe7307a32201f91e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 01:38:33 -0500 Subject: [PATCH 07/14] Read the adapter MAC through esp_read_mac --- .../components/esp32_ble_tracker/esp32_ble_tracker.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 785bc258b1..5474c68a14 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -9,6 +9,7 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#include #endif #include #include @@ -286,10 +287,9 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li } void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { - get_mac_address_raw(out); // WiFi base MAC, MSB-first - // BT MAC = base MAC + 2 on the last octet only, wrapping without carry — - // exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET. - out[5] += 2; + // IDF owns the BT-offset derivation (base + 2 with four universal MACs, + // base + 1 with two); works before the BT stack is up. + esp_read_mac(out, ESP_MAC_BT); } void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { From 03c9b001f4811472e003fa9e3d6203bad6cb240c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 01:46:45 -0500 Subject: [PATCH 08/14] Ask the stack for the adapter MAC on hosted controllers --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5474c68a14..e6170af750 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -11,6 +11,7 @@ #include #include #endif +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #ifdef USE_OTA #include "esphome/components/ota/ota_backend.h" @@ -287,9 +289,21 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li } void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + // Hosted controller (esp32-p4): the adapter MAC lives in the remote chip's + // efuse, so only the running stack knows it; ESP_MAC_BT does not exist + // here. Null before init becomes all-zero, the proxy's unavailable state. + const uint8_t *mac = esp_bt_dev_get_address(); + if (mac != nullptr) { + memcpy(out, mac, 6); + } else { + memset(out, 0, 6); + } +#else // IDF owns the BT-offset derivation (base + 2 with four universal MACs, // base + 1 with two); works before the BT stack is up. esp_read_mac(out, ESP_MAC_BT); +#endif } void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { From 465a6a359639a0b4d8f7edbcb9c620205b00ae27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 01:50:49 -0500 Subject: [PATCH 09/14] Let esp32_ble own the adapter MAC lookup --- esphome/components/esp32_ble/ble.cpp | 18 ++++++++++++++++ esphome/components/esp32_ble/ble.h | 3 +++ .../esp32_ble_tracker/esp32_ble_tracker.cpp | 21 ------------------- .../esp32_ble_tracker/esp32_ble_tracker.h | 2 +- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index fb75e8837f..15b9322dbe 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -8,6 +8,7 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#include #else #include "esphome/components/watchdog/watchdog.h" #include @@ -674,6 +675,23 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif +void ESP32BLE::get_mac_msb_first(uint8_t out[6]) { +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + // The adapter MAC lives in the remote chip's efuse, so only the running + // stack knows it. + const uint8_t *mac = esp_bt_dev_get_address(); + if (mac != nullptr) { + memcpy(out, mac, 6); + } else { + memset(out, 0, 6); + } +#else + // IDF owns the BT-offset derivation (base + 2 with four universal MACs, + // base + 1 with two); works before the BT stack is up. + esp_read_mac(out, ESP_MAC_BT); +#endif +} + float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index c85ddfc983..326bdc291f 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -108,6 +108,9 @@ class ESP32BLE final : public Component { void setup() override; void loop() override; void dump_config() override; + /// Adapter MAC in printable (MSB-first) order. Hosted controllers only + /// know it once the stack is up; all-zero means unavailable. + void get_mac_msb_first(uint8_t out[6]); float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e6170af750..0950bfeb70 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -9,9 +9,7 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include -#include #endif -#include #include #include #include @@ -20,7 +18,6 @@ #include #include #include -#include #ifdef USE_OTA #include "esphome/components/ota/ota_backend.h" @@ -288,24 +285,6 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li #endif } -void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID - // Hosted controller (esp32-p4): the adapter MAC lives in the remote chip's - // efuse, so only the running stack knows it; ESP_MAC_BT does not exist - // here. Null before init becomes all-zero, the proxy's unavailable state. - const uint8_t *mac = esp_bt_dev_get_address(); - if (mac != nullptr) { - memcpy(out, mac, 6); - } else { - memset(out, 0, 6); - } -#else - // IDF owns the BT-offset derivation (base + 2 with four universal MACs, - // base + 1 with two); works before the BT stack is up. - esp_read_mac(out, ESP_MAC_BT); -#endif -} - void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 960692404e..ee1b1429c0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -206,7 +206,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) override; + 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_; } From c6d4ca7377f601870d880facdd583c7fc386768d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 01:54:36 -0500 Subject: [PATCH 10/14] Use one stack query for the adapter MAC --- esphome/components/esp32_ble/ble.cpp | 19 ++++++++----------- esphome/components/esp32_ble/ble.h | 3 +-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 15b9322dbe..456f02de9d 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -8,7 +8,6 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include -#include #else #include "esphome/components/watchdog/watchdog.h" #include @@ -676,27 +675,25 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat #endif void ESP32BLE::get_mac_msb_first(uint8_t out[6]) { -#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID - // The adapter MAC lives in the remote chip's efuse, so only the running - // stack knows it. + // The running stack owns the address (on hosted controllers it lives in + // the remote chip's efuse); null before init becomes all-zero. const uint8_t *mac = esp_bt_dev_get_address(); if (mac != nullptr) { memcpy(out, mac, 6); } else { memset(out, 0, 6); } -#else - // IDF owns the BT-offset derivation (base + 2 with four universal MACs, - // base + 1 with two); works before the BT stack is up. - esp_read_mac(out, ESP_MAC_BT); -#endif } float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - const uint8_t *mac_address = esp_bt_dev_get_address(); - if (mac_address) { + uint8_t mac_address[6]; + this->get_mac_msb_first(mac_address); + bool mac_known = false; + for (uint8_t b : mac_address) + mac_known |= b != 0; + if (mac_known) { const char *io_capability_s; switch (this->io_cap_) { case ESP_IO_CAP_OUT: diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 326bdc291f..43c9584c3c 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -108,8 +108,7 @@ class ESP32BLE final : public Component { void setup() override; void loop() override; void dump_config() override; - /// Adapter MAC in printable (MSB-first) order. Hosted controllers only - /// know it once the stack is up; all-zero means unavailable. + /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. void get_mac_msb_first(uint8_t out[6]); float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } From 962a82d1570cf33ae33dd72d9218128b7f2c3da1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 02:43:45 -0500 Subject: [PATCH 11/14] Make the adapter MAC accessor const --- esphome/components/esp32_ble/ble.cpp | 2 +- esphome/components/esp32_ble/ble.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 456f02de9d..a7b3330bd6 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,7 +674,7 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif -void ESP32BLE::get_mac_msb_first(uint8_t out[6]) { +void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { // The running stack owns the address (on hosted controllers it lives in // the remote chip's efuse); null before init becomes all-zero. const uint8_t *mac = esp_bt_dev_get_address(); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 43c9584c3c..45cfd8ee71 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -109,7 +109,7 @@ class ESP32BLE final : public Component { void loop() override; void dump_config() override; /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. - void get_mac_msb_first(uint8_t out[6]); + void get_mac_msb_first(uint8_t out[6]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } From ca5fa6dfaff1ed2d4f1d575f8e03679864c610a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 10:55:22 -0500 Subject: [PATCH 12/14] Use the shared MAC validity helper --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 5 +---- esphome/components/esp32_ble/ble.cpp | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 2777418f09..9fc975680e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -179,10 +179,7 @@ class BluetoothProxy final : public Component { this->hub_->get_adapter_mac(mac); // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn // the address once the link layer is up, and report all-zero until then. - bool nonzero = false; - for (uint8_t b : mac) - nonzero |= b != 0; - if (nonzero) { + if (mac_address_is_valid(mac)) { format_mac_addr_upper(mac, output.data()); } else { output[0] = '\0'; diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a7b3330bd6..d11683ab35 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -690,10 +690,7 @@ float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { uint8_t mac_address[6]; this->get_mac_msb_first(mac_address); - bool mac_known = false; - for (uint8_t b : mac_address) - mac_known |= b != 0; - if (mac_known) { + if (mac_address_is_valid(mac_address)) { const char *io_capability_s; switch (this->io_cap_) { case ESP_IO_CAP_OUT: From f68ebcf7d1d57bf2c745219a9dac84d56cf9b248 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 00:56:59 -0500 Subject: [PATCH 13/14] Retire the scanner-state listener interface --- .../components/esp32_ble_tracker/__init__.py | 17 -------------- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ---- .../esp32_ble_tracker/esp32_ble_tracker.h | 23 ------------------- esphome/core/defines.h | 1 - .../ble_device_base/test_slot_counter.py | 8 ------- 5 files changed, 54 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 646ce79233..0549a5f218 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -69,9 +69,6 @@ def _get_required_features() -> set[BLEFeatures]: # registered listener, client, or scanner state listener. _request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") _request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") -_request_scanner_state_listener_slot = cg.slot_counter( - "ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT" -) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -386,17 +383,3 @@ async def register_raw_client( paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var - - -async def register_scanner_state_listener( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a listener for scanner state changes. - - The slot request here is what sizes the tracker's listener storage; a - build with no registrations compiles the storage out entirely. - """ - _request_scanner_state_listener_slot() - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.add_scanner_state_listener(var)) - return var diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0950bfeb70..18b6cf022d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -392,11 +392,6 @@ void ESP32BLETracker::set_scanner_state_(ScannerState state) { 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); - } -#endif } void ESP32BLETracker::dump_config() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index ee1b1429c0..9031d86c97 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -89,16 +89,6 @@ using ble_device_base::client_state_to_string; // Neutral scanner lifecycle re-exported for backward compatibility. using ScannerState = ble_device_base::ScannerState; -/** Listener interface for BLE scanner state changes. - * - * Components can implement this interface to receive scanner state updates - * without the overhead of std::function callbacks. - */ -class BLEScannerStateListener { - public: - virtual void on_scanner_state(ScannerState state) = 0; -}; - /// Base class for BLE GATT clients that connect to remote devices. /// /// State Change Tracking Design: @@ -226,15 +216,6 @@ class ESP32BLETracker final : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - /// Add a listener for scanner state changes. Only compiled when a consumer - /// requested a slot in codegen: register through - /// esp32_ble_tracker.register_scanner_state_listener() in your component's - /// to_code, which requests the slot and emits this call. - void add_scanner_state_listener(BLEScannerStateListener *listener) { - this->scanner_state_listeners_.push_back(listener); - } -#endif ScannerState get_scanner_state() const { return this->scanner_state_; } protected: @@ -300,10 +281,6 @@ class ESP32BLETracker final : public Component, #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT - StaticVector - scanner_state_listeners_; #endif // Parsed listeners registered through the neutral BLEHub contract (migrated // sensors); dispatched alongside listeners_. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7ddc607c5c..fd351356df 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -307,7 +307,6 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 -#define ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 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 daa2884588..c174673155 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -95,10 +95,6 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None # No consumer subscribed to scanner state, so the storage compiles out. - assert ( - get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") - is None - ) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None @@ -111,10 +107,6 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( 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") - 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" From 152b6a5d5052c98f17c61ff67b4e50f2a7596bac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 01:31:22 -0500 Subject: [PATCH 14/14] Update stale comments left by the listener removal --- esphome/components/esp32_ble_tracker/__init__.py | 2 +- tests/component_tests/ble_device_base/test_slot_counter.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 0549a5f218..b1ad07dfdd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -66,7 +66,7 @@ def _get_required_features() -> set[BLEFeatures]: # Slot counters sizing the tracker's StaticVector storage; one request per -# registered listener, client, or scanner state listener. +# registered listener or client. _request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") _request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") 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 c174673155..e784c9871e 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -94,7 +94,7 @@ def test_esp32_tracker_handler_counts( assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None - # No consumer subscribed to scanner state, so the storage compiles out. + # No advertisement listener or client is registered, so both storages compile out. assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None @@ -105,7 +105,7 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( ) -> None: """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.""" + callbacks, so no listener slot exists.""" generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3"