Compare commits

...
Author SHA1 Message Date
J. Nick Koston e67cd5b978 Merge branch 'esp32-proxy-flip' into gatt-consumer-support 2026-08-09 01:01:25 -05:00
J. Nick Koston 06a52f682f Name the platform explicitly in schema builders for the dumper 2026-08-09 01:01:21 -05:00
J. Nick Koston 7e89d962f4 Merge branch 'esp32-proxy-flip' into gatt-consumer-support 2026-08-09 00:59:18 -05:00
J. Nick Koston 409ff027cc Collapse the identical ignore branches 2026-08-09 00:59:16 -05:00
J. Nick Koston 4d90dbda97 Merge branch 'esp32-proxy-flip' into gatt-consumer-support 2026-08-09 00:45:42 -05:00
J. Nick Koston 3c7e5000a5 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 00:44:41 -05:00
J. Nick Koston 7af211ab54 Deliver completions through a GattClientListener interface 2026-08-09 00:44:35 -05:00
J. Nick Koston 5420b0106a Use the CORE platform property 2026-08-09 00:35:54 -05:00
J. Nick Koston 964835c523 Merge branch 'esp32-proxy-flip' into gatt-consumer-support
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
2026-08-09 00:29:06 -05:00
J. Nick Koston cc81069d02 Keep the esp32 maintenance functions outside the gated surface 2026-08-09 00:28:54 -05:00
J. Nick Koston 20f63026a2 Dedicated-backend consumer support: proxy gate, consumer schema, slot ledger 2026-08-09 00:27:53 -05:00
J. Nick Koston ba567cecc3 Shared backend codegen registry and flip cleanups 2026-08-09 00:23:34 -05:00
J. Nick Koston ee7fb83334 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
2026-08-09 00:20:09 -05:00
J. Nick Koston 9db31db24d Multi-consumer sink, table materializer, and review fixes for the backend 2026-08-09 00:19:39 -05:00
J. Nick Koston ceca66c01a Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:53:50 -05:00
J. Nick Koston dc97c5a161 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 23:53:48 -05:00
J. Nick Koston 97bbcf55d9 Describe the ladder arm's real safety net in the test docstring 2026-08-08 23:53:45 -05:00
J. Nick Koston d135ac97e5 Report teardown once at CLOSE_EVT and refuse connects on a busy slot 2026-08-08 23:27:04 -05:00
J. Nick Koston 296dd19361 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:21:58 -05:00
J. Nick Koston 138304cfbe Fail discovery on count errors and free idle slots at once 2026-08-08 23:21:57 -05:00
J. Nick Koston 9fef1862be Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 23:09:18 -05:00
J. Nick Koston 80758f11aa Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 23:09:16 -05:00
J. Nick Koston 9d8362738f Use the canonical board id and the sibling test conventions 2026-08-08 23:09:13 -05:00
J. Nick Koston b2c22b5802 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 22:45:31 -05:00
J. Nick Koston f62009ecc9 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 22:45:29 -05:00
J. Nick Koston c09e3e0d45 Check the alias defines through real codegen 2026-08-08 22:42:56 -05:00
J. Nick Koston cdd7bef74a Check the alias defines through real codegen 2026-08-08 22:40:36 -05:00
J. Nick Koston 0e1321a813 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-08 22:38:31 -05:00
J. Nick Koston 022b612366 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 22:38:29 -05:00
J. Nick Koston 2dc9fbec5e Pin the tracker alias three-place invariant 2026-08-08 22:38:26 -05:00
J. Nick Koston 9dafff6a61 Apply the simplify findings and fix the passive esp32 build 2026-08-08 22:32:36 -05:00
J. Nick Koston 3c7a1062c8 Flip the esp32 proxy onto the hub wrapper 2026-08-08 22:21:38 -05:00
J. Nick Koston 8164cdd018 Stream in place through a backend cut-through 2026-08-08 22:08:16 -05:00
J. Nick Koston 4ea491d46f Bound the streaming peak to one batch window 2026-08-08 21:59:08 -05:00
J. Nick Koston 7fdb6cd2d5 Merge branch 'esp32-hub-devirtualize' into esp32-gatt-backend 2026-08-08 21:41:25 -05:00
J. Nick Koston 2d50e07e90 Fold the push-hub surface into the concept 2026-08-08 21:41:23 -05:00
J. Nick Koston 78a451a1c0 Apply the simplify findings 2026-08-08 21:39:48 -05:00
J. Nick Koston 2483621500 Bluedroid backend for the GATT client contract 2026-08-08 21:31:09 -05:00
J. Nick Koston 4f086b6b25 Drive the push and poll halves off one define 2026-08-08 21:23:54 -05:00
J. Nick Koston c4850a9320 Note the tracker-native calls in the esp32 arm 2026-08-08 21:03:19 -05:00
J. Nick Koston 91bcb14c09 Restore merged pairing dispatch and capability doc 2026-08-08 21:02:10 -05:00
J. Nick Koston 85e4a6cc33 Merge remote-tracking branch 'origin/dev' into esp32-hub-devirtualize 2026-08-08 21:00:53 -05:00
J. Nick Koston 149d7cd801 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 19:14:28 -05:00
J. Nick Koston ce2e113a6d Update stale comments left by the listener removal 2026-08-08 19:13:11 -05:00
J. Nick Koston ae5b6d271f Retire the scanner-state listener interface 2026-08-08 19:13:11 -05:00
J. Nick Koston e1572d7520 Drop the duplicated scanner-state define 2026-08-08 16:36:16 -05:00
J. Nick Koston 536afce394 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/ble_device_base/ble_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 16:02:18 -05:00
J. Nick Koston 152b6a5d50 Update stale comments left by the listener removal 2026-08-08 15:59:54 -05:00
J. Nick Koston f68ebcf7d1 Retire the scanner-state listener interface 2026-08-08 15:59:54 -05:00
J. Nick Koston ca5fa6dfaf Use the shared MAC validity helper 2026-08-08 15:59:53 -05:00
J. Nick Koston 962a82d157 Make the adapter MAC accessor const 2026-08-08 15:59:53 -05:00
J. Nick Koston c6d4ca7377 Use one stack query for the adapter MAC 2026-08-08 15:59:53 -05:00
J. Nick Koston 465a6a3596 Let esp32_ble own the adapter MAC lookup 2026-08-08 15:59:53 -05:00
J. Nick Koston 03c9b001f4 Ask the stack for the adapter MAC on hosted controllers 2026-08-08 15:59:53 -05:00
J. Nick Koston d680200e5a Read the adapter MAC through esp_read_mac 2026-08-08 15:59:53 -05:00
J. Nick Koston 98dae49440 Remove redundant access specifier 2026-08-08 15:59:53 -05:00
J. Nick Koston 7ec366c1ba Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 15:59:53 -05:00
J. Nick Koston b1e1b0d7c5 Retire the raw listener path and parser-type enum from the tracker 2026-08-08 15:59:53 -05:00
J. Nick Koston ec49eb12dd Finish the docstring reflow 2026-08-08 11:28:16 -05:00
J. Nick Koston 949474efc6 Name the alias header in the module docstring 2026-08-08 10:55:27 -05:00
J. Nick Koston 708f095b6f Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 10:55:27 -05:00
J. Nick Koston bf4af703b6 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 10:55:25 -05:00
J. Nick Koston 541616e19c Use the shared MAC validity helper 2026-08-08 10:55:22 -05:00
J. Nick Koston 952c3e6584 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 09:52:26 -05:00
J. Nick Koston 2ea55bee7b Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 09:52:24 -05:00
J. Nick Koston 1931ef32ee Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 09:52:12 -05:00
J. Nick Koston f2da6c1a56 Merge remote-tracking branch 'origin/dev' into esp32-proxy-hub-scanner-state
# Conflicts:
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	tests/component_tests/ble_device_base/test_slot_counter.py
2026-08-08 09:51:50 -05:00
J. Nick Koston 6e9e83bdfd Include defines.h where its define is tested 2026-08-08 04:05:45 -05:00
J. Nick Koston fe29a53b71 Require the push slot in the concept where it exists 2026-08-08 03:20:11 -05:00
J. Nick Koston fa2dafcbca Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 02:43:49 -05:00
J. Nick Koston dc18fced19 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 02:43:47 -05:00
J. Nick Koston 389d3629c2 Make the adapter MAC accessor const 2026-08-08 02:43:45 -05:00
J. Nick Koston 36a4d87611 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 02:43:21 -05:00
J. Nick Koston 65db614f22 Pin the scanner-state callback slot semantics 2026-08-08 02:43:09 -05:00
J. Nick Koston 591856e7dc Constrain the contract's return types 2026-08-08 02:30:10 -05:00
J. Nick Koston b680482092 Trim the surface doc to the semantics 2026-08-08 02:01:49 -05:00
J. Nick Koston 7fe447a4a2 Enforce the hub surface with a concept 2026-08-08 02:00:17 -05:00
J. Nick Koston 8fae4f6767 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:54:40 -05:00
J. Nick Koston 3d458bd9c8 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:54:38 -05:00
J. Nick Koston fd7510d6e5 Use one stack query for the adapter MAC 2026-08-08 01:54:36 -05:00
J. Nick Koston ffe55f7691 Trim the new comments to repo style 2026-08-08 01:52:40 -05:00
J. Nick Koston 40ac344bf2 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize
# Conflicts:
#	esphome/components/esp32_ble_tracker/esp32_ble_tracker.h
2026-08-08 01:51:20 -05:00
J. Nick Koston 667ffe98d9 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:50:51 -05:00
J. Nick Koston 270f776675 Let esp32_ble own the adapter MAC lookup 2026-08-08 01:50:49 -05:00
J. Nick Koston 965bbd4425 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:46:50 -05:00
J. Nick Koston e485e5af90 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:46:48 -05:00
J. Nick Koston c795d0734d Ask the stack for the adapter MAC on hosted controllers 2026-08-08 01:46:45 -05:00
J. Nick Koston 0e77d47df4 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:38:39 -05:00
J. Nick Koston 47c2f8f31a Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:38:36 -05:00
J. Nick Koston fe53d24e46 Read the adapter MAC through esp_read_mac 2026-08-08 01:38:33 -05:00
J. Nick Koston daf988fde8 Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:31:26 -05:00
J. Nick Koston 6604a9cca7 Update stale comments left by the listener removal 2026-08-08 01:31:22 -05:00
J. Nick Koston ddd45e393b Merge branch 'esp32-tracker-retire-scanner-listener' into esp32-hub-devirtualize 2026-08-08 01:26:09 -05:00
J. Nick Koston 0974e8a1f5 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:26:07 -05:00
J. Nick Koston 1478307280 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 01:26:06 -05:00
J. Nick Koston fc77cdcb7d Drop a leftover declaration and fix a stale test name 2026-08-08 01:26:03 -05:00
J. Nick Koston fcb9da489e Call the static capability query through the type 2026-08-08 01:22:53 -05:00
J. Nick Koston ab458fffab Bind BLEHub to the build's tracker at compile time 2026-08-08 01:18:21 -05:00
J. Nick Koston c0c3afe6b1 Merge branch 'esp32-tracker-parser-cleanup' into esp32-tracker-retire-scanner-listener 2026-08-08 01:02:41 -05:00
J. Nick Koston b092f64744 Remove redundant access specifier 2026-08-08 01:02:39 -05:00
J. Nick Koston 6b2938c1f7 Retire the scanner-state listener interface 2026-08-08 00:56:59 -05:00
J. Nick Koston 0e5d902c95 Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 00:53:31 -05:00
J. Nick Koston 11fc8e4c07 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 00:52:35 -05:00
J. Nick Koston d9f0f40373 Simplify scanner state: aliased enum, define-gated slot, one guarded sender 2026-08-08 00:52:12 -05:00
J. Nick Koston e671a602e9 Merge branch 'esp32-proxy-hub-scanner-state' into esp32-tracker-parser-cleanup 2026-08-08 00:41:34 -05:00
J. Nick Koston 7f44b9b826 Merge branch 'esp32-proxy-hub-advertisements' into esp32-proxy-hub-scanner-state
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
2026-08-08 00:41:33 -05:00
J. Nick Koston 47804f8d08 Address review: one tracker pointer, portable format, doc fixes 2026-08-08 00:40:43 -05:00
J. Nick Koston 67cc43c8ef Retire the raw listener path and parser-type enum from the tracker 2026-08-08 00:36:22 -05:00
J. Nick Koston 3e9808162b Deliver scanner state through a hub push callback 2026-08-08 00:31:18 -05:00
J. Nick Koston a6f81f3185 Carry the address as uint64 in RawAdvertisement and unify the proxy setup 2026-08-08 00:12:50 -05:00
J. Nick Koston 5296539849 Pin the removed listener slot 2026-08-08 00:01:43 -05:00
J. Nick Koston de9074fb5e Move the esp32 proxy advertisement path to the hub raw callback 2026-08-08 00:00:57 -05:00
39 changed files with 1852 additions and 1100 deletions
@@ -144,6 +144,9 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_BK72XX_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -45,7 +45,6 @@ namespace esphome::bk72xx_ble_tracker {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component, class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener, public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE> public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
@@ -93,15 +92,15 @@ class BK72xxBLETracker : public Component,
void stop_scan(); void stop_scan();
// ---- ble_device_base::BLEHub contract ---- // ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener); this->listeners_.push_back(listener);
#endif #endif
} }
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback; this->raw_advertisement_callback_ = callback;
} }
ble_device_base::HubCapabilities get_capabilities() const override { static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The Beken BDK exposes no active-scan path (passive scanning only), so the // The Beken BDK exposes no active-scan path (passive scanning only), so the
// controller never solicits scan responses and never merges them; consumers // controller never solicits scan responses and never merges them; consumers
// relying on scan-response fields (device names) get them only where the // relying on scan-response fields (device names) get them only where the
@@ -110,21 +109,21 @@ class BK72xxBLETracker : public Component,
// path there is no mode to switch to. // path there is no mode to switch to.
return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false}; return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false};
} }
bool request_scan_mode(bool active) override { bool request_scan_mode(bool active) {
// Passive-only controller: a passive request is already honored, an active // Passive-only controller: a passive request is already honored, an active
// one cannot be. // one cannot be.
return !active; return !active;
} }
// The controller stores the address LSB-first (BLE convention); the contract // The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order. // wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override { void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6]; uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac); this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
out[i] = mac[5 - i]; out[i] = mac[5 - i];
} }
bool scan_running() override { return this->scan_running_; } bool scan_running() { return this->scan_running_; }
bool scan_active() override { return false; } // BK72xx scan is passive-only bool scan_active() { return false; } // BK72xx scan is passive-only
// ---- bk72xx_ble::BLEScanListener ---- // ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the // Delivered by the controller's loop() on the ESPHome main task — the
+16 -12
View File
@@ -3,19 +3,22 @@ ble_device_base — the platform-neutral BLE layer.
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
(BLEHub, in ble_hub.h) on every platform. (BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h)
on every platform.
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
subclass, so there is no platform table here and no dependency in either subclass, so there is no Python platform table here and no dependency in
direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an either direction (C++-side, the compile-time alias header ble_hub_impl.h and the
explicit ble_hub_id: is a declared key even on strict schemas) and calls defines.h mirror are the deliberate exceptions). A sensor extends
register_ble_device() in to_code; a tracker component subclasses BLEHub (C++ BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a
and codegen class) and MUST call register_hub_provider() at import time — declared key even on strict schemas) and calls register_ble_device() in
without it _require_hub rejects configs that bind through the generated id to_code; a tracker component declares BLEHub as its codegen-class parent and
(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip MUST call register_hub_provider() at import time — without it _require_hub
requires only a new in-tree tracker component; out-of-tree BLE hubs are rejects configs that bind through the generated id (an explicit ble_hub_id:
not supported. bypasses the registry). Adding a new BLE chip requires a new in-tree tracker
component plus its alias arm and define (see above); out-of-tree BLE hubs
are not supported.
AES-CCM decryption for encrypted advertisements is provided portably in AES-CCM decryption for encrypted advertisements is provided portably in
ble_aes_ccm.h. ble_aes_ccm.h.
@@ -48,8 +51,9 @@ LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
# The neutral tracker contract. Every tracker's codegen class declares this as a # The neutral tracker contract. Every tracker's codegen class declares this as
# parent, which is what lets cv.use_id(BLEHub) resolve any of them. # a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python
# only: C++-side the name is a per-platform alias (ble_hub_impl.h).
BLEHub = ble_device_base_ns.class_("BLEHub") BLEHub = ble_device_base_ns.class_("BLEHub")
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). # The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
@@ -1,11 +1,12 @@
// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses // Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses
// registered on a BLEHub, exposed by each tracker under its own automation // registered on a BLEHub, exposed by each tracker under its own automation
// names. parse_device()'s return feeds the "Found device" suppression. // names. parse_device()'s return feeds the "Found device" suppression.
// Constructors are templated on the hub type so this header also builds with
// no tracker present (host unit tests).
#pragma once #pragma once
#include "ble_device.h" #include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/automation.h" #include "esphome/core/automation.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
@@ -18,7 +19,7 @@ namespace esphome::ble_device_base {
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. // on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener { class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
public: public:
explicit ESPBTAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; } void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
@@ -39,7 +40,7 @@ class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public
// data for the given UUID. Optional single-MAC filter. // data for the given UUID. Optional single-MAC filter.
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener { class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
public: public:
explicit BLEServiceDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } template<typename Hub> explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); } void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); } void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
@@ -73,7 +74,7 @@ class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>,
// manufacturer data for the given ID. Optional single-MAC filter. // manufacturer data for the given ID. Optional single-MAC filter.
class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener { class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
public: public:
explicit BLEManufacturerDataAdvertiseTrigger(BLEHub *parent) { parent->register_listener(this); } template<typename Hub> explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); } void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); } void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
@@ -108,7 +109,7 @@ class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_
// claims devices (parse_device always returns false). // claims devices (parse_device always returns false).
class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener {
public: public:
explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); } template<typename Hub> explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); }
bool parse_device(const ESPBTDevice &device) override { return false; } bool parse_device(const ESPBTDevice &device) override { return false; }
void on_scan_end() override { this->trigger(); } void on_scan_end() override { this->trigger(); }
@@ -18,6 +18,10 @@ namespace esphome::ble_device_base {
static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NOT_CONNECTED = -1;
static constexpr int GATT_ERR_NO_MEMORY = -2; static constexpr int GATT_ERR_NO_MEMORY = -2;
/// Safety net shared by every GATT backend: force IDLE when the stack never
/// delivers its disconnect completion.
static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000;
// Preferred connection parameters shared by every platform's GATT client so // Preferred connection parameters shared by every platform's GATT client so
// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency // the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency
// 0). FAST covers connection setup and service discovery; MEDIUM is the // 0). FAST covers connection setup and service discovery; MEDIUM is the
@@ -5,8 +5,12 @@
// Exactly one GATT backend exists per build, so BLEGattConnection is a // Exactly one GATT backend exists per build, so BLEGattConnection is a
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
// interface. // interface.
// The hub BluetoothConnection wrapper drives it and receives completions // A consumer - a streaming consumer that forwards the raw database (the hub
// through its event-sink methods, which the backend calls directly. All sink // BluetoothConnection wrapper) or a direct consumer owning a dedicated
// backend and resolving handles by UUID - drives it and receives
// completions through the GattClientListener interface (one build can hold
// several consumer types while the backend stays a single non-virtual
// class). All listener
// calls are delivered on the ESPHome main loop; borrowed data pointers are // calls are delivered on the ESPHome main loop; borrowed data pointers are
// valid only for the duration of the call. // valid only for the duration of the call.
// //
@@ -78,9 +82,31 @@ struct GattServiceTable {
uint16_t descriptor_count{0}; uint16_t descriptor_count{0};
}; };
/// The event surface a backend delivers completions through. Genuine runtime
/// polymorphism lives here - one build can hold several consumer types (the
/// proxy's connection wrapper, dedicated-backend components) against the one
/// non-virtual backend class - so this is a plain virtual interface: every
/// method defaults to a no-op, consumers override what they consume (override
/// makes a misspelled name a compile error), and adding an event touches no
/// existing consumer. No destructor: components are never destroyed, and
/// nothing deletes through this base.
/// on_connection_state carries the negotiated MTU and an HCI
/// status/disconnect reason. Codegen wires the listener before setup(), so
/// backends may call without a null check.
class GattClientListener {
public:
virtual void on_connection_state(bool connected, uint16_t mtu, int error) {}
virtual void on_service_discovery_done(int error) {}
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
virtual void on_write_result(uint16_t handle, int error) {}
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {}
virtual void on_pairing_result(int status) {}
};
// The BLEGattConnection op surface, asserted where the alias binds // The BLEGattConnection op surface, asserted where the alias binds
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives // (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
// through the sink) or a synchronous error (busy, not connected, stack // through the listener) or a synchronous error (busy, not connected, stack
// rejection); one operation may be outstanding at a time. Semantics beyond // rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures: // the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). // - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
@@ -88,15 +114,19 @@ struct GattServiceTable {
// - notify_characteristic: local registration only; the CCCD write is the // - notify_characteristic: local registration only; the CCCD write is the
// API client's responsibility (a plain write_descriptor). // API client's responsibility (a plain write_descriptor).
// - get_service_table/release_services: backend-owned transient storage, // - get_service_table/release_services: backend-owned transient storage,
// released after streaming (release is idempotent). // released after streaming (release is idempotent). A backend may
// additionally provide its own service streamer (stream_service_batch on
// the concrete type, detected by the consumer at compile time) for
// arbitrary-size databases; the table then materializes only for consumers
// that ask for it.
// - completions: connect and disconnect land in on_connection_state, // - completions: connect and disconnect land in on_connection_state,
// discover_services in on_service_discovery_done, pair in // discover_services in on_service_discovery_done, pair in
// on_pairing_result, reads in on_read_result, notify_characteristic in // on_pairing_result, reads in on_read_result, notify_characteristic in
// on_notify_state, characteristic writes with response and descriptor // on_notify_state, characteristic writes with response and descriptor
// writes in on_write_result. // writes in on_write_result.
template<typename T, typename Sink> template<typename T>
concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(sink); conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>; { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>; { conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>; { conn.discover_services() } -> std::same_as<int>;
@@ -109,23 +139,55 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *
{ conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as<int>; { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as<int>;
{ conn.get_service_table() } -> std::same_as<GattServiceTable>; { conn.get_service_table() } -> std::same_as<GattServiceTable>;
{ conn.release_services() } -> std::same_as<void>; { conn.release_services() } -> std::same_as<void>;
// Connection-type hint for backends that tune parameters by it; others
// carry an inline no-op.
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
}; };
// The event sink the backend calls directly (the hub BluetoothConnection // ---- service table lookup helpers ----
// wrapper), asserted where the wrapper is defined: on_connection_state //
// carries the negotiated MTU and an HCI status/disconnect reason. The // Neutral, bounds-checked walks over a materialized GattServiceTable for
// requirements check call validity, not exact parameter types; keep sink // direct consumers that resolve a known device's handles by UUID (streaming
// parameters at the documented widths (uint16_t handles and lengths). // consumers forward the raw database and never need these). Linear search:
template<typename S> // the table exists only between discovery and release_services(), for one
concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { // small known device.
{ sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_service_discovery_done(int{}) } -> std::same_as<void>; /// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
{ sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as<void>; static constexpr uint16_t CCCD_UUID = 0x2902;
{ sink.on_write_result(uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as<void>; inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
{ sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as<void>; for (uint16_t i = 0; i < table.service_count; i++) {
{ sink.on_pairing_result(int{}) } -> std::same_as<void>; if (table.services[i].uuid == uuid)
}; return &table.services[i];
}
return nullptr;
}
inline const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
uint16_t end = service.first_characteristic + service.characteristic_count;
if (end > table.characteristic_count)
return nullptr;
for (uint16_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
inline uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
uint16_t end = characteristic.first_descriptor + characteristic.descriptor_count;
if (end > table.descriptor_count)
return 0;
const ESPBTUUID cccd_uuid = ESPBTUUID::from_uint16(CCCD_UUID);
for (uint16_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == cccd_uuid)
return table.descriptors[i].handle;
}
return 0;
}
} // namespace esphome::ble_device_base } // namespace esphome::ble_device_base
+36 -52
View File
@@ -1,13 +1,10 @@
// ble_hub.h // ble_hub.h
// //
// BLEHub — the platform-neutral BLE tracker contract. // The platform-neutral BLE tracker contract: shared types plus the method
// // surface every tracker provides (documented below). Exactly one tracker
// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, // exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not
// ln882h_ble_tracker, future chips) implements this interface; every BLE // an abstract interface — no vtable, every hub call inlinable. Consumers
// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via // include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub).
// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares.
// Adding a new BLE chip therefore requires only a new tracker component that
// implements BLEHub: no consumer, registry, or base changes.
// //
// Chip differences are expressed as data (HubCapabilities), never as // Chip differences are expressed as data (HubCapabilities), never as
// platform conditionals in consumers. // platform conditionals in consumers.
@@ -15,7 +12,9 @@
#pragma once #pragma once
#include "ble_device.h" #include "ble_device.h"
#include "esphome/core/defines.h"
#include <concepts>
#include <cstdint> #include <cstdint>
namespace esphome::ble_device_base { namespace esphome::ble_device_base {
@@ -61,9 +60,8 @@ enum class ScannerState : uint8_t {
}; };
/// Subscriber slot for scanner-state transitions; same shape as /// Subscriber slot for scanner-state transitions; same shape as
/// RawAdvertisementCallback, delivered on the ESPHome main loop. Hubs that /// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs
/// cannot push drop the registration and the consumer falls back to polling /// that push provide the setter; consumers of the rest poll scan_running().
/// scan_running().
struct ScannerStateCallback { struct ScannerStateCallback {
void *instance{nullptr}; void *instance{nullptr};
void (*fn)(void *instance, ScannerState state){nullptr}; void (*fn)(void *instance, ScannerState state){nullptr};
@@ -91,48 +89,34 @@ struct HubCapabilities {
bool scan_mode_switch; bool scan_mode_switch;
}; };
class BLEHub { // The BLEHub method surface, asserted where ble_hub_impl.h binds the alias.
public: // Semantics beyond the signatures:
virtual ~BLEHub() = default; // - register_listener: parsed-advertisement consumers (sensors, triggers).
// - set_raw_advertisement_callback: raw stream, one consumer at a time.
/// Register a parsed-advertisement consumer (BLE sensors, automation triggers). // - get_adapter_mac: printable order, out[0] = MSB.
virtual void register_listener(ESPBTDeviceListener *listener) = 0; // - scan_active: the current/configured mode sends scan requests.
// - request_scan_mode: false = cannot honor, state untouched (the caller
/// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. // reports the real state back); true = applied immediately, restarting a
virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; // running scan. Honoring is advertised by HubCapabilities::scan_mode_switch.
// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback)
// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept
// requires both exactly when that define is set. A push hub must emit a
// transition for every accepted or refused mode request - consumers skip
// their own mode report on push builds.
template<typename T>
concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback,
uint8_t *mac) {
hub.register_listener(listener);
hub.set_raw_advertisement_callback(raw_callback);
{ T::get_capabilities() } -> std::same_as<HubCapabilities>;
hub.get_adapter_mac(mac);
{ hub.scan_running() } -> std::same_as<bool>;
{ hub.scan_active() } -> std::same_as<bool>;
{ hub.request_scan_mode(true) } -> std::same_as<bool>;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK #ifdef USE_BLE_SCANNER_STATE_CALLBACK
/// Push subscriber for scanner-state transitions; hubs that can push hub.set_scanner_state_callback(ScannerStateCallback{});
/// invoke scanner_state_callback_ where their state changes. Compiled only { hub.get_scanner_state() } -> std::same_as<ScannerState>;
/// when a subscriber exists (bluetooth_proxy emits the define), so #endif
/// subscriber-less builds carry no storage.
void set_scanner_state_callback(ScannerStateCallback callback) { this->scanner_state_callback_ = callback; }
protected:
ScannerStateCallback scanner_state_callback_{};
public:
#endif // USE_BLE_SCANNER_STATE_CALLBACK
virtual HubCapabilities get_capabilities() const = 0;
/// Adapter MAC in printable (MSB-first) order, out[0] = MSB.
virtual void get_adapter_mac(uint8_t out[6]) = 0;
virtual bool scan_running() = 0;
/// True when the current/configured scan mode is active (scan requests sent).
virtual bool scan_active() = 0;
/// Request a scan-mode change (active = send scan requests). Returns false
/// when the hub cannot honor the request; the caller reports the real state
/// back to its subscriber. A hub that returns true applies the mode
/// immediately: a running scan is restarted with the new mode, an idle one
/// picks it up on its next start. The default cannot-change keeps hubs
/// without a mode switch (and out-of-tree trackers) building unchanged.
/// Independent of HubCapabilities::active_scan: that bit describes what the
/// CONTROLLER can do; whether this method honors requests is advertised by
/// HubCapabilities::scan_mode_switch, so consumers can gate features on the
/// switch without probing.
virtual bool request_scan_mode(bool active) { return false; }
}; };
} // namespace esphome::ble_device_base } // namespace esphome::ble_device_base
@@ -0,0 +1,35 @@
// ble_hub_impl.h
//
// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's
// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header,
// trackers include ble_hub.h (the contract).
#pragma once
#include "ble_hub.h"
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE_TRACKER)
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker
#elif defined(USE_RP2_BLE_TRACKER)
#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker
#elif defined(USE_BK72XX_BLE_TRACKER)
#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker
#elif defined(USE_LN882H_BLE_TRACKER)
#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h"
#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker
#endif
// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub.
namespace esphome::ble_device_base {
#ifdef ESPHOME_BLE_HUB_TYPE
using BLEHub = ESPHOME_BLE_HUB_TYPE;
static_assert(BLEHubContract<BLEHub>, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)");
#undef ESPHOME_BLE_HUB_TYPE
#endif
} // namespace esphome::ble_device_base
@@ -1,23 +1,41 @@
"""Per-platform GATT connection backends the Bluetooth proxy drives. """Per-platform GATT connection backends and the helpers to embed one.
Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
user-facing configuration; the proxy's codegen declares and registers the consumer's codegen declares and registers the backend instances — the
connection instances. Bluetooth proxy through its per-slot connection wrappers (a streaming
consumer), and direct consumers owning a dedicated backend through
gatt_client_config_schema() + new_gatt_backend().
""" """
import functools from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import esphome.codegen as cg import esphome.codegen as cg
from esphome.config_helpers import filter_source_files_from_platform from esphome.config_helpers import filter_source_files_from_platform
from esphome.const import PLATFORM_RP2, PlatformFramework import esphome.config_validation as cv
from esphome.const import (
CONF_MAC_ADDRESS,
PLATFORM_ESP32,
PLATFORM_RP2,
PlatformFramework,
)
from esphome.core import CORE from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
DOMAIN = "bluetooth_connection"
def AUTO_LOAD() -> list[str]: def AUTO_LOAD() -> list[str]:
"""The esp32 connection header includes esp32_ble_client, so the closure """ble_device_base plus the platform BLE stack the build's backend
must be self-satisfying; no target platform (tooling) gets the union.""" registers with, so consumers stay platform-blind. The platform-less arm
if CORE.is_esp32 or CORE.target_platform is None: serves tooling that resolves the manifest without a target."""
return ["ble_device_base", "esp32_ble_client"] if CORE.is_esp32:
return ["ble_device_base", "esp32_ble_tracker"]
if CORE.is_rp2:
return ["ble_device_base", "rp2040_ble"]
if CORE.target_platform is None:
return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"]
return ["ble_device_base"] return ["ble_device_base"]
@@ -33,25 +51,208 @@ RP2_MAX_CONNECTIONS = 1
# registry of which hub platforms run the connection-capable proxy. # registry of which hub platforms run the connection-capable proxy.
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
# The hub-platform wrapper and the rp2 BTstack backend codegen classes. # The hub-platform wrapper and the backend codegen classes.
HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection")
RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component)
BluedroidGattClient = bluetooth_connection_ns.class_(
"BluedroidGattClient", cg.Component
)
CONF_BACKEND_ID = "backend_id"
CONF_ADDRESS_TYPE = "address_type"
# BLE_ADDR_TYPE_* code space shared with the API and the backends.
ADDRESS_TYPES = {"public": 0, "random": 1}
@functools.cache def _esp32_schema_fragment() -> cv.Schema:
def esp32_connection_class() -> cg.MockObjClass: from esphome.components import esp32_ble_tracker
"""Lazy: importing esp32_ble_client registers esp32-only automations as
an import side effect, which must not leak into other platforms."""
from esphome.components import esp32_ble_client
return bluetooth_connection_ns.class_( return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA
"BluetoothConnection", esp32_ble_client.BLEClientBase
def _rp2_schema_fragment() -> cv.Schema:
from esphome.components import rp2040_ble
return cv.Schema(
{cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)}
) )
async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import esp32_ble_tracker
# The tracker's promote loop owns connect timing; the backend's
# tracker-facing shim registers as a raw client.
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import rp2040_ble
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
@dataclass(frozen=True)
class _PlatformBackend:
"""One platform's backend: codegen class, extra schema keys (lazy so the
platform stack is only imported when targeted), and stack registration."""
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
# The single registry of platforms with a GATT client backend; a platform
# missing here fails loudly everywhere instead of falling into another
# platform's arm.
_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = {
PLATFORM_ESP32: _PlatformBackend(
BluedroidGattClient, _esp32_schema_fragment, _esp32_register
),
PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register),
}
# Gates dedicated-backend consumers (cv.only_on).
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
def _backend_entry(platform: str | None = None) -> _PlatformBackend:
key = platform if platform is not None else CORE.target_platform
if (entry := _PLATFORM_BACKENDS.get(key)) is None:
raise cv.Invalid(f"no GATT client backend is registered for {key}")
return entry
def gatt_client_schema(platform: str | None = None) -> cv.Schema:
"""Schema fragment for one GATT backend instance: its generated id plus
the platform-stack reference new_gatt_backend() resolves.
Defaults to the platform being validated; pass `platform` explicitly when
building a schema outside validation (the language-schema dumper calls
per-platform builders under arbitrary CORE platforms).
"""
entry = _backend_entry(platform)
return entry.schema_fragment().extend(
{cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)}
)
def hub_connection_schema(platform: str | None = None) -> cv.Schema:
"""Per-slot schema for the proxy's connection wrappers: the wrapper id on
top of the backend fragment. Same platform rules as gatt_client_schema()."""
return gatt_client_schema(platform).extend(
{cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}
)
@dataclass
class _SlotLedger:
"""GATT connection slots claimed this run, for the platform cap check."""
consumers: list[str] = field(default_factory=list)
def _ledger() -> _SlotLedger:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _SlotLedger()
return CORE.data[DOMAIN]
def consume_gatt_slot(consumer: str, count: int = 1):
"""Validator claiming GATT connection slots — the one spelling for every
claimant (the proxy per configured slot, dedicated backends once). The
neutral ledger feeds the platform cap check in FINAL_VALIDATE_SCHEMA;
esp32 additionally charges the controller's connection budget."""
def validator(config: ConfigType) -> ConfigType:
_ledger().consumers.extend([consumer] * count)
if CORE.is_esp32:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
return config
return validator
def _validate_slot_totals(config: ConfigType) -> ConfigType:
# esp32 has its own controller budget (esp32_ble); the hub platforms cap
# at the prebuilt stack's client count, and nothing else counts claims
# across components (e.g. a proxy plus a radon_eye_rd200 on rp2).
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
return config
claimed = _ledger().consumers
if len(claimed) > cap:
raise cv.Invalid(
f"{CORE.target_platform} supports at most {cap} GATT client "
f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}"
)
return config
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
# The peer keys every dedicated-backend consumer shares: one target device.
_PEER_SCHEMA = cv.Schema(
{
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ADDRESS_TYPE, default="public"): cv.enum(
ADDRESS_TYPES, lower=True
),
}
)
def gatt_client_config_schema(base_schema: cv.Schema, consumer: str):
"""Wrap a dedicated-backend consumer's schema so the consumer stays
platform-blind: gates on the platforms with a backend, folds in
gatt_client_schema() plus the peer keys (mac_address, address_type),
and claims the connection slot. `consumer` names the component in
slot-exhaustion errors."""
@schema_extractor("schema")
def apply(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform; expose the
# consumer's keys plus the platform-free peer keys.
return base_schema.extend(_PEER_SCHEMA)
cv.only_on(GATT_CLIENT_PLATFORMS)(config)
schema = base_schema.extend(_PEER_SCHEMA).extend(gatt_client_schema())
config = schema(config)
return consume_gatt_slot(consumer)(config)
return apply
async def new_gatt_backend(
config: ConfigType, *, service_table: bool = True
) -> cg.MockObj:
"""Instantiate the backend declared by gatt_client_schema() and register
it with its platform stack. The connection slot is claimed at validation
(gatt_client_config_schema / the proxy's slot validators), not here.
service_table compiles the on-demand service-table materializer into the
backend; direct consumers need it, the streaming proxy does not, so
proxy-only builds keep the smaller footprint.
"""
from esphome.components import ble_device_base
ble_device_base.request_gatt_client()
if service_table:
cg.add_define("USE_BLE_GATT_SERVICE_TABLE")
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
# The backend has no user-facing component options; an empty config keeps
# the consumer's own keys (update_interval, ...) off it.
await cg.register_component(backend, {})
await _backend_entry().register(backend, config)
return backend
FILTER_SOURCE_FILES = filter_source_files_from_platform( FILTER_SOURCE_FILES = filter_source_files_from_platform(
{ {
"bluetooth_connection_esp32.cpp": { "bluetooth_connection_bluedroid.cpp": {
PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_IDF,
}, },
@@ -61,6 +262,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"bluetooth_connection_hub.cpp": { "bluetooth_connection_hub.cpp": {
PlatformFramework.RP2_ARDUINO, PlatformFramework.RP2_ARDUINO,
PlatformFramework.LN882X_ARDUINO, PlatformFramework.LN882X_ARDUINO,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
}, },
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
} }
@@ -1,6 +1,11 @@
#include "bluetooth_connection.h" #include "bluetooth_connection.h"
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef USE_ESP32
#include <esp_bt_device.h>
#include <esp_gattc_api.h>
#endif
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_pb2.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -39,4 +44,26 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
} // namespace esphome::bluetooth_connection } // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_HAS_GATT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
namespace esphome::bluetooth_connection {
// Address-scoped Bluedroid maintenance shared by every esp32 proxy build,
// including advertisement-only ones where no GATT backend (and none of the
// gated surface above) is compiled - so this block sits outside that gate.
conn_err_t unpair_device(uint64_t address) {
esp_bd_addr_t bda;
ble_device_base::uint64_to_mac_msb_first(address, bda);
return esp_ble_remove_bond_device(bda);
}
conn_err_t clear_gatt_cache(uint64_t address) {
esp_bd_addr_t bda;
ble_device_base::uint64_to_mac_msb_first(address, bda);
return esp_ble_gattc_cache_clean(bda);
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32
@@ -16,11 +16,16 @@
#include <esp_err.h> #include <esp_err.h>
#endif #endif
// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub // The proxy-serving surface is compiled: a proxy is present and a GATT
// platform with the neutral GATT client compiled in. Single-sourced here so // backend is wired by codegen (one slot per connection). This is the single
// the proxy and this component cannot drift. // spelling of that predicate - the hub wrapper, the connection-aware API
#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) // request handlers, and the Bluedroid in-place streamer all gate on it.
#define BLUETOOTH_CONNECTION_HAS_GATT // Advertisement-only builds get the clean-error handlers; address-scoped
// maintenance (unpair, cache clear) still works there through the
// per-platform free functions below. Backend-only builds (a dedicated-backend
// consumer without bluetooth_proxy) compile none of this API surface.
#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY)
#define BLUETOOTH_CONNECTION_SERVES_PROXY
#endif #endif
namespace esphome::api { namespace esphome::api {
@@ -136,7 +141,7 @@ inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uu
} }
} }
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// Result of close_service_batch: keep filling the batch or send it now. /// Result of close_service_batch: keep filling the batch or send it now.
/// An oversized service is packed alone; a failed (backpressured) send is /// An oversized service is packed alone; a failed (backpressured) send is
/// retried from the batch start, so no service is silently skipped. /// retried from the batch start, so no service is silently skipped.
@@ -148,6 +153,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND };
/// cannot drift. /// cannot drift.
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service, BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str); uint8_t connection_index, const char *address_str);
#endif // BLUETOOTH_CONNECTION_HAS_GATT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
} // namespace esphome::bluetooth_connection } // namespace esphome::bluetooth_connection
@@ -0,0 +1,828 @@
#include "bluetooth_connection_bluedroid.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#endif
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <esp_gatt_common_api.h>
#include <cstring>
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection.bluedroid";
using ble_device_base::FAST_CONN_TIMEOUT;
using ble_device_base::FAST_MAX_CONN_INTERVAL;
using ble_device_base::FAST_MIN_CONN_INTERVAL;
using ble_device_base::MEDIUM_CONN_TIMEOUT;
using ble_device_base::MEDIUM_MAX_CONN_INTERVAL;
using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
using esp32_ble_tracker::ClientState;
using esp32_ble_tracker::ConnectionType;
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
// ---- shim forwarders ----
bool BluedroidTrackerShim::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
return this->engine_->handle_gattc_event_(event, gattc_if, param);
}
void BluedroidTrackerShim::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
this->engine_->handle_gap_event_(event, param);
}
void BluedroidTrackerShim::connect() { this->engine_->tracker_connect_(); }
void BluedroidTrackerShim::disconnect() { this->engine_->disconnect(); }
// ---- component ----
void BluedroidGattClient::setup() {
static uint8_t connection_index = 0;
this->connection_index_ = connection_index++;
}
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: re-register the app on the next enable.
this->set_state_(ClientState::INIT);
return;
}
auto st = this->state_();
if (st == ClientState::INIT) {
auto ret = esp_ble_gattc_app_register(this->shim_.app_id);
if (ret) {
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->shim_.app_id, ret);
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
this->set_state_(ClientState::IDLE);
} else if (st == ClientState::IDLE) {
// The loop only drives the bootstrap and the disconnect safety timeout.
this->disable_loop();
} else if (st == ClientState::DISCONNECTING &&
millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGE(TAG, "[%d] Timeout waiting for CLOSE_EVT, forcing IDLE", this->connection_index_);
// Release before idling: unconditional disconnect does not release, and a
// lost CLOSE/DISCONNECT would otherwise leak the table and the cache.
this->release_services();
this->set_idle_();
this->report_connection_state_(false, ESP_GATT_CONN_TIMEOUT);
}
}
void BluedroidGattClient::dump_config() {
ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_);
if (this->is_failed()) {
ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots");
}
}
// ---- contract ops ----
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
// Refuse anything but a fully idle slot. Clobbering DISCONNECTING with
// DISCOVERED would let the tracker open a new link while the old one is
// still closing - the stale CLOSE_EVT then tears the new attempt down.
if (this->state_() != ClientState::IDLE) {
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
return ESP_GATT_BUSY;
}
ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_);
this->remote_addr_type_ = addr_type;
// Hand the request to the tracker's promote loop: it stops the scan, raises
// coex, and calls tracker_connect_() - the tracker owns connect timing here.
this->set_state_(ClientState::DISCOVERED);
return 0;
}
void BluedroidGattClient::tracker_connect_() {
auto st = this->state_();
if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) {
ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_);
return;
}
if (st == ClientState::DISCONNECTING) {
ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_);
return;
}
ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_);
this->services_released_ = false;
this->seen_mtu_ = false;
this->enable_loop();
this->set_state_(ClientState::CONNECTING);
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
// Fast params for the discovery phase; stepped down at SEARCH_CMPL.
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0,
FAST_CONN_TIMEOUT);
} else {
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0,
MEDIUM_CONN_TIMEOUT);
}
auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_,
static_cast<esp_ble_addr_type_t>(this->remote_addr_type_), true);
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_open", ret);
// CONNECT_EVT never fired, so conn_id_ is legitimately unset: plain IDLE.
this->set_state_(ClientState::IDLE);
this->report_connection_state_(false, ret);
}
}
int BluedroidGattClient::disconnect() {
auto st = this->state_();
if (st == ClientState::DISCONNECTING) {
return 0;
}
// Nothing was opened, so no completion event will follow: report
// not-connected and the hub frees the slot at once (rp2 convention).
if (st == ClientState::IDLE) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
if (st == ClientState::DISCOVERED) {
// Parked for the tracker promote loop, never opened.
this->set_state_(ClientState::IDLE);
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_);
this->shim_.schedule_disconnect();
return 0;
}
this->unconditional_disconnect_();
return 0;
}
void BluedroidGattClient::unconditional_disconnect_() {
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
if (this->conn_id_ == UNSET_CONN_ID) {
ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_);
return;
}
auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_);
if (err != ESP_OK) {
// The stack is now in an indeterminate state for this link.
ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err);
}
this->set_disconnecting_();
}
int BluedroidGattClient::discover_services() {
if (this->conn_id_ == UNSET_CONN_ID) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
return this->check_and_log_error_("esp_ble_gattc_search_service",
esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr));
}
int BluedroidGattClient::read_characteristic(uint16_t handle) {
return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_,
handle, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
// The BTC layer copies the payload immediately, so the const_cast is safe.
return this->check_and_log_error_(
"esp_ble_gattc_write_char",
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::read_descriptor(uint16_t handle) {
return this->check_and_log_error_(
"esp_ble_gattc_read_char_descr",
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return this->check_and_log_error_(
"esp_ble_gattc_write_char_descr",
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
}
int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) {
// Local registration only; the CCCD write is the API client's responsibility.
if (enable) {
return this->check_and_log_error_("esp_ble_gattc_register_for_notify",
esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify",
esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle));
}
int BluedroidGattClient::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) {
return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom");
}
void BluedroidGattClient::release_services() {
this->service_total_ = 0;
#ifdef USE_BLE_GATT_SERVICE_TABLE
this->free_service_table_();
#endif
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
esp_ble_gattc_cache_clean(this->remote_bda_);
#endif
}
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
if (this->table_storage_ == nullptr &&
(this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) {
return {};
}
return this->table_view_();
}
// The view is carved from the storage block and the counts on each call
// (a cold path) rather than cached, saving a per-instance table member.
ble_device_base::GattServiceTable BluedroidGattClient::table_view_() const {
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = this->table_char_total_ * sizeof(ble_device_base::GattCharacteristic);
return {reinterpret_cast<const ble_device_base::GattService *>(this->table_storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes),
this->service_total_,
this->table_char_total_,
this->table_desc_total_};
}
void BluedroidGattClient::free_service_table_() {
if (this->table_storage_ == nullptr) {
return;
}
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->table_storage_, 0);
this->table_storage_ = nullptr;
this->table_char_total_ = 0;
this->table_desc_total_ = 0;
}
template<typename ServiceFn, typename CharFn, typename DescFn>
bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
// Shared enumeration for both table-build passes: an identical walk order
// is what lets the counting pass size the block the filling pass fills.
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
for (uint16_t s = 0; s < this->service_total_; s++) {
esp_gattc_service_elem_t svc;
uint16_t svc_count = 1;
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s) != ESP_GATT_OK ||
svc_count == 0) {
return false;
}
if (!on_service(s, svc)) {
return false;
}
uint16_t svc_chars = 0;
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle,
svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) {
return false;
}
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
break;
}
if (status != ESP_GATT_OK || char_count == 0) {
return false;
}
if (!on_char(svc, chr)) {
return false;
}
for (uint16_t d = 0;; d++) {
esp_gattc_descr_elem_t desc;
uint16_t desc_count = 1;
auto desc_status =
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || desc_count == 0) {
return false;
}
if (!on_desc(chr, desc)) {
return false;
}
}
}
}
return true;
}
bool BluedroidGattClient::build_service_table_() {
// Pass 1: count, so one exact-size block holds the whole table.
uint16_t char_total = 0;
uint16_t desc_total = 0;
bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
char_total++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
desc_total++;
return true;
});
if (!counted) {
return false;
}
// The arrays share one block; carving stays aligned because each struct's
// strictest member is the UUID and array sizes are multiples of it.
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->table_storage_ = allocator.allocate(total_bytes);
if (this->table_storage_ == nullptr) {
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->connection_index_,
static_cast<unsigned>(total_bytes));
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->table_storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes);
auto *descriptors =
reinterpret_cast<ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes);
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
// means the cached database changed between the passes; fail the build
// rather than serve an inconsistent table (the consumer retries).
uint16_t char_index = 0;
uint16_t desc_index = 0;
ble_device_base::GattService *cur_service = nullptr;
ble_device_base::GattCharacteristic *cur_char = nullptr;
bool filled = this->walk_database_(
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
cur_service = &services[s];
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
cur_service->start_handle = svc.start_handle;
cur_service->end_handle = svc.end_handle;
cur_service->first_characteristic = char_index;
cur_service->characteristic_count = 0;
return true;
},
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
if (char_index >= char_total) {
return false;
}
cur_char = &characteristics[char_index++];
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
cur_char->value_handle = chr.char_handle;
// Bluedroid addresses descriptors by characteristic handle, so the
// table's end_handle only needs the service-bounded upper bound.
cur_char->end_handle = svc.end_handle;
cur_char->properties = chr.properties;
cur_char->first_descriptor = desc_index;
cur_char->descriptor_count = 0;
cur_service->characteristic_count++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
if (desc_index >= desc_total) {
return false;
}
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
descriptors[desc_index].handle = desc.handle;
desc_index++;
cur_char->descriptor_count++;
return true;
});
if (!filled || char_index != char_total || desc_index != desc_total) {
this->free_service_table_();
return false;
}
this->table_char_total_ = char_total;
this->table_desc_total_ = desc_total;
return true;
}
#endif // USE_BLE_GATT_SERVICE_TABLE
// ---- internals ----
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0;
}
void BluedroidGattClient::set_idle_() {
this->set_state_(ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
}
void BluedroidGattClient::set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state_(ClientState::DISCONNECTING);
// The loop may be disabled while idle; the safety timeout needs it.
this->enable_loop();
}
void BluedroidGattClient::report_connection_state_(bool connected, int error) {
this->listener_->on_connection_state(connected, this->mtu_, error);
}
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout, const char *param_type) {
esp_ble_conn_update_params_t conn_params = {{0}};
memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t));
conn_params.min_int = min_interval;
conn_params.max_int = max_interval;
conn_params.latency = latency;
conn_params.timeout = timeout;
ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type);
return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params));
}
int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK) {
this->log_gattc_warning_(operation, err);
}
return err;
}
void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) {
ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code);
}
// ---- service streaming ----
void BluedroidGattClient::handle_search_cmpl_() {
// Step down from the fast discovery params.
this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium");
uint16_t primary = 0;
uint16_t secondary = 0;
auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE,
0x0001, 0xFFFF, 0, &primary);
auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE,
0x0001, 0xFFFF, 0, &secondary);
if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) {
// A failed count must not become an authoritative empty database - V3
// clients cache the streamed result permanently.
auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status);
this->listener_->on_service_discovery_done(status);
return;
}
this->service_total_ = primary + secondary;
this->listener_->on_service_discovery_done(0);
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
conn.proxy_->send_gatt_services_done(conn.address_);
this->release_services();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (a resubscribing client gets silence and its 30 s
// timeout, never an authoritative partial list).
auto *api_conn = conn.proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_);
conn.send_service_ = DONE_SENDING_SERVICES;
this->release_services();
return;
}
bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids();
api::BluetoothGATTGetServicesResponse resp;
resp.address = conn.address_;
size_t current_size = resp.calculate_size();
int16_t batch_start = conn.send_service_;
while (conn.send_service_ < this->service_total_) {
esp_gattc_service_elem_t service_result;
uint16_t svc_count = 1;
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, &svc_count,
conn.send_service_) != ESP_GATT_OK ||
svc_count == 0) {
ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_,
conn.address_str_, conn.send_service_);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
uint16_t total_char_count = 0;
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
service_result.start_handle, service_result.end_handle, 0,
&total_char_count) != ESP_GATT_OK) {
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
// If this service likely won't fit, send the current batch first.
size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids);
if (!resp.services.empty() && current_size + estimated_size > MAX_PACKET_SIZE) {
break;
}
resp.services.emplace_back();
auto &service_resp = resp.services.back();
fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid,
ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids);
service_resp.handle = service_result.start_handle;
if (total_char_count > 0) {
service_resp.characteristics.init(total_char_count);
uint16_t char_offset = 0;
esp_gattc_char_elem_t char_result;
// Bounded by the count query: a misbehaving peripheral can make the
// enumeration return more entries than it reported.
while (char_offset < total_char_count) {
uint16_t cc = 1;
auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
service_result.end_handle, &char_result, &cc, char_offset);
if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) {
break;
}
if (char_status != ESP_GATT_OK || cc == 0) {
if (char_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
break;
}
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid,
ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids);
characteristic_resp.handle = char_result.char_handle;
characteristic_resp.properties = char_result.properties;
uint16_t total_desc_count = 0;
auto desc_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR,
0, 0, char_result.char_handle, &total_desc_count);
if (desc_count_status != ESP_GATT_OK) {
// Abort rather than stream the characteristic descriptor-less: a
// missing CCCD in a cached database breaks notifications for good.
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
if (total_desc_count > 0) {
characteristic_resp.descriptors.init(total_desc_count);
uint16_t desc_offset = 0;
esp_gattc_descr_elem_t desc_result;
while (desc_offset < total_desc_count) {
uint16_t dc = 1;
auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle,
&desc_result, &dc, desc_offset);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || dc == 0) {
if (desc_status != ESP_GATT_OK) {
this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status);
conn.send_service_ = DONE_SENDING_SERVICES;
return;
}
break;
}
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid,
ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids);
descriptor_resp.handle = desc_result.handle;
desc_offset++;
}
}
char_offset++;
}
}
if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) !=
BatchClose::CONTINUE) {
break;
}
}
// On a failed send, rewind the cursor so the batch is retried instead of
// silently skipped.
if (!api_conn->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_);
conn.send_service_ = batch_start;
}
}
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
// ---- events ----
void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
auto st = this->state_();
if (st == ClientState::IDLE) {
// IDF can deliver OPEN_EVT after esp_ble_gattc_open already returned an
// error and the slot went IDLE; do not resurrect it.
ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, param->open.status);
return;
}
if (st != ClientState::CONNECTING) {
ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_);
}
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->log_gattc_warning_("Connection open", param->open.status);
// Never established, CLOSE_EVT may not follow.
this->set_idle_();
this->report_connection_state_(false, param->open.status);
return;
}
if (this->shim_.disconnect_pending()) {
// Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches.
this->unconditional_disconnect_();
return;
}
this->set_state_(ClientState::CONNECTED);
ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_);
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
this->set_state_(ClientState::ESTABLISHED);
// No discovery phase: report immediately; the MTU report below is
// suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here,
// matching the previous esp32 behavior).
this->seen_mtu_ = true;
this->report_connection_state_(true, 0);
// Settled: only the disconnect safety net needs the loop, and
// set_disconnecting_() re-enables it.
this->disable_loop();
}
}
void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) {
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_() == ClientState::CONNECTED) {
ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_);
} else {
ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason);
}
if (this->state_() == ClientState::IDLE) {
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
return;
}
// Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting
// earlier makes the controller reject with 133 or assert) and before
// reporting - the wrapper frees the slot on the report, and a freed slot
// invites a reconnect into the still-closing link.
this->release_services();
this->set_disconnecting_();
}
bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (event == ESP_GATTC_REG_EVT && this->shim_.app_id != param->reg.app_id)
return false;
if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_)
return false;
switch (event) {
case ESP_GATTC_REG_EVT: {
if (param->reg.status == ESP_GATT_OK) {
this->gattc_if_ = esp_gattc_if;
} else {
ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status);
this->mark_failed();
}
break;
}
case ESP_GATTC_CONNECT_EVT: {
if (!this->check_addr_(param->connect.remote_bda))
return false;
this->conn_id_ = param->connect.conn_id;
// MTU request here rather than OPEN_EVT, matching the IDF examples.
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret);
}
break;
}
case ESP_GATTC_OPEN_EVT: {
if (!this->check_addr_(param->open.remote_bda))
return false;
this->handle_open_evt_(param);
break;
}
case ESP_GATTC_CFG_MTU_EVT: {
if (this->conn_id_ != param->cfg_mtu.conn_id)
return false;
if (param->cfg_mtu.status != ESP_GATT_OK) {
// Warn only; a disconnect will follow if the link is dead.
this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status);
} else {
this->mtu_ = param->cfg_mtu.mtu;
}
if (!this->seen_mtu_) {
this->seen_mtu_ = true;
// The connected report waited for the MTU so HA never sees 23.
this->report_connection_state_(true, 0);
}
break;
}
case ESP_GATTC_DISCONNECT_EVT: {
if (!this->check_addr_(param->disconnect.remote_bda))
return false;
this->handle_disconnect_evt_(param);
break;
}
case ESP_GATTC_CLOSE_EVT: {
if (this->conn_id_ != param->close.conn_id)
return false;
this->release_services();
this->set_idle_();
// The one connected=false report: the wrapper frees the slot on it,
// so it must not fire before the controller finished closing.
this->report_connection_state_(false, param->close.reason);
break;
}
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (this->conn_id_ != param->search_cmpl.conn_id)
return false;
ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_);
this->set_state_(ClientState::ESTABLISHED);
this->handle_search_cmpl_();
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
this->disable_loop();
break;
}
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT: {
if (this->conn_id_ != param->read.conn_id)
return false;
bool ok = param->read.status == ESP_GATT_OK;
this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr,
ok ? param->read.value_len : 0, ok ? 0 : param->read.status);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
this->listener_->on_write_result(param->write.handle,
param->write.status == ESP_GATT_OK ? 0 : param->write.status);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
this->listener_->on_notify_state(param->reg_for_notify.handle, true,
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
this->listener_->on_notify_state(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (this->conn_id_ != param->notify.conn_id)
return false;
ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle);
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
break;
}
default:
break;
}
return true;
}
void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
switch (event) {
case ESP_GAP_BLE_SEC_REQ_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
// Always accept a server-initiated security request.
esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true);
break;
}
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
this->listener_->on_pairing_result(
param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason);
break;
}
default:
break;
}
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
@@ -0,0 +1,166 @@
// Bluedroid (esp32) GATT client backend: the esp32 arm of the
// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection
// wrapper. Not a BLEClientBase: the tracker's promote loop owns
// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only
// parks the address in DISCOVERED and the real esp_ble_gattc_open happens in
// the tracker-invoked shim connect(). The shim exists because the tracker's
// ESPBTClient::disconnect() returns void while the contract's returns int -
// one class cannot carry both.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
namespace esphome::bluetooth_connection {
class BluedroidGattClient;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
class BluetoothConnection;
#endif
// The tracker-facing half: owns the ClientState the promote loop reads and
// forwards events/commands to the engine.
class BluedroidTrackerShim final : public esp32_ble_tracker::ESPBTClient {
public:
explicit BluedroidTrackerShim(BluedroidGattClient *engine) : engine_(engine) {}
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;
void connect() override;
void disconnect() override;
bool wants_parsed_advertisements() override { return false; }
void on_scan_end() override {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
void schedule_disconnect() { this->want_disconnect_ = true; }
protected:
BluedroidGattClient *engine_;
};
class BluedroidGattClient final : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Wired by codegen before setup and invariant for the device lifetime.
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; }
// ---- 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);
// Materialized on demand from Bluedroid's cached database for direct
// consumers that resolve handles by UUID. The streaming consumer (the
// proxy wrapper) never calls this - it uses stream_service_batch - so the
// materializer only compiles when codegen declares a direct consumer
// (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old
// footprint; a direct consumer's peak is bounded by its one known device.
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
ble_device_base::GattServiceTable get_service_table() { return {}; }
#endif
void release_services();
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// In-place service streamer (the proxy wrapper detects and prefers it):
/// builds one api response batch directly from Bluedroid's cached database,
/// so the streaming peak is the response itself - the old esp32 model.
void stream_service_batch(BluetoothConnection &conn);
#endif
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
protected:
friend class BluedroidTrackerShim;
esp32_ble_tracker::ClientState state_() const { return this->shim_.state(); }
void set_state_(esp32_ble_tracker::ClientState st) { this->shim_.set_state(st); }
bool check_addr_(const esp_bd_addr_t &addr) const;
void tracker_connect_();
bool handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param);
void handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
void handle_open_evt_(esp_ble_gattc_cb_param_t *param);
void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param);
void handle_search_cmpl_();
void unconditional_disconnect_();
void set_idle_();
void set_disconnecting_();
void report_connection_state_(bool connected, int error);
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
int check_and_log_error_(const char *operation, esp_err_t err);
void log_gattc_warning_(const char *operation, int code);
#ifdef USE_BLE_GATT_SERVICE_TABLE
template<typename ServiceFn, typename CharFn, typename DescFn>
bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
bool build_service_table_();
void free_service_table_();
ble_device_base::GattServiceTable table_view_() const;
#endif
// Group 1: pointers / composed objects
BluedroidTrackerShim shim_{this};
ble_device_base::GattClientListener *listener_{nullptr};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// One exact-size block carved into the table's three arrays; owned here,
// freed by release_services(). Null when no table is materialized. The
// GattServiceTable view is rebuilt from this pointer and the counts on
// each (cold) get_service_table() call instead of being cached.
uint8_t *table_storage_{nullptr};
#endif
// Group 2: 4-byte types
int gattc_if_{ESP_GATT_IF_NONE};
uint32_t disconnecting_started_{0};
// Group 3: arrays
esp_bd_addr_t remote_bda_{};
// Group 4: 2-byte types
uint16_t conn_id_{0xFFFF};
uint16_t mtu_{23};
uint16_t service_total_{0};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// Filled element counts of the materialized table (0 when none).
uint16_t table_char_total_{0};
uint16_t table_desc_total_{0};
#endif
// Group 5: 1-byte types
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
uint8_t remote_addr_type_{0};
esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE};
uint8_t connection_index_;
// Set only when release_services() cleans the stack's GATT cache, which no
// walk may then touch (Bluedroid asserts rather than erroring).
bool services_released_{false};
// The connected report waits for the MTU exchange; OPEN_EVT alone would
// hand HA the default 23.
bool seen_mtu_{false};
};
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT
@@ -1,484 +0,0 @@
#include "bluetooth_connection_esp32.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
namespace esphome::bluetooth_connection {
namespace espbt = esphome::esp32_ble_tracker;
using ble_device_base::ESPBTUUID;
static const char *const TAG = "bluetooth_connection";
conn_err_t unpair_device(uint64_t address) {
esp_bd_addr_t bd_addr;
ble_device_base::uint64_to_mac_msb_first(address, bd_addr);
return esp_ble_remove_bond_device(bd_addr);
}
conn_err_t clear_gatt_cache(uint64_t address) {
esp_bd_addr_t bd_addr;
ble_device_base::uint64_to_mac_msb_first(address, bd_addr);
return esp_ble_gattc_cache_clean(bd_addr);
}
void BluetoothConnection::dump_config() {
ESP_LOGCONFIG(TAG, "BLE Connection:");
BLEClientBase::dump_config();
}
void BluetoothConnection::set_address(uint64_t address) {
// Keep the proxy's pre-allocated connections-free message in step
this->proxy_->update_address_slot_(this->address_, address);
// Call parent implementation to actually set the address
BLEClientBase::set_address(address);
}
void BluetoothConnection::loop() {
BLEClientBase::loop();
// Early return if no active connection
if (this->address_ == 0) {
return;
}
// Handle service discovery if in valid range
if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) {
this->send_service_for_discovery_();
}
// Check if we should disable the loop
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); }
void BluetoothConnection::send_service_for_discovery_() {
if (this->send_service_ >= this->service_count_) {
this->send_service_ = DONE_SENDING_SERVICES;
this->proxy_->send_gatt_services_done(this->address_);
this->release_services();
return;
}
// Early return if no API connection
auto *api_conn = this->proxy_->get_api_connection();
if (api_conn == nullptr) {
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
// Check if client supports efficient UUIDs
bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids();
// Prepare response
api::BluetoothGATTGetServicesResponse resp;
resp.address = this->address_;
// Dynamic batching based on actual size
// Keep running total of actual message size
size_t current_size = resp.calculate_size();
int16_t batch_start = this->send_service_;
while (this->send_service_ < this->service_count_) {
esp_gattc_service_elem_t service_result;
uint16_t service_count = 1;
esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr,
&service_result, &service_count, this->send_service_);
if (service_status != ESP_GATT_OK || service_count == 0) {
ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d",
this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing",
service_status, service_count, this->send_service_);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
// Get the number of characteristics BEFORE adding to response
uint16_t total_char_count = 0;
esp_gatt_status_t char_count_status =
esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC,
service_result.start_handle, service_result.end_handle, 0, &total_char_count);
if (char_count_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
// If this service likely won't fit, send current batch (unless it's the first)
size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids);
if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) {
// This service likely won't fit, send current batch
break;
}
// Now add the service since we know it will likely fit
resp.services.emplace_back();
auto &service_resp = resp.services.back();
fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid),
use_efficient_uuids);
service_resp.handle = service_result.start_handle;
if (total_char_count > 0) {
// Initialize FixedVector with exact count and process characteristics
service_resp.characteristics.init(total_char_count);
uint16_t char_offset = 0;
esp_gattc_char_elem_t char_result;
// Bound by total_char_count: the vector is sized for it, and a malicious peripheral
// can make enumeration return more entries than the count query reported
while (char_offset < total_char_count) { // characteristics
uint16_t char_count = 1;
esp_gatt_status_t char_status =
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
service_result.end_handle, &char_result, &char_count, char_offset);
if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) {
break;
}
if (char_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_all_char", char_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (char_count == 0) {
break;
}
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid),
use_efficient_uuids);
characteristic_resp.handle = char_result.char_handle;
characteristic_resp.properties = char_result.properties;
char_offset++;
// Get the number of descriptors directly with one call
uint16_t total_desc_count = 0;
esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count(
this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count);
if (desc_count_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (total_desc_count == 0) {
continue;
}
// Initialize FixedVector with exact count and process descriptors
characteristic_resp.descriptors.init(total_desc_count);
uint16_t desc_offset = 0;
esp_gattc_descr_elem_t desc_result;
while (desc_offset < total_desc_count) { // descriptors
uint16_t desc_count = 1;
esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr(
this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK) {
this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status);
this->send_service_ = DONE_SENDING_SERVICES;
return;
}
if (desc_count == 0) {
break; // No more descriptors
}
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, ESPBTUUID::from_uuid(desc_result.uuid),
use_efficient_uuids);
descriptor_resp.handle = desc_result.handle;
desc_offset++;
}
}
} // end if (total_char_count > 0)
if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) !=
BatchClose::CONTINUE) {
break;
}
}
// Send the message with dynamically batched services; on a failed send,
// rewind the cursor so the batch is retried instead of silently skipped.
if (!api_conn->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_);
this->send_service_ = batch_start;
}
}
void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) {
ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status);
}
void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) {
ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err);
}
void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) {
ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action,
type);
}
void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) {
ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(),
operation, handle, status);
}
esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK) {
this->log_connection_warning_(operation, err);
return err;
}
return ESP_OK;
}
bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
if (!BLEClientBase::gattc_event_handler(event, gattc_if, param))
return false;
switch (event) {
case ESP_GATTC_DISCONNECT_EVT: {
// Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources
// This prevents race condition where we mark slot as free before controller cleanup is complete
ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_,
param->disconnect.reason);
// Send disconnection notification but don't free the slot yet
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
} else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) {
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->proxy_->send_connections_free();
}
this->seen_mtu_or_services_ = false;
break;
}
case ESP_GATTC_CFG_MTU_EVT:
case ESP_GATTC_SEARCH_CMPL_EVT: {
if (!this->seen_mtu_or_services_) {
// We don't know if we will get the MTU or the services first, so
// only send the device connection true if we have already received
// the services.
this->seen_mtu_or_services_ = true;
break;
}
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->proxy_->send_connections_free();
break;
}
case ESP_GATTC_READ_DESCR_EVT:
case ESP_GATTC_READ_CHAR_EVT: {
if (param->read.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status);
this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTReadResponse resp;
resp.address = this->address_;
resp.handle = param->read.handle;
resp.set_data(param->read.value, param->read.value_len);
api_connection->send_message(resp);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT: {
if (param->write.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status);
this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTWriteResponse resp;
resp.address = this->address_;
resp.handle = param->write.handle;
api_connection->send_message(resp);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
if (param->unreg_for_notify.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle,
param->unreg_for_notify.status);
this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = param->unreg_for_notify.handle;
api_connection->send_message(resp);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
if (param->reg_for_notify.status != ESP_GATT_OK) {
this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle,
param->reg_for_notify.status);
this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = param->reg_for_notify.handle;
api_connection->send_message(resp);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_,
param->notify.handle);
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyDataResponse resp;
resp.address = this->address_;
resp.handle = param->notify.handle;
resp.set_data(param->notify.value, param->notify.value_len);
api_connection->send_message(resp);
break;
}
default:
break;
}
return true;
}
void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
BLEClientBase::gap_event_handler(event, param);
switch (event) {
case ESP_GAP_BLE_AUTH_CMPL_EVT:
if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0)
break;
if (param->ble_security.auth_cmpl.success) {
this->proxy_->send_device_pairing(this->address_, true);
} else {
this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason);
}
break;
default:
break;
}
}
esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
if (!this->connected()) {
this->log_gatt_not_connected_("read", "characteristic");
return GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_read_char", err);
}
esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
bool response) {
if (!this->connected()) {
this->log_gatt_not_connected_("write", "characteristic");
return GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
// ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data
// The BTC layer immediately copies the data to its own buffer (see btc_gattc.c)
// const_cast is safe here and was previously hidden by a C-style cast
esp_err_t err =
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_write_char", err);
}
esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
if (!this->connected()) {
this->log_gatt_not_connected_("read", "descriptor");
return GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err);
}
esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) {
if (!this->connected()) {
this->log_gatt_not_connected_("write", "descriptor");
return GATT_NOT_CONNECTED;
}
ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
// ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data
// The BTC layer immediately copies the data to its own buffer (see btc_gattc.c)
// const_cast is safe here and was previously hidden by a C-style cast
esp_err_t err = esp_ble_gattc_write_char_descr(
this->gattc_if_, this->conn_id_, handle, length, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err);
}
esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
if (!this->connected()) {
this->log_gatt_not_connected_("notify", "characteristic");
return GATT_NOT_CONNECTED;
}
if (enable) {
ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_,
this->address_str_, handle);
esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle);
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
}
ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_,
this->address_str_, handle);
esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle);
return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err);
}
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32
@@ -1,76 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "bluetooth_connection.h"
namespace esphome::bluetooth_proxy {
class BluetoothProxy;
} // namespace esphome::bluetooth_proxy
namespace esphome::bluetooth_connection {
class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
public:
void dump_config() override;
void loop() override;
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;
// 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);
esp_err_t read_descriptor(uint16_t handle);
esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response);
esp_err_t notify_characteristic(uint16_t handle, bool enable);
esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom");
}
bool has_gatt_services() const { return this->service_count_ != 0; }
/// Start connecting: record the API address type and hand the client to the
/// tracker's promote loop (it pauses the scan and opens the connection).
void initiate_connection(uint8_t address_type) {
this->set_remote_addr_type(static_cast<esp_ble_addr_type_t>(address_type));
this->set_state(esp32_ble_tracker::ClientState::DISCOVERED);
}
void set_address(uint64_t address) override;
protected:
friend class bluetooth_proxy::BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
void log_connection_error_(const char *operation, esp_gatt_status_t status);
void log_connection_warning_(const char *operation, esp_err_t err);
void log_gatt_not_connected_(const char *action, const char *type);
void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status);
esp_err_t check_and_log_error_(const char *operation, esp_err_t err);
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
bluetooth_proxy::BluetoothProxy *proxy_;
// Group 2: 2-byte types
int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states
// Group 3: 1-byte types
bool seen_mtu_or_services_{false};
// 1 byte used, 1 byte padding
};
} // namespace esphome::bluetooth_connection
#endif // USE_ESP32
@@ -15,17 +15,18 @@
#if defined(USE_RP2040_BLE) #if defined(USE_RP2040_BLE)
#include "bluetooth_connection_rp2.h" #include "bluetooth_connection_rp2.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient #define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
#elif defined(USE_ESP32_BLE)
#include "bluetooth_connection_bluedroid.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) #elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
// Emitted only by the host unit-test manifest: the tests compile the hub // 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 // wrapper standalone, so bind a do-nothing backend. Every other backend-less
// build hits the #error below. // build hits the #error below.
namespace esphome::bluetooth_connection { namespace esphome::bluetooth_connection {
class BluetoothConnection;
class StubGattBackend { class StubGattBackend {
public: public:
void set_listener(BluetoothConnection *listener) {} void set_listener(ble_device_base::GattClientListener *listener) {}
int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } 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 disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
@@ -43,6 +44,7 @@ class StubGattBackend {
return ble_device_base::GATT_ERR_NOT_CONNECTED; return ble_device_base::GATT_ERR_NOT_CONNECTED;
} }
ble_device_base::GattServiceTable get_service_table() { return {}; } ble_device_base::GattServiceTable get_service_table() { return {}; }
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services() {} void release_services() {}
}; };
@@ -55,7 +57,7 @@ class StubGattBackend {
namespace esphome::ble_device_base { namespace esphome::ble_device_base {
using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE;
static_assert(BLEGattConnectionContract<BLEGattConnection, bluetooth_connection::BluetoothConnection>, static_assert(BLEGattConnectionContract<BLEGattConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE #undef ESPHOME_BLE_GATT_CONNECTION_TYPE
@@ -1,7 +1,7 @@
// Hub-platform connection wrapper (USE_RP2 hub builds today). // The proxy's per-slot connection wrapper, shared by every platform.
#include "bluetooth_connection_hub.h" #include "bluetooth_connection_hub.h"
#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
@@ -27,7 +27,7 @@ void BluetoothConnection::set_address(uint64_t address) {
} }
void BluetoothConnection::start_connect_() { void BluetoothConnection::start_connect_() {
// No connect timeout here (esp32 parity): the client's own timeout or // No connect timeout here: the API client's own timeout or
// the api-gone sweep drives disconnect(). // the api-gone sweep drives disconnect().
this->state_ = ClientState::CONNECTING; this->state_ = ClientState::CONNECTING;
int err = this->backend_->connect(this->address_, this->remote_addr_type_); int err = this->backend_->connect(this->address_, this->remote_addr_type_);
@@ -38,7 +38,7 @@ void BluetoothConnection::start_connect_() {
} }
void BluetoothConnection::disconnect() { void BluetoothConnection::disconnect() {
// Idempotent like the esp32 class: the proxy's teardown loop calls this // Idempotent: the proxy's teardown loop calls this
// every 100 ms while the API subscriber is gone, and a repeat call must not // every 100 ms while the API subscriber is gone, and a repeat call must not
// reach the backend (whose busy error would free the slot mid-teardown). // reach the backend (whose busy error would free the slot mid-teardown).
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
@@ -65,10 +65,10 @@ void BluetoothConnection::disconnect() {
} }
void BluetoothConnection::check_disconnect_timeout_() { void BluetoothConnection::check_disconnect_timeout_() {
// Safety net mirroring the esp32 base class: if the backend's disconnect // Safety net: if the backend's disconnect completion is lost (or a refusal
// completion is lost, force the slot free instead of leaking it. // left the teardown unresolved), force the slot free instead of leaking it.
static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; // The caller already gates on DISCONNECTING.
if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_);
this->reset_connection_(GATT_NOT_CONNECTED); this->reset_connection_(GATT_NOT_CONNECTED);
} }
@@ -96,7 +96,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) {
this->proxy_->reset_connection_slot_(this, reason); this->proxy_->reset_connection_slot_(this, reason);
} }
// ---- backend event sink ---- // ---- backend event listener ----
void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected && this->address_ == 0) { if (connected && this->address_ == 0) {
@@ -130,7 +130,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
// The API client has the services cached; never discover them. No // The API client has the services cached; never discover them. No
// discovery phase needs the fast interval, so settle straight into the // discovery phase needs the fast interval, so settle straight into the
// shared steady-state parameters (same lifecycle place as esp32). // shared steady-state parameters. On esp32 the backend already set the
// same values as prefer-params before opening, so this request is
// usually redundant there - kept because rp2 has no prefer-params and
// the explicit update is its only path to the steady-state interval.
this->state_ = ClientState::ESTABLISHED; this->state_ = ClientState::ESTABLISHED;
int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL,
ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0,
@@ -433,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() {
} // namespace esphome::bluetooth_connection } // namespace esphome::bluetooth_connection
#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -1,17 +1,17 @@
// Hub-platform BluetoothConnection: drives the build's GATT backend (the // BluetoothConnection: drives the build's GATT backend (the
// ble_device_base::BLEGattConnection alias) and translates its events into // ble_device_base::BLEGattConnection alias) and translates its events into
// the same API messages the esp32 class emits. // the proxy's API messages. One wrapper for every platform; per-backend
// Presents the identical method surface, so the proxy's GATT dispatch // differences live behind the alias and the streamer cut-through.
// compiles against either class unchanged.
#pragma once #pragma once
#include "esphome/core/defines.h"
#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT)
#include "bluetooth_connection.h" #include "bluetooth_connection.h"
// The wrapper exists to serve the proxy's API surface; direct consumers
// drive the backend themselves, so backend-only builds compile this header
// empty.
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/ble_device_base/ble_client_state.h" #include "esphome/components/ble_device_base/ble_client_state.h"
#include "bluetooth_connection_gatt_backend.h" #include "bluetooth_connection_gatt_backend.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
@@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState; using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType; using ConnectionType = ble_device_base::ConnectionType;
class BluetoothConnection final { class BluetoothConnection final : public ble_device_base::GattClientListener {
public: public:
/// Wire the platform backend. Called from codegen before setup. /// Wire the platform backend. Called from codegen before setup.
void set_backend(ble_device_base::BLEGattConnection *backend) { void set_backend(ble_device_base::BLEGattConnection *backend) {
@@ -33,7 +33,7 @@ class BluetoothConnection final {
backend->set_listener(this); backend->set_listener(this);
} }
// ---- proxy dispatch surface (mirrors the esp32 class) ---- // ---- proxy dispatch surface ----
conn_err_t read_characteristic(uint16_t handle); conn_err_t read_characteristic(uint16_t handle);
conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response);
conn_err_t read_descriptor(uint16_t handle); conn_err_t read_descriptor(uint16_t handle);
@@ -52,10 +52,6 @@ class BluetoothConnection final {
bool is_paired() const { return this->paired_; } bool is_paired() const { return this->paired_; }
void set_unpaired() { this->paired_ = false; } void set_unpaired() { this->paired_ = false; }
conn_err_t pair() { return this->backend_->pair(); } 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; }
void cancel_pending_disconnect() {}
void set_address(uint64_t address); void set_address(uint64_t address);
uint64_t get_address() const { return this->address_; } uint64_t get_address() const { return this->address_; }
@@ -65,37 +61,58 @@ class BluetoothConnection final {
ClientState state() const { return this->state_; } ClientState state() const { return this->state_; }
void set_state(ClientState st) { this->state_ = st; } void set_state(ClientState st) { this->state_ = st; }
bool connected() const { return this->state_ == ClientState::ESTABLISHED; } bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } void set_connection_type(ConnectionType ct) {
this->connection_type_ = ct;
// The bluedroid backend branches on the type itself (prefer-params and
// the with-cache report at OPEN_EVT); the others ignore it.
this->backend_->set_connection_type(ct);
}
// Latched at discovery completion rather than read from the backend table: // Latched at discovery completion rather than read from the backend table:
// streaming frees the table, and this must stay true for the connection's // streaming frees the table, and this must stay true for the connection's
// lifetime (esp32 parity — a repeat GetServices is silently ignored there, // lifetime (a repeat GetServices is silently ignored, never answered with
// never answered with an authoritative empty database). // an authoritative empty database).
bool has_gatt_services() const { return this->services_discovered_; } bool has_gatt_services() const { return this->services_discovered_; }
/// Stream any pending service-discovery batch and police the disconnect /// Stream any pending service-discovery batch and police the disconnect
/// safety timeout. Called from the proxy's loop — hub connections have no /// safety timeout. Called from the proxy's loop — the wrapper has no
/// Component loop of their own (the esp32 class streams from its own /// Component loop of its own.
/// loop() and has the same 10 s safety net in its base class).
void process_pending_services() { void process_pending_services() {
if (this->send_service_ >= 0) { if (this->send_service_ >= 0) {
this->send_service_for_discovery_(); this->stream_pending_(this->backend_);
}
// Inline state gate: this runs per loop iteration for every slot, and the
// 10 s safety net only matters while DISCONNECTING.
if (this->state_ == ClientState::DISCONNECTING) {
this->check_disconnect_timeout_();
} }
this->check_disconnect_timeout_();
} }
// ---- backend event sink (called directly by the backend, main loop) ---- // ---- backend event listener (called directly by the backend, main loop) ----
void on_connection_state(bool connected, uint16_t mtu, int error); void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error); void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int 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); void on_write_result(uint16_t handle, int error) override;
void on_notify_state(uint16_t handle, bool enabled, int error); 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); void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_pairing_result(int status); void on_pairing_result(int status) override;
protected: protected:
friend class bluetooth_proxy::BluetoothProxy; friend class bluetooth_proxy::BluetoothProxy;
// The Bluedroid backend streams services in place from its stack cache.
friend class BluedroidGattClient;
void start_connect_(); void start_connect_();
// A backend providing its own streamer (see the contract doc) builds the
// response in place from its stack cache; the rest use the table streamer.
// Template so the discarded branch is not odr-checked against backends
// that lack the method.
template<typename Backend> void stream_pending_(Backend *backend) {
if constexpr (requires { backend->stream_service_batch(*this); }) {
backend->stream_service_batch(*this);
} else {
this->send_service_for_discovery_();
}
}
void send_service_for_discovery_(); void send_service_for_discovery_();
void check_disconnect_timeout_(); void check_disconnect_timeout_();
void reset_connection_(conn_err_t reason); void reset_connection_(conn_err_t reason);
@@ -128,9 +145,6 @@ class BluetoothConnection final {
bool services_discovered_{false}; bool services_discovered_{false};
}; };
static_assert(ble_device_base::GattClientEventSinkContract<BluetoothConnection>,
"The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)");
} // namespace esphome::bluetooth_connection } // namespace esphome::bluetooth_connection
#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -1,6 +1,5 @@
#include "bluetooth_connection_rp2.h" #include "bluetooth_connection_rp2.h"
#include "bluetooth_connection_hub.h"
#include "bluetooth_connection.h" #include "bluetooth_connection.h"
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
@@ -26,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY;
// and keeps the scan inhibited, so the engine cancels after 20 s. The // and keeps the scan inhibited, so the engine cancels after 20 s. The
// disconnect timeout mirrors the esp32 CLOSE_EVT safety net. // disconnect timeout mirrors the esp32 CLOSE_EVT safety net.
static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000;
static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000;
// Can-send windows normally open within a connection interval (tens of ms). // Can-send windows normally open within a connection interval (tens of ms).
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
@@ -384,7 +382,7 @@ void RP2GattClient::loop() {
RP2GattNotifyEvent *notify; RP2GattNotifyEvent *notify;
while ((notify = this->notify_queue_.pop()) != nullptr) { while ((notify = this->notify_queue_.pop()) != nullptr) {
if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { if (this->notify_subscribed_(notify->handle)) {
this->listener_->on_notify_data(notify->handle, notify->data, notify->len); this->listener_->on_notify_data(notify->handle, notify->data, notify->len);
} }
this->notify_pool_.release(notify); this->notify_pool_.release(notify);
@@ -430,7 +428,7 @@ void RP2GattClient::loop() {
} }
} }
} else if (this->state_ == EngineState::DISCONNECTING) { } else if (this->state_ == EngineState::DISCONNECTING) {
if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
} }
@@ -448,9 +446,7 @@ void RP2GattClient::loop() {
} }
if (timed_out) { if (timed_out) {
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
if (this->listener_ != nullptr) { this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
}
} }
} else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() &&
this->event_queue_.empty() && this->notify_queue_.empty())) { this->event_queue_.empty() && this->notify_queue_.empty())) {
@@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
this->state_ = EngineState::READY; this->state_ = EngineState::READY;
// Scanning resumes and runs alongside the established connection. // Scanning resumes and runs alongside the established connection.
this->release_scan_inhibit_(); this->release_scan_inhibit_();
if (this->listener_ != nullptr) { this->listener_->on_connection_state(true, this->mtu_, 0);
this->listener_->on_connection_state(true, this->mtu_, 0);
}
} }
break; break;
case RP2GattEvent::QUERY_COMPLETE: case RP2GattEvent::QUERY_COMPLETE:
@@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
this->finish_write_no_rsp_(event.status); this->finish_write_no_rsp_(event.status);
break; break;
case RP2GattEvent::PAIRING_RESULT: case RP2GattEvent::PAIRING_RESULT:
if (this->listener_ != nullptr) { this->listener_->on_pairing_result(event.status);
this->listener_->on_pairing_result(event.status);
}
break; break;
} }
} }
@@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) {
return; return;
} }
this->op_type_ = OpType::NONE; this->op_type_ = OpType::NONE;
if (this->listener_ != nullptr) { this->listener_->on_write_result(this->op_handle_, status);
this->listener_->on_write_result(this->op_handle_, status);
}
} }
void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
@@ -576,9 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) {
this->cleanup_link_state_(); this->cleanup_link_state_();
this->release_scan_inhibit_(); this->release_scan_inhibit_();
this->state_ = EngineState::IDLE; this->state_ = EngineState::IDLE;
if (this->listener_ != nullptr) { this->listener_->on_connection_state(false, 0, reason);
this->listener_->on_connection_state(false, 0, reason);
}
} }
void RP2GattClient::cleanup_link_state_() { void RP2GattClient::cleanup_link_state_() {
@@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) {
if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) {
OpType op = this->op_type_; OpType op = this->op_type_;
this->op_type_ = OpType::NONE; this->op_type_ = OpType::NONE;
if (this->listener_ == nullptr) {
return;
}
switch (op) { switch (op) {
case OpType::READ_CHAR: case OpType::READ_CHAR:
case OpType::READ_DESC: case OpType::READ_DESC:
@@ -796,9 +781,7 @@ void RP2GattClient::finish_discovery_(int error) {
if (error != 0) { if (error != 0) {
this->release_services(); this->release_services();
} }
if (this->listener_ != nullptr) { this->listener_->on_service_discovery_done(error);
this->listener_->on_service_discovery_done(error);
}
} }
ble_device_base::GattServiceTable RP2GattClient::get_service_table() { ble_device_base::GattServiceTable RP2GattClient::get_service_table() {
@@ -990,7 +973,7 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui
return 0; return 0;
} }
} }
if (status == 0 && this->listener_ != nullptr) { if (status == 0) {
this->listener_->on_write_result(handle, 0); this->listener_->on_write_result(handle, 0);
} }
return status; return status;
@@ -1092,9 +1075,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) {
} }
} }
} }
if (this->listener_ != nullptr) { this->listener_->on_notify_state(handle, enable, 0);
this->listener_->on_notify_state(handle, enable, 0);
}
return 0; return 0;
} }
@@ -26,8 +26,6 @@
namespace esphome::bluetooth_connection { namespace esphome::bluetooth_connection {
class BluetoothConnection;
// Caps for the transient service table. Sized generously for real devices // Caps for the transient service table. Sized generously for real devices
// (typical peripherals expose < 8 services / < 30 characteristics); a peer // (typical peripherals expose < 8 services / < 30 characteristics); a peer
// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than
@@ -80,7 +78,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
void dump_config() override; void dump_config() override;
float get_setup_priority() const override; float get_setup_priority() const override;
void set_listener(BluetoothConnection *listener) { this->listener_ = listener; } void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
// ---- ble_device_base::BLEGattConnection contract ---- // ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type); int connect(uint64_t address, uint8_t addr_type);
@@ -94,6 +92,8 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
int pair(); int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); 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(); ble_device_base::GattServiceTable get_service_table();
// No connection-type branching on this backend.
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services(); void release_services();
protected: protected:
@@ -150,7 +150,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
} }
// Group 1: containers / large storage // Group 1: containers / large storage
BluetoothConnection *listener_{nullptr}; ble_device_base::GattClientListener *listener_{nullptr};
ServiceArena *arena_{nullptr}; ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_; esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_; esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
+37 -59
View File
@@ -4,17 +4,23 @@ import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import ble_device_base, bluetooth_connection from esphome.components import ble_device_base, bluetooth_connection
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2 from esphome.const import (
CONF_ACTIVE,
CONF_ID,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
)
from esphome.core import CORE from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType from esphome.types import ConfigType
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is # The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing # inside _esp32_config_schema()/_to_code_esp32(): importing those modules
# those modules registers esp32-only automations (ble.enable, ble.disable, ...) # registers esp32-only automations (ble.enable, ble.disable, ...) as a side
# as a side effect, and a module-scope import would leak them into every # effect, and a module-scope import would leak them into every platform's
# platform's registry the moment a config declares `bluetooth_proxy:` — # registry the moment a config declares `bluetooth_proxy:` — degrading
# degrading "Unable to find action" config errors into C++ compile failures. # "Unable to find action" config errors into C++ compile failures.
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
@@ -27,7 +33,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
target platform set, so it takes one of the concrete branches. target platform set, so it takes one of the concrete branches.
""" """
if CORE.is_esp32: if CORE.is_esp32:
return ["bluetooth_connection", "esp32_ble_client", "esp32_ble_tracker"] return ["bluetooth_connection", "esp32_ble_tracker"]
if CORE.target_platform in _HUB_PLATFORMS: if CORE.target_platform in _HUB_PLATFORMS:
return ["ble_device_base", "bluetooth_connection"] return ["ble_device_base", "bluetooth_connection"]
# No target platform, or one this component does not support: tooling # No target platform, or one this component does not support: tooling
@@ -36,7 +42,6 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
return [ return [
"ble_device_base", "ble_device_base",
"bluetooth_connection", "bluetooth_connection",
"esp32_ble_client",
"esp32_ble_tracker", "esp32_ble_tracker",
] ]
@@ -59,7 +64,6 @@ _LOGGER = logging.getLogger(__name__)
CONF_CONNECTION_SLOTS = "connection_slots" CONF_CONNECTION_SLOTS = "connection_slots"
CONF_CACHE_SERVICES = "cache_services" CONF_CACHE_SERVICES = "cache_services"
CONF_CONNECTIONS = "connections" CONF_CONNECTIONS = "connections"
CONF_BACKEND_ID = "backend_id"
DEFAULT_CONNECTION_SLOTS = 3 DEFAULT_CONNECTION_SLOTS = 3
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
@@ -86,12 +90,7 @@ def _esp32_config_schema() -> cv.All:
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
) )
BluetoothConnection = bluetooth_connection.esp32_connection_class() CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
def validate_connections(config): def validate_connections(config):
if CONF_CONNECTIONS in config: if CONF_CONNECTIONS in config:
@@ -101,7 +100,7 @@ def _esp32_config_schema() -> cv.All:
) )
elif config[CONF_ACTIVE]: elif config[CONF_ACTIVE]:
connection_slots: int = config[CONF_CONNECTION_SLOTS] connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")( bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
config config
) )
@@ -154,22 +153,16 @@ def _rp2_config_schema() -> cv.All:
"""Full proxy on the rp2 BLE hub: active connections through the BTstack """Full proxy on the rp2 BLE hub: active connections through the BTstack
GATT client backend in bluetooth_connection. The slot limit comes from the GATT client backend in bluetooth_connection. The slot limit comes from the
prebuilt BTstack library (one connection today); the code is built for N.""" prebuilt BTstack library (one connection today); the code is built for N."""
from esphome.components import rp2040_ble connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
connection_schema = cv.Schema(
{
cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection),
cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(
bluetooth_connection.RP2GattClient
),
}
)
def populate_connections(config: ConfigType) -> ConfigType: def populate_connections(config: ConfigType) -> ConfigType:
# One wrapper + backend pair per slot, declared during validation so # One wrapper + backend pair per slot, declared during validation so
# their ids exist for codegen (the esp32 arm's `connections` pattern). # their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]: if not config[CONF_ACTIVE]:
return config return config
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
)(config)
return { return {
**config, **config,
CONF_CONNECTIONS: [ CONF_CONNECTIONS: [
@@ -182,11 +175,6 @@ def _rp2_config_schema() -> cv.All:
cv.Schema( cv.Schema(
{ {
**_COMMON_SCHEMA_KEYS, **_COMMON_SCHEMA_KEYS,
# The GATT backend drives the controller directly (connect, GATT
# ops), not through the tracker hub.
cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(
rp2040_ble.RP2040BLE
),
cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional( cv.Optional(
CONF_CONNECTION_SLOTS, CONF_CONNECTION_SLOTS,
@@ -212,25 +200,22 @@ def _rp2_config_schema() -> cv.All:
return cv.All(schema, populate_connections) return cv.All(schema, populate_connections)
async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
from esphome.components import rp2040_ble """One wrapper + backend pair per slot; the platform-specific backend
registration lives in bluetooth_connection.new_gatt_backend()."""
# One wrapper + backend pair per slot (the esp32 arm's pattern). for connection_conf in config.get(CONF_CONNECTIONS, []):
for connection_conf in config[CONF_CONNECTIONS]: backend = await bluetooth_connection.new_gatt_backend(
ble_device_base.request_gatt_client() connection_conf, service_table=False
backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) )
await cg.register_component(backend, connection_conf)
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
connection = cg.new_Pvariable(connection_conf[CONF_ID]) connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend)) cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection)) cg.add(var.register_connection(connection))
# Per-platform schema builders and connection codegen; every key of # Per-platform schema builders; every key of
# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by # bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by
# tests/component_tests/bluetooth_proxy/). # tests/component_tests/bluetooth_proxy/). Connection codegen is shared.
_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} _GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema}
_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code}
# Keys every platform arm declares identically; each arm spreads this dict so # Keys every platform arm declares identically; each arm spreads this dict so
@@ -374,20 +359,18 @@ async def _to_code_esp32(config: ConfigType) -> None:
await cg.register_component(var, config) await cg.register_component(var, config)
cg.add(var.set_active(config[CONF_ACTIVE])) cg.add(var.set_active(config[CONF_ACTIVE]))
# Advertisements and scanner state arrive through the hub callbacks
# (installed in setup()); the tracker stays typed for scan-mode calls.
tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID])
cg.add(var.set_parent(tracker)) cg.add(var.set_ble_hub(tracker))
# Compiles the scanner-state push slot into the tracker and the matching
# registration into the proxy; the other hubs are polled instead.
cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
# Define max connections for protobuf fixed array # Define max connections for protobuf fixed array
connection_count = len(config.get(CONF_CONNECTIONS, [])) connection_count = len(config.get(CONF_CONNECTIONS, []))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
for connection_conf in config.get(CONF_CONNECTIONS, []): await _connections_to_code(var, config)
connection_var = cg.new_Pvariable(connection_conf[CONF_ID])
await cg.register_component(connection_var, connection_conf)
cg.add(var.register_connection(connection_var))
await esp32_ble_tracker.register_raw_client(connection_var, connection_conf)
if config.get(CONF_CACHE_SERVICES): if config.get(CONF_CACHE_SERVICES):
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
@@ -407,10 +390,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
# from the loop below (the define sizes fixed storage in the proxy). # from the loop below (the define sizes fixed storage in the proxy).
slots = len(config.get(CONF_CONNECTIONS, ())) slots = len(config.get(CONF_CONNECTIONS, ()))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots)
if not slots: await _connections_to_code(var, config)
return
await _GATT_HUB_TO_CODE[CORE.target_platform](var, config)
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
@@ -426,5 +406,3 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("USE_BLUETOOTH_PROXY") 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")
@@ -51,7 +51,7 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState
return this->api_connection_->send_message(resp); return this->api_connection_->send_message(resp);
} }
#ifndef USE_ESP32 #ifndef USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::send_polled_scanner_state_() { void BluetoothProxy::send_polled_scanner_state_() {
// One read feeds both the frame and the change detector; the detector only // 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 // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a
@@ -62,7 +62,7 @@ void BluetoothProxy::send_polled_scanner_state_() {
this->last_scan_running_ = running; this->last_scan_running_ = running;
} }
} }
#endif // !USE_ESP32 #endif // !USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::setup() { void BluetoothProxy::setup() {
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
@@ -75,9 +75,12 @@ void BluetoothProxy::setup() {
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv); static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv);
}}); }});
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// Only push hubs compile the slot; elsewhere loop() polls scan_running().
this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) {
static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state); static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
}}); }});
#endif
} }
// The hub delivers raw advertisements on the ESPHome main loop. // The hub delivers raw advertisements on the ESPHome main loop.
@@ -103,7 +106,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
} }
} }
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
connection->address_str(), ble_device_base::client_state_to_string(state)); connection->address_str(), ble_device_base::client_state_to_string(state));
@@ -112,7 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
} }
#endif // BLUETOOTH_CONNECTION_HAS_GATT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -129,13 +132,6 @@ void BluetoothProxy::log_advertisement_flush_() {
} }
void BluetoothProxy::dump_config() { void BluetoothProxy::dump_config() {
#ifdef USE_ESP32
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
" Connections: %d",
YESNO(this->active_), this->connection_count_);
#else
// Print configured facts. dump_config runs right after setup, before the // Print configured facts. dump_config runs right after setup, before the
// radio is up, so live scan state would always read "stopped" here — the // radio is up, so live scan state would always read "stopped" here — the
// loop's BluetoothScannerStateResponse carries the changing value instead. // loop's BluetoothScannerStateResponse carries the changing value instead.
@@ -143,7 +139,7 @@ void BluetoothProxy::dump_config() {
this->get_bluetooth_mac_address_pretty(mac_str); this->get_bluetooth_mac_address_pretty(mac_str);
const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)";
const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive";
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n" "Bluetooth Proxy:\n"
" Active: %s\n" " Active: %s\n"
@@ -159,33 +155,9 @@ void BluetoothProxy::dump_config() {
" Adapter MAC: %s", " Adapter MAC: %s",
scan_mode, mac_out); scan_mode, mac_out);
#endif #endif
#endif
} }
#ifdef USE_ESP32 #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::loop() {
// Run advertisement flush / connection cleanup every 100ms
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_advertisement_flush_time_ < 100)
return;
this->last_advertisement_flush_time_ = now;
if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
this->flush_pending_advertisements_();
return;
}
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() != 0 && !connection->disconnect_pending()) {
connection->disconnect();
}
}
}
#endif // USE_ESP32
#ifdef BLUETOOTH_CONNECTION_HAS_GATT
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. // maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused.
void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) {
@@ -197,11 +169,8 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c
ESP_LOGE(TAG, "Connection registry full, dropping registration"); ESP_LOGE(TAG, "Connection registry full, dropping registration");
return; return;
} }
#ifndef USE_ESP32 // The hub wrapper has no Component lifecycle, so the index is assigned here.
// esp32 assigns connection_index_ in BLEClientBase::setup(); the hub
// class has no Component lifecycle, so the index is assigned here.
connection->connection_index_ = this->connection_count_; connection->connection_index_ = this->connection_count_;
#endif
this->connections_[this->connection_count_++] = connection; this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this; connection->proxy_ = this;
#endif #endif
@@ -271,16 +240,9 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_device_connection(msg.address, true); this->send_device_connection(msg.address, true);
this->send_connections_free(); this->send_connections_free();
return; return;
} else if (connection->state() == ClientState::CONNECTING) {
if (connection->disconnect_pending()) {
ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect",
connection->get_connection_index(), connection->address_str());
connection->cancel_pending_disconnect();
return;
}
this->log_connection_request_ignored_(connection, connection->state());
return;
} else if (connection->state() != ClientState::INIT) { } else if (connection->state() != ClientState::INIT) {
// Covers CONNECTING too: a repeat request during a connect attempt is
// ignored the same way.
this->log_connection_request_ignored_(connection, connection->state()); this->log_connection_request_ignored_(connection, connection->state());
return; return;
} }
@@ -312,7 +274,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
break; break;
} }
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: {
// Both connection classes expose the same pairing surface; success is // The connection wrapper exposes the pairing surface; success is
// reported when the platform's pairing completion arrives. // reported when the platform's pairing completion arrives.
auto *connection = this->get_connection_(msg.address, false); auto *connection = this->get_connection_(msg.address, false);
if (connection != nullptr) { if (connection != nullptr) {
@@ -464,28 +426,52 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp); this->api_connection_->send_message(resp);
} }
#endif // BLUETOOTH_CONNECTION_HAS_GATT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32 #ifdef USE_ESP32
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->parent_()->get_scan_active() == active) { // esp32 only: BLEHub is the concrete tracker here, so these calls reach
// tracker-native methods beyond the neutral contract.
if (this->hub_->get_scan_active() == active) {
return; return;
} }
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->parent_()->set_scan_active(active); this->hub_->set_scan_active(active);
this->parent_()->stop_scan(); this->hub_->stop_scan();
this->parent_()->set_scan_continuous( this->hub_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up. true); // Set this to true to automatically start scanning again when it has cleaned up.
} }
#else // !USE_ESP32 #else // !USE_ESP32
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->hub_->scan_active() != active) {
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
if (!this->hub_->request_scan_mode(active)) {
// Passive-only controller asked for active scanning; the state report
// below carries the real, unchanged mode so the subscriber does not
// assume the change happened.
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
}
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
if (this->api_connection_ != nullptr) {
// Reports the mode change; the sender also refreshes last_scan_running_, so
// a failed restart (scan_running_ dropped by the tracker) is not reported
// again by loop() on the next tick. A push hub reports the restart's
// transitions (mode rides along) instead.
this->send_polled_scanner_state_();
}
#endif
}
#endif // USE_ESP32
void BluetoothProxy::loop() { void BluetoothProxy::loop() {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Stream pending service-discovery batches every iteration (esp32 parity: // Stream pending service-discovery batches every iteration; the streamer
// its connections stream from their own per-iteration Component loop). // handles a vanished API connection itself.
// send_service_for_discovery_() handles a vanished API connection itself.
for (uint8_t i = 0; i < this->connection_count_; i++) { for (uint8_t i = 0; i < this->connection_count_; i++) {
this->connections_[i]->process_pending_services(); this->connections_[i]->process_pending_services();
} }
@@ -498,9 +484,9 @@ void BluetoothProxy::loop() {
this->last_advertisement_flush_time_ = now; this->last_advertisement_flush_time_ = now;
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// The API subscriber is gone: tear down any connections it left behind // The API subscriber is gone: tear down any connections it left behind
// (disconnect() on an already-disconnecting backend is a no-op). // (disconnect() on an already-disconnecting slot is a no-op).
for (uint8_t i = 0; i < this->connection_count_; i++) { for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i]; auto *connection = this->connections_[i];
if (connection->get_address() != 0) { if (connection->get_address() != 0) {
@@ -511,16 +497,18 @@ void BluetoothProxy::loop() {
return; return;
} }
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
// This hub doesn't push scanner-state transitions; poll and report on // This hub doesn't push scanner-state transitions; poll and report on
// change. A hub gaining push must also refresh last_scan_running_ here. // change. A hub gaining push emits the define and drops this poll.
if (this->hub_->scan_running() != this->last_scan_running_) { if (this->hub_->scan_running() != this->last_scan_running_) {
this->send_polled_scanner_state_(); this->send_polled_scanner_state_();
} }
#endif
this->flush_pending_advertisements_(); this->flush_pending_advertisements_();
} }
#ifndef BLUETOOTH_CONNECTION_HAS_GATT #ifndef BLUETOOTH_CONNECTION_SERVES_PROXY
// Advertisement-only proxy. GATT client connections are excluded at compile // Advertisement-only proxy. GATT client connections are excluded at compile
// time (no connection backend on this platform, or active: false), so every // time (no connection backend on this platform, or active: false), so every
@@ -543,12 +531,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
break; break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); // Address-scoped maintenance needs no connection slot: real on esp32
// (Bluedroid bond table), the stub elsewhere keeps the old error reply.
conn_err_t ret = bluetooth_connection::unpair_device(msg.address);
this->send_device_unpairing(msg.address, ret == CONN_OK, ret);
break; break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: }
this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address);
this->send_device_clear_cache(msg.address, ret == CONN_OK, ret);
break; break;
}
} }
} }
@@ -586,27 +580,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp); this->api_connection_->send_message(resp);
} }
#endif // !BLUETOOTH_CONNECTION_HAS_GATT #endif // !BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->hub_->scan_active() != active) {
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
if (!this->hub_->request_scan_mode(active)) {
// Passive-only controller asked for active scanning; the state report
// below carries the real, unchanged mode so the subscriber does not
// assume the change happened.
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
}
}
if (this->api_connection_ != nullptr) {
// Reports the mode change; the sender also refreshes last_scan_running_, so
// a failed restart (scan_running_ dropped by the tracker) is not reported
// again by loop() on the next tick.
this->send_polled_scanner_state_();
}
}
#endif // USE_ESP32
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
@@ -622,8 +596,9 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
this->api_connection_->get_peername_to(old_peername)); this->api_connection_->get_peername_to(old_peername));
} }
this->api_connection_ = api_connection; this->api_connection_ = api_connection;
#ifdef USE_ESP32 #ifdef USE_BLE_SCANNER_STATE_CALLBACK
this->send_bluetooth_scanner_state_(this->parent_()->get_scanner_state()); // get_scanner_state() is part of the push-hub surface (see BLEHubContract).
this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state());
#else #else
this->send_polled_scanner_state_(); this->send_polled_scanner_state_();
#endif #endif
@@ -5,8 +5,6 @@
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY
#include <array> #include <array>
#include <map>
#include <vector>
#include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_pb2.h"
@@ -15,17 +13,9 @@
#include "esphome/components/bluetooth_connection/bluetooth_connection.h" #include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#ifdef USE_ESP32 #include "esphome/components/ble_device_base/ble_hub_impl.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h"
#else
#include "esphome/components/ble_device_base/ble_hub.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h"
#endif
#endif // USE_ESP32
namespace esphome::bluetooth_proxy { namespace esphome::bluetooth_proxy {
@@ -33,11 +23,10 @@ namespace esphome::bluetooth_proxy {
// re-exported here so the proxy code reads unqualified. // re-exported here so the proxy code reads unqualified.
using bluetooth_connection::CONN_OK; using bluetooth_connection::CONN_OK;
using bluetooth_connection::conn_err_t; using bluetooth_connection::conn_err_t;
using bluetooth_connection::DONE_SENDING_SERVICES;
using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::GATT_NOT_CONNECTED;
using bluetooth_connection::INIT_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
using BluetoothConnection = bluetooth_connection::BluetoothConnection; using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState; using ClientState = ble_device_base::ClientState;
#endif #endif
@@ -69,26 +58,21 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
}; };
class BluetoothProxy final : public Component { class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Allow the connection to update connections_free_response_ // Allow the connection to update connections_free_response_
friend bluetooth_connection::BluetoothConnection; friend bluetooth_connection::BluetoothConnection;
#endif #endif
public: public:
BluetoothProxy(); BluetoothProxy();
#ifdef USE_ESP32 void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
// Advertisements arrive through the hub's raw callback; parent_() below
// recovers the tracker type for the esp32-only scan-mode calls.
void set_parent(esp32_ble_tracker::ESP32BLETracker *parent) { this->hub_ = parent; }
#endif // USE_ESP32
void dump_config() override; void dump_config() override;
void setup() override; void setup() override;
void loop() override; void loop() override;
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void register_connection(BluetoothConnection *connection); void register_connection(BluetoothConnection *connection);
#endif // BLUETOOTH_CONNECTION_HAS_GATT #endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifndef USE_ESP32 #ifndef USE_ESP32
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and // snapshots scan_active()/scan_running() and installs the raw callback, and
// the BLEHub contract does not promise those are settled any earlier than // the BLEHub contract does not promise those are settled any earlier than
@@ -152,7 +136,7 @@ class BluetoothProxy final : public Component {
// scan_mode_switch is the capability bit for exactly that (#18079) — // scan_mode_switch is the capability bit for exactly that (#18079) —
// active_scan alone is not enough, a hub may support active scanning yet // active_scan alone is not enough, a hub may support active scanning yet
// refuse the runtime switch. // refuse the runtime switch.
if (this->hub_->get_capabilities().scan_mode_switch) { if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) {
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
} }
#endif #endif
@@ -188,7 +172,7 @@ class BluetoothProxy final : public Component {
protected: protected:
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
#ifndef USE_ESP32 #ifndef USE_BLE_SCANNER_STATE_CALLBACK
void send_polled_scanner_state_(); void send_polled_scanner_state_();
#endif #endif
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
@@ -205,7 +189,7 @@ class BluetoothProxy final : public Component {
} }
void log_advertisement_flush_(); void log_advertisement_flush_();
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
BluetoothConnection *get_connection_(uint64_t address, bool reserve); BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
void log_connection_info_(BluetoothConnection *connection, const char *message); void log_connection_info_(BluetoothConnection *connection, const char *message);
@@ -213,7 +197,7 @@ class BluetoothProxy final : public Component {
void log_not_connected_gatt_(const char *action, const char *type); void log_not_connected_gatt_(const char *action, const char *type);
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// Keep the pre-allocated connections-free message in step when a /// Keep the pre-allocated connections-free message in step when a
/// connection slot changes address (0 = free). Called from the connection /// connection slot changes address (0 = free). Called from the connection
/// classes' set_address(). /// classes' set_address().
@@ -253,18 +237,11 @@ class BluetoothProxy final : public Component {
// Group 1: Pointers (4 bytes each, naturally aligned) // Group 1: Pointers (4 bytes each, naturally aligned)
api::APIConnection *api_connection_{nullptr}; api::APIConnection *api_connection_{nullptr};
#ifdef BLUETOOTH_CONNECTION_HAS_GATT #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Group 2: Fixed-size array of connection pointers // Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{}; std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
#endif #endif
ble_device_base::BLEHub *hub_{nullptr}; ble_device_base::BLEHub *hub_{nullptr};
#ifdef USE_ESP32
// set_parent() is the only writer of hub_ on esp32, so the downcast is
// exact; ESP32BLETracker derives from BLEHub non-virtually.
esp32_ble_tracker::ESP32BLETracker *parent_() {
return static_cast<esp32_ble_tracker::ESP32BLETracker *>(this->hub_);
}
#endif
// BLE advertisement batching // BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_; api::BluetoothLERawAdvertisementsResponse response_;
@@ -279,7 +256,7 @@ class BluetoothProxy final : public Component {
bool active_; bool active_;
uint8_t connection_count_{0}; uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML bool configured_scan_active_{false}; // Configured scan mode from YAML
#ifndef USE_ESP32 #ifndef USE_BLE_SCANNER_STATE_CALLBACK
bool last_scan_running_{false}; // Last scanner state reported to the subscriber bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif #endif
}; };
@@ -205,6 +205,9 @@ async def to_code(config):
# available on esp32 (sensors with irk: worked without opting in). # available on esp32 (sensors with irk: worked without opting in).
ble_device_base.request_irk_support() ble_device_base.request_irk_support()
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_ESP32_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -161,7 +161,6 @@ class ESPBTClient : public ESPBTDeviceListener {
}; };
class ESP32BLETracker final : public Component, class ESP32BLETracker final : public Component,
public ble_device_base::BLEHub,
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
public ota::OTAGlobalStateListener, public ota::OTAGlobalStateListener,
#endif #endif
@@ -186,19 +185,27 @@ class ESP32BLETracker final : public Component,
void register_client(ESPBTClient *client); void register_client(ESPBTClient *client);
// ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override; void register_listener(ble_device_base::ESPBTDeviceListener *listener);
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback; this->raw_advertisement_callback_ = callback;
} }
ble_device_base::HubCapabilities get_capabilities() const override { #ifdef USE_BLE_SCANNER_STATE_CALLBACK
void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) {
this->scanner_state_callback_ = callback;
}
#endif
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// scan_mode_switch is false: the mode is driven through this tracker's own // scan_mode_switch is false: the mode is driven through this tracker's own
// API (set_scan_active + restart), not the neutral request_scan_mode(). // API (set_scan_active + restart), not the neutral request_scan_mode().
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true,
/* scan_mode_switch = */ false}; /* scan_mode_switch = */ false};
} }
void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); }
bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; }
bool scan_active() override { return this->scan_active_; } bool scan_active() { return this->scan_active_; }
// The mode is driven through this tracker's own API (see get_capabilities);
// the neutral request refuses without changing any state.
bool request_scan_mode(bool active) { return false; }
#ifdef USE_ESP32_BLE_DEVICE #ifdef USE_ESP32_BLE_DEVICE
void print_bt_device_info(const ESPBTDevice &device); void print_bt_device_info(const ESPBTDevice &device);
@@ -288,6 +295,9 @@ class ESP32BLETracker final : public Component,
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> neutral_listeners_; StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> neutral_listeners_;
#endif #endif
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
ble_device_base::ScannerStateCallback scanner_state_callback_{};
#endif
#ifdef USE_ESP32_BLE_DEVICE #ifdef USE_ESP32_BLE_DEVICE
/// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl)
ble_device_base::DiscoveredDeviceLog discovered_log_; ble_device_base::DiscoveredDeviceLog discovered_log_;
@@ -127,6 +127,9 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_LN882H_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -26,7 +26,6 @@ namespace esphome::ln882h_ble_tracker {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class LN882HBLETracker : public Component, class LN882HBLETracker : public Component,
public ble_device_base::BLEHub,
public Parented<ln882h_ble::LN882HBLE>, public Parented<ln882h_ble::LN882HBLE>,
public ln882h_ble::BLEScanListener public ln882h_ble::BLEScanListener
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
@@ -75,15 +74,15 @@ class LN882HBLETracker : public Component,
void stop_scan(); void stop_scan();
// ---- ble_device_base::BLEHub contract ---- // ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener); this->listeners_.push_back(listener);
#endif #endif
} }
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback; this->raw_advertisement_callback_ = callback;
} }
ble_device_base::HubCapabilities get_capabilities() const override { static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The LN882H controller supports active scanning; adv + scan response arrive // The LN882H controller supports active scanning; adv + scan response arrive
// as separate reports and are merged by this tracker (Bluedroid semantics). // as separate reports and are merged by this tracker (Bluedroid semantics).
// The SDK's GATT client is not exposed. // The SDK's GATT client is not exposed.
@@ -92,15 +91,15 @@ class LN882HBLETracker : public Component,
} }
// The controller stores the address LSB-first (BLE convention); the contract // The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order. // wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override { void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6]; uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac); this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
out[i] = mac[5 - i]; out[i] = mac[5 - i];
} }
bool scan_running() override { return this->scan_running_; } bool scan_running() { return this->scan_running_; }
bool scan_active() override { return this->scan_active_; } bool scan_active() { return this->scan_active_; }
bool request_scan_mode(bool active) override; bool request_scan_mode(bool active);
// ---- ln882h_ble::BLEScanListener ---- // ---- ln882h_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the // Delivered by the controller's loop() on the ESPHome main task — the
@@ -55,6 +55,9 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_RP2_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -17,7 +17,6 @@
namespace esphome::rp2_ble_tracker { namespace esphome::rp2_ble_tracker {
class RP2BLETracker : public Component, class RP2BLETracker : public Component,
public ble_device_base::BLEHub,
public rp2040_ble::BLEScanListener, public rp2040_ble::BLEScanListener,
public Parented<rp2040_ble::RP2040BLE> public Parented<rp2040_ble::RP2040BLE>
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
@@ -51,15 +50,15 @@ class RP2BLETracker : public Component,
void stop_scan(); void stop_scan();
// ---- ble_device_base::BLEHub contract ---- // ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener); this->listeners_.push_back(listener);
#endif #endif
} }
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback; this->raw_advertisement_callback_ = callback;
} }
ble_device_base::HubCapabilities get_capabilities() const override { static constexpr ble_device_base::HubCapabilities get_capabilities() {
// BTstack delivers scan responses as separate advertisement reports rather // BTstack delivers scan responses as separate advertisement reports rather
// than merging them into the advertisement — consumers relying on // than merging them into the advertisement — consumers relying on
// scan-response fields (device names) get them only where the receiver // scan-response fields (device names) get them only where the receiver
@@ -74,10 +73,10 @@ class RP2BLETracker : public Component,
} }
// The controller stores the address in printable (MSB-first) order, which is // The controller stores the address in printable (MSB-first) order, which is
// exactly what the contract wants. // exactly what the contract wants.
void get_adapter_mac(uint8_t out[6]) override { this->parent_->get_mac_msb_first(out); } void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); }
bool scan_running() override { return this->scan_running_; } bool scan_running() { return this->scan_running_; }
bool scan_active() override { return this->scan_active_; } bool scan_active() { return this->scan_active_; }
bool request_scan_mode(bool active) override; bool request_scan_mode(bool active);
// ---- rp2040_ble::BLEScanListener ---- // ---- rp2040_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main loop — the // Delivered by the controller's loop() on the ESPHome main loop — the
+17 -4
View File
@@ -252,12 +252,12 @@
// platforms whose API/network types the proxy header cannot assume. // platforms whose API/network types the proxy header cannot assume.
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2)
#define USE_BLUETOOTH_PROXY #define USE_BLUETOOTH_PROXY
#define USE_BLE_SCANNER_STATE_CALLBACK
// Mirror the codegen values per platform: _to_code_esp32() emits the connection // 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 // count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits
// advertisement-only hubs) — so static analysis checks the same // the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis
// std::array<uint64_t, N> instantiation a real build produces. // checks the same instantiations a real build produces.
#ifdef USE_ESP32 #ifdef USE_ESP32
#define USE_BLE_SCANNER_STATE_CALLBACK
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
#elif defined(USE_RP2) #elif defined(USE_RP2)
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 #define BLUETOOTH_PROXY_MAX_CONNECTIONS 1
@@ -305,6 +305,10 @@
#define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE #define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE
#define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_CONNECT
#define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT
#define USE_ESP32_BLE_TRACKER
#define USE_BLE_GATT_CLIENT
#define USE_BLE_GATT_SERVICE_TABLE
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
@@ -465,6 +469,7 @@
#define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_CDC
#define USE_SOCKET_IMPL_LWIP_TCP #define USE_SOCKET_IMPL_LWIP_TCP
#define USE_RP2040_BLE #define USE_RP2040_BLE
#define USE_RP2_BLE_TRACKER
#define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define RP2040_BLE_SCAN_LISTENER_COUNT 1
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_BLE_GATT_CLIENT #define USE_BLE_GATT_CLIENT
@@ -489,6 +494,14 @@
#define BK72XX_BLE_SCAN_LISTENER_COUNT 1 #define BK72XX_BLE_SCAN_LISTENER_COUNT 1
#define USE_LN882H_BLE #define USE_LN882H_BLE
#define LN882H_BLE_SCAN_LISTENER_COUNT 1 #define LN882H_BLE_SCAN_LISTENER_COUNT 1
// One tracker arm per build: ln882x gets its real hub; bk72xx also stands in
// for hub-less LibreTiny chips (rtl87xx) so bluetooth_proxy.h has a BLEHub
// to parse against.
#ifdef USE_LN882X
#define USE_LN882H_BLE_TRACKER
#else
#define USE_BK72XX_BLE_TRACKER
#endif
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_CAPTIVE_PORTAL #define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK #define USE_WIFI_SCAN_RESULTS_LOCK
@@ -0,0 +1,7 @@
esphome:
name: slotcount-ln882h-tracker
ln882x:
board: generic-ln882h
ln882h_ble_tracker:
@@ -1,6 +1,6 @@
"""Tests for the BLE hub provider registry and the missing-hub diagnostics.""" """Tests for the BLE hub provider registry and the missing-hub diagnostics."""
from collections.abc import Generator from collections.abc import Callable, Generator
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
@@ -202,3 +202,30 @@ def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) -
assert "0x00,0xff,0xee,0xdd" in emitted[2] assert "0x00,0xff,0xee,0xdd" in emitted[2]
with pytest.raises(ValueError, match="Unsupported UUID format"): with pytest.raises(ValueError, match="Unsupported UUID format"):
ble_device_base.add_service_uuid(var, "123") ble_device_base.add_service_uuid(var, "123")
@pytest.mark.parametrize(
("config_name", "define"),
[
("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"),
("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"),
("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"),
("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"),
],
)
def test_every_tracker_emits_its_alias_define(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_name: str,
define: str,
) -> None:
"""Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the
ble_hub_impl.h alias ladder selects on it. Checked through real codegen
(the other two legs of the invariant, the ladder arm and the defines.h
mirror, are compile-enforced: a missing arm fails any build containing a
BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses
on every tracker platform - and clang-tidy compiles each arm's
static_assert)."""
generate_main(component_config_path(config_name))
assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen"
@@ -109,6 +109,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only(
generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) 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_LISTENER_COUNT") is None
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3"
# One neutral GATT backend slot per connection (the hub-model flip).
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
def test_counts_reset_between_compiles( def test_counts_reset_between_compiles(
@@ -15,6 +15,13 @@ import voluptuous as vol
from esphome import config_validation as cv from esphome import config_validation as cv
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
def _esp32_schema_keys() -> dict[str, object]:
# The builder names its platform explicitly, so no CORE state is needed
# (this also mirrors how the language-schema dumper calls it).
return _keys(_schema_of(_esp32_config_schema()))
# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA # esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA
# plumbing (derived, so a future core key does not fail this component's test), # plumbing (derived, so a future core key does not fail this component's test),
# generated IDs (not user-walkable options), and connections (must validate # generated IDs (not user-walkable options), and connections (must validate
@@ -38,7 +45,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]:
def test_outer_scalar_keys_exist_in_esp32_schema() -> None: def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
outer = _keys(_schema_of(CONFIG_SCHEMA)) outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _keys(_schema_of(_esp32_config_schema())) esp32 = _esp32_schema_keys()
missing = set(outer) - set(esp32) missing = set(outer) - set(esp32)
assert not missing, ( assert not missing, (
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
@@ -51,7 +58,7 @@ def test_esp32_scalars_all_walkable() -> None:
"""Every non-generated esp32 scalar option must appear in the outer schema """Every non-generated esp32 scalar option must appear in the outer schema
(connections is deliberately excluded — it must validate exactly once).""" (connections is deliberately excluded — it must validate exactly once)."""
outer = _keys(_schema_of(CONFIG_SCHEMA)) outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _keys(_schema_of(_esp32_config_schema())) esp32 = _esp32_schema_keys()
scalar = { scalar = {
name name
for name, key in esp32.items() for name, key in esp32.items()
@@ -177,28 +177,35 @@ def test_rp2_rejects_esp32_only_keys_by_name(
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
def test_bluetooth_connection_auto_load_covers_its_includes() -> None: def test_bluetooth_connection_auto_load_matches_the_platform_stack() -> None:
# The esp32 connection header includes esp32_ble_client; the auto load # The backend registers with its platform BLE stack, so that dependency
# must satisfy that closure itself (regression: it once relied on the # lives here and consumers (proxy, radon_eye_rd200) stay platform-blind;
# consumer's auto loads). # the platform-less arm is the union for manifest-resolving tooling.
_set_platform("esp32") _set_platform("esp32")
assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"]
_set_platform("rp2") _set_platform("rp2")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"]
_set_platform("ln882x")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"]
# No target platform (tooling resolving the manifest): the union, so
# dependency closures stay complete for build_codeowners and friends.
_set_platform(None) _set_platform(None)
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] assert bluetooth_connection.AUTO_LOAD() == [
"ble_device_base",
"esp32_ble_tracker",
"rp2040_ble",
]
def test_every_registered_hub_platform_has_a_schema_arm() -> None: def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# A platform added to HUB_MAX_CONNECTIONS without a schema builder, # A platform added to HUB_MAX_CONNECTIONS without a schema builder or
# codegen arm, or _HUB_PLATFORMS entry would only fail when a config for # _HUB_PLATFORMS entry would only fail when a config for it is validated
# it is validated (or not even then); pin all three couplings here. # (or not even then); pin both couplings here. Connection codegen is
# shared (bluetooth_connection.new_gatt_backend), so it needs no arm.
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE)
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
# Hub platforms must also be in the backend registry the shared consumer
# helpers dispatch on.
assert registered <= set(bluetooth_connection.GATT_CLIENT_PLATFORMS)
# The outer walkable schema's bound must stay the loosest platform cap. # The outer walkable schema's bound must stay the loosest platform cap.
assert ( assert (
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
@@ -2,7 +2,7 @@
// configured; this TU pins it on the host so the header cannot rot unseen. // 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 contract is a concept (BLEGattConnection is a per-platform alias), so
// the minimal backend here proves the concept stays satisfiable and routes // the minimal backend here proves the concept stays satisfiable and routes
// events through the duck-typed sink the way a real backend does. // events through the GattClientListener interface the way a real backend does.
#define USE_BLE_GATT_CLIENT #define USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h" #include "esphome/components/ble_device_base/ble_gatt_client.h"
@@ -11,37 +11,36 @@
namespace esphome::ble_device_base::testing { namespace esphome::ble_device_base::testing {
struct RecordingSink { // Overrides only what it records; the interface's defaults cover the rest.
void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } class RecordingListener : public GattClientListener {
void on_service_discovery_done(int error) { this->discovery_error_ = error; } public:
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; }
void on_write_result(uint16_t handle, int error) {} void on_service_discovery_done(int error) override { this->discovery_error_ = error; }
void on_notify_state(uint16_t handle, bool enabled, int error) {} void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; }
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {}
void on_pairing_result(int status) {}
bool connected_{false}; bool connected_{false};
int discovery_error_{0}; int discovery_error_{0};
uint16_t write_handle_{0};
}; };
static_assert(GattClientEventSinkContract<RecordingSink>, "the recording sink must cover the full event-sink surface");
class MinimalConnection { class MinimalConnection {
public: public:
void set_listener(RecordingSink *listener) { this->listener_ = listener; } void set_listener(GattClientListener *listener) { this->listener_ = listener; }
int connect(uint64_t address, uint8_t addr_type) { int connect(uint64_t address, uint8_t addr_type) {
if (this->listener_ != nullptr) this->listener_->on_connection_state(true, 517, 0);
this->listener_->on_connection_state(true, 517, 0);
return 0; return 0;
} }
int disconnect() { return 0; } int disconnect() { return 0; }
int discover_services() { int discover_services() {
if (this->listener_ != nullptr) this->listener_->on_service_discovery_done(0);
this->listener_->on_service_discovery_done(0);
return 0; return 0;
} }
int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } 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 write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
this->listener_->on_write_result(handle, 0);
return 0;
}
int read_descriptor(uint16_t handle) { 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 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 notify_characteristic(uint16_t handle, bool enable) { return 0; }
@@ -51,23 +50,26 @@ class MinimalConnection {
} }
GattServiceTable get_service_table() { return {}; } GattServiceTable get_service_table() { return {}; }
void release_services() {} void release_services() {}
void set_connection_type(ConnectionType ct) {}
protected: protected:
RecordingSink *listener_{nullptr}; GattClientListener *listener_{nullptr};
}; };
static_assert(BLEGattConnectionContract<MinimalConnection, RecordingSink>, static_assert(BLEGattConnectionContract<MinimalConnection>,
"a minimal backend must satisfy the contract the alias asserts"); "a minimal backend must satisfy the contract the alias asserts");
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
MinimalConnection connection; MinimalConnection connection;
RecordingSink listener; RecordingListener listener;
connection.set_listener(&listener); connection.set_listener(&listener);
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
EXPECT_TRUE(listener.connected_); EXPECT_TRUE(listener.connected_);
EXPECT_EQ(connection.discover_services(), 0); EXPECT_EQ(connection.discover_services(), 0);
EXPECT_EQ(listener.discovery_error_, 0); EXPECT_EQ(listener.discovery_error_, 0);
EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED);
EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0);
EXPECT_EQ(listener.write_handle_, 7);
// A default table is empty and safe to walk. // A default table is empty and safe to walk.
GattServiceTable table = connection.get_service_table(); GattServiceTable table = connection.get_service_table();
@@ -76,4 +78,54 @@ TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
EXPECT_EQ(table.descriptor_count, 0); EXPECT_EQ(table.descriptor_count, 0);
} }
// A radon_eye_rd200-shaped table: two services, the second holding a
// notifying characteristic with a CCCD and a bare write characteristic.
class ServiceTableLookup : public ::testing::Test {
protected:
void SetUp() override {
this->services_[0] = {ESPBTUUID::from_uint16(0x1800), 0x0001, 0x0005, 0, 1};
this->services_[1] = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 1, 2};
this->characteristics_[0] = {ESPBTUUID::from_uint16(0x2A00), 0x0003, 0x0003, 0x02, 0, 0};
this->characteristics_[1] = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 1};
this->characteristics_[2] = {ESPBTUUID::from_uint16(0x1524), 0x0016, 0x0016, 0x04, 1, 0};
this->descriptors_[0] = {ESPBTUUID::from_uint16(0x2902), 0x0013};
this->table_ = {this->services_, this->characteristics_, this->descriptors_, 2, 3, 1};
}
GattService services_[2];
GattCharacteristic characteristics_[3];
GattDescriptor descriptors_[1];
GattServiceTable table_;
};
TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
ASSERT_NE(service, nullptr);
EXPECT_EQ(service->start_handle, 0x0010);
EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr);
const GattCharacteristic *characteristic =
find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
ASSERT_NE(characteristic, nullptr);
EXPECT_EQ(characteristic->value_handle, 0x0012);
// The lookup is scoped to the service: 0x2A00 lives in the other service.
EXPECT_EQ(find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x2A00)), nullptr);
}
TEST_F(ServiceTableLookup, FindsTheCccdAndReportsItsAbsence) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
const GattCharacteristic *notify_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
EXPECT_EQ(find_cccd(this->table_, *notify_char), 0x0013);
const GattCharacteristic *write_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1524));
EXPECT_EQ(find_cccd(this->table_, *write_char), 0);
}
TEST_F(ServiceTableLookup, RejectsRangesThatOverrunTheTable) {
// A corrupt index range must fail the lookup, not walk out of bounds.
GattService bad_service = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 2, 5};
EXPECT_EQ(find_characteristic(this->table_, bad_service, ESPBTUUID::from_uint16(0x1524)), nullptr);
GattCharacteristic bad_char = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 9};
EXPECT_EQ(find_cccd(this->table_, bad_char), 0);
}
} // namespace esphome::ble_device_base::testing } // namespace esphome::ble_device_base::testing
@@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing {
// //
// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against // The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against
// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are // the Beken SDK and cannot run host-side, so the guard-and-fire semantics are
// pinned here through a minimal host BLEHub implementation instead. // pinned here through a minimal host hub carrying only the slot under test.
namespace { namespace {
class FakeHub : public BLEHub { class FakeHub {
public: public:
void register_listener(ESPBTDeviceListener *listener) override {} void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; }
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; }
HubCapabilities get_capabilities() const override { return {false, false, false}; }
void get_adapter_mac(uint8_t out[6]) override {}
bool scan_running() override { return false; }
bool scan_active() override { return false; }
/// The emit path every tracker implements: fire only when a subscriber is set. /// The emit path every tracker implements: fire only when a subscriber is set.
void emit(const RawAdvertisement &adv) { void emit(const RawAdvertisement &adv) {
@@ -1,76 +0,0 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_hub.h"
namespace esphome::ble_device_base::testing {
// Pins the request_scan_mode() contract: the base default refuses (so hubs
// without a mode switch — and out-of-tree trackers — keep building and
// callers report the real state), while an overriding hub both honors the
// request and applies it.
namespace {
class DefaultHub : public BLEHub {
public:
void register_listener(ESPBTDeviceListener *listener) override {}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override {}
HubCapabilities get_capabilities() const override { return {false, false, false}; }
void get_adapter_mac(uint8_t out[6]) override {}
bool scan_running() override { return false; }
// Backed by real state so "changes nothing" is observable: a base default
// that silently mutated the hub would flip this and fail the assertion.
bool scan_active() override { return this->active_; }
protected:
bool active_{true};
};
class SwitchingHub : public DefaultHub {
public:
HubCapabilities get_capabilities() const override { return {true, false, false, /* scan_mode_switch = */ true}; }
bool request_scan_mode(bool active) override {
this->active_ = active;
return true;
}
};
// The esp32 shape: the controller supports active scanning but the hub keeps
// the refusing default (mode is driven through its own tracker API).
class CapableRefusingHub : public DefaultHub {
public:
HubCapabilities get_capabilities() const override { return {true, false, false}; }
};
} // namespace
TEST(BLEHubScanModeRequest, DefaultRefusesAndChangesNothing) {
DefaultHub hub;
EXPECT_TRUE(hub.scan_active());
EXPECT_FALSE(hub.request_scan_mode(false));
// Refused, not applied-and-reported-false: the state is untouched.
EXPECT_TRUE(hub.scan_active());
EXPECT_FALSE(hub.request_scan_mode(true));
EXPECT_TRUE(hub.scan_active());
}
TEST(BLEHubScanModeRequest, OverrideHonorsAndApplies) {
SwitchingHub hub;
EXPECT_TRUE(hub.get_capabilities().scan_mode_switch);
EXPECT_TRUE(hub.request_scan_mode(true));
EXPECT_TRUE(hub.scan_active());
EXPECT_TRUE(hub.request_scan_mode(false));
EXPECT_FALSE(hub.scan_active());
}
TEST(BLEHubScanModeRequest, CapabilityAndSwitchAreIndependent) {
CapableRefusingHub hub;
EXPECT_TRUE(hub.get_capabilities().active_scan);
// The esp32 shape advertises no runtime switch, and the request refuses.
EXPECT_FALSE(hub.get_capabilities().scan_mode_switch);
EXPECT_FALSE(hub.request_scan_mode(false));
EXPECT_TRUE(hub.scan_active());
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,10 @@
# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is
# compiled (USE_BLE_GATT_CLIENT unset), which pins the SERVES_PROXY gating and the
# address-scoped maintenance path that a connections build never exercises.
packages:
common: !include common.yaml
esp32_ble_tracker:
bluetooth_proxy:
active: false