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 KostonandGitHub 862b13c8dd [esp32_ble_tracker] Retire the scanner-state listener interface (#18179) 2026-08-08 20:58:48 -05:00
Edu_CoderandGitHub 2bb01853e0 [tuya] GMT time 0x0C command handler (#17158) 2026-08-08 17:53:10 -07: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 KostonandJ. Nick Koston ce2e113a6d Update stale comments left by the listener removal 2026-08-08 19:13:11 -05:00
J. Nick KostonandJ. Nick Koston ae5b6d271f Retire the scanner-state listener interface 2026-08-08 19:13:11 -05:00
J. Nick KostonandGitHub c7d6b4aaa4 [esp32_ble_tracker] Retire the raw listener path and parser-type enum (#18177) 2026-08-08 19:13:07 -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 KostonandJ. Nick Koston 152b6a5d50 Update stale comments left by the listener removal 2026-08-08 15:59:54 -05:00
J. Nick KostonandJ. Nick Koston f68ebcf7d1 Retire the scanner-state listener interface 2026-08-08 15:59:54 -05:00
J. Nick KostonandJ. Nick Koston ca5fa6dfaf Use the shared MAC validity helper 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston 962a82d157 Make the adapter MAC accessor const 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston c6d4ca7377 Use one stack query for the adapter MAC 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston 465a6a3596 Let esp32_ble own the adapter MAC lookup 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston 03c9b001f4 Ask the stack for the adapter MAC on hosted controllers 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston d680200e5a Read the adapter MAC through esp_read_mac 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston 98dae49440 Remove redundant access specifier 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. Nick Koston 7ec366c1ba Monotonic parse flag, client-scoped opt-out, drop dead include 2026-08-08 15:59:53 -05:00
J. Nick KostonandJ. 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 KostonandGitHub 8c74e3d5ef [bluetooth_proxy] Deliver scanner state through the hub callback (#18175) 2026-08-08 15:59:49 -05:00
J. Nick KostonandGitHub 04384e0f5b [ble_device_base] Bind the GATT backend at compile time (#18185) 2026-08-08 15:59:32 -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 KostonandGitHub 7218aa4803 [bluetooth_connection] Explicit pairing for rp2 (#18166) 2026-08-08 09:51:12 -05:00
J. Nick KostonandGitHub 9cb46aa584 [bluetooth_proxy] Deliver esp32 advertisements through the hub callback (#18173) 2026-08-08 09:50:32 -05:00
Josef ZweckandGitHub 747c5c3e40 [modbus] Make sure we log on no accepting device (#18187) 2026-08-08 09:34:59 -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
Edvard FilistovičandGitHub 68acc055bf [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 13: xiaomi_rtcgq02lm, xiaomi_wx08zm, xiaomi_xmwsdj04mmc) (#18183) 2026-08-08 02:06:40 -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
2730c10c2c [modbus] Route broadcast writes (address 0) to all server devices (#17387)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-08 01:35:49 -05:00
Edvard FilistovičandGitHub 252bb3333e [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 12: xiaomi_miscale, xiaomi_mjyd02yla, xiaomi_mue4094rt) (#18180) 2026-08-08 01:34:08 -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
Edvard FilistovičandGitHub 98f4854ecd [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 11: xiaomi_lywsdcgq, xiaomi_mhoc303, xiaomi_mhoc401) (#18178) 2026-08-08 01:01:24 -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
Edvard FilistovičandGitHub 413a4c5885 [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 10: xiaomi_lywsd02, xiaomi_lywsd02mmc, xiaomi_lywsd03mmc) (#18174) 2026-08-08 00:27:44 -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
Edvard FilistovičandGitHub 087b80eeb5 [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 9: xiaomi_hhccjcy10, xiaomi_hhccpot002, xiaomi_jqjcy01ym) (#18172) 2026-08-07 23:42:37 -05:00
745dee0734 [usb_cdc_acm] Don't discard queued TX data when USB flush times out (#17637)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-07 23:24:56 -05:00
Edvard FilistovičandGitHub b986530efc [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 8: xiaomi_cgpr1, xiaomi_gcls002, xiaomi_hhccjcy01) (#18171) 2026-08-07 22:51:33 -05:00
Edvard FilistovičandGitHub 3aaea907ba [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 7: xiaomi_cgd1, xiaomi_cgdk2, xiaomi_cgg1) (#18170) 2026-08-07 21:59:21 -05:00
Edvard FilistovičandGitHub 1d184b43eb [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 6: thermopro_ble, exposure_notifications, xiaomi_ble) (#18168) 2026-08-07 20:47:52 -05:00
J. Nick KostonandGitHub 8d494a84c5 [bluetooth_connection] Engine follow-ups from the rp2 GATT series (#18159) 2026-08-07 16:25:55 -05:00
b0aec6dc2f [modbus_client] Lambda sugar (#18146)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 16:09:15 -05:00
4a7d270494 [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 5: airthings_ble, inkbird_ibsth1_mini, radon_eye_ble) (#18165)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-07 20:46:43 +00:00
dependabot[bot]andGitHub ae26483be6 Bump github/codeql-action/init from 4.37.5 to 4.37.6 (#18164)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 19:27:57 +00:00
dependabot[bot]andGitHub 5d7bd179b1 Bump github/codeql-action/analyze from 4.37.5 to 4.37.6 (#18163)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 14:27:09 -05:00
Edvard FilistovičandGitHub 23a3454551 [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 4: ruuvi_ble, ruuvitag, b_parasite) (#18161) 2026-08-07 14:21:46 -05:00
f5ee72753d [modbus_client] Add typed read/write actions (#18078)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 14:18:20 -05:00
esphome[bot]andGitHub ee13996ee6 Bump bundled esphome-device-builder to 1.9.4 (#18162) 2026-08-07 19:17:26 +00:00
c24e61439b [modbus] Add server support for read/write multiple registers (0x17) (#17357)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-07 14:17:11 -05:00
J. Nick KostonandGitHub 2e1c517821 [bluetooth_proxy] Enable active connections on rp2 (#18132) 2026-08-07 13:51:41 -05:00
950cfc4da3 [ld6002b] Add select and button platforms (4/5) (#17822)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-07 14:33:37 -04:00
715b14aeba [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 3: mopeka_ble, mopeka_pro_check, mopeka_std_check) (#17951)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-07 12:59:27 -05:00
J. Nick KostonandGitHub d7b5ad77da [bluetooth_connection] Add BTstack GATT client backend for rp2 (#18131) 2026-08-07 12:53:09 -05:00
Edvard FilistovičandGitHub 77bfda6f1f [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 2: atc_mithermometer, pvvx_mithermometer, bthome_mithermometer) (#17950) 2026-08-07 12:48:18 -05:00
Oliver KleineckeGitHubOliver KleineckeClaude Sonnet 4.6Copilot Autofix powered by AIstorage splitpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
b326cefea7 [esp32] refactor esp32-vfs default configs to FINAL co-routine & add some defaults (#17337)
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: storage split <ai@local>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-07 12:36:59 -05:00
J. Nick KostonandGitHub c0e70d9beb [rp2040_ble] Add scan arbitration and GATT client hooks (#18155) 2026-08-07 11:50:18 -05:00
J. Nick KostonandGitHub c5e165d062 [bluetooth_proxy] Make the GATT dispatch platform neutral (#18130) 2026-08-07 11:29:19 -05:00
Edvard FilistovičandGitHub 5f483b11b6 [ble_device_base] Migrate BLE sensor platforms to the neutral layer (batch 1: ble_presence, ble_rssi, ble_scanner) (#17716) 2026-08-07 16:19:08 +00:00
b802740997 [ld6002b] Add switch, number and text sensor platforms (3/5) (#17821)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-07 11:11:29 -04:00
Edvard FilistovičandGitHub 1bbe8b415f [bluetooth_proxy] Only advance the scanner-state detector when the frame was sent (#18154) 2026-08-07 15:01:50 +00:00
8b0e23d55b [bluetooth_proxy] Fold scanner-state bookkeeping into the sender; extend platform-gate tests (#18150)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-07 14:30:00 +00:00
J. Nick KostonandGitHub 2f2634bf6b [bluetooth_connection] Move BluetoothConnection out of bluetooth_proxy (#18129) 2026-08-07 08:54:28 -05:00
Jonathan SwobodaandGitHub 9c93d8925f [ota] Use PSA crypto for signature verification on ESP-IDF 6 (#18145) 2026-08-07 07:41:48 -04:00
J. Nick KostonandGitHub 634515ecc5 [ble_device_base] Add platform-neutral GATT client contract (#18128) 2026-08-06 21:45:57 -05:00
J. Nick KostonandGitHub e58ba59288 [core] Add StringRef::starts_with (#18142) 2026-08-06 20:58:41 -05:00
Jonathan SwobodaandGitHub bf95be5550 [opentherm] Initialize output state before the first write (#18126) 2026-08-06 21:46:37 -04:00
Jonathan SwobodaandGitHub 96f0ea10f4 [midea] Bump MideaUART so its ESP-IDF shims stop shadowing millis() (#18119) 2026-08-06 21:46:28 -04:00
J. Nick KostonandGitHub 97558ce64a [core] Add EventPool::warm() (#18127) 2026-08-06 20:40:04 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
4301e5a2a2 Bump resvg-py from 0.3.3 to 0.3.4 (#18137)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-06 19:58:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
09ecfa58de Bump prek from 0.4.11 to 0.4.12 (#18138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-06 19:57:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
27f6ced1a9 Bump github/codeql-action/analyze from 4.37.4 to 4.37.5 (#18139)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-06 19:49:40 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
9802c7f32f Bump github/codeql-action/init from 4.37.4 to 4.37.5 (#18140)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-06 19:49:32 -04:00
Jonathan SwobodaandGitHub bf36a62f62 [ci] Raise runner-concurrency caps for the OHF enterprise pool (#18125) 2026-08-06 19:47:12 -04:00
4c47051da9 [zigbee] Add on_start automation (3/3) (#18009)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-06 19:45:44 -04:00
56682534f8 [modbus_client] Add component for ad-hoc modbus request/response (#17676)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-06 14:52:23 -05:00
53b1b3a253 [ld6002b] Add target sensors (2/5) (#17820)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-06 15:36:32 -04:00
zerafachrisandGitHub 4ccf4210be [epaper_spi] Do not yield on the final row of a T133A01 transfer phase (#17709) 2026-08-07 03:34:33 +10:00
3221ed2bad [ble_device_base] Hub provider registry and shared consumer helpers (#18081)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-06 12:13:30 -05:00
J. Nick KostonandGitHub abc13a7c0a [script] Disable queued script polling while the queue is empty (#18121) 2026-08-06 10:00:46 -05:00
luar123andGitHub 3353e71f9a [zigbee] Improve network handling on esp32 (2/3) (#18008) 2026-08-06 10:34:32 -04:00
J. Nick KostonandGitHub 73689f8d8b [rp2] Limit pin validation to GPIO 0-29 on RP2350A boards (#18102) 2026-08-06 08:55:24 -05:00
Mustafa KURUandGitHub ba86685361 [const] Move CONF_TARGET_COUNT to components/const (#18120) 2026-08-06 09:37:36 -04:00
Keith BurzinskiGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
a16c2e2b04 [esp32] Use constants for the signing scheme identifiers (#18118)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-06 01:54:21 -05:00
J. Nick KostonandGitHub eb0fac9cbf [core] Only snapshot the user config when esphome config --no-defaults asks for it (#18113) 2026-08-05 21:20:52 -05:00
Bonne EgglestonandGitHub 3b6f45894b [core] StaticVector converting ctor (#18117) 2026-08-05 21:20:37 -05:00
f8cabc9d1f [ld6002b] Add LD6002B 60GHz presence radar (1/5) (#17819)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-05 22:18:51 -04:00
J. Nick KostonandGitHub a7740091ec [ci] Run CodSpeed benchmarks when top-level esphome Python modules change (#18114) 2026-08-05 20:50:13 -05:00
esphome[bot]andGitHub 0fcc2a6ff1 Bump bundled esphome-device-builder to 1.9.3 (#18116) 2026-08-05 20:50:00 -05:00
J. Nick KostonGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
0d192e8334 [rp2] Bump arduino-pico framework to 6.0.0 (#18101)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-06 01:21:35 +00:00
451 changed files with 14668 additions and 2746 deletions
+2 -2
View File
@@ -182,8 +182,8 @@ jobs:
contents: read # actions/checkout to load the test configs
strategy:
fail-fast: false
# Cap concurrency so this smoke test doesn't hog all the shared runners.
max-parallel: 2
# Modest cap so this smoke test leaves room on the shared runner pool.
max-parallel: 8
matrix:
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
# share a toolchain bundle, so esp32 is exercised on the base variant
+1 -4
View File
@@ -516,7 +516,6 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- id: clang-tidy
@@ -707,7 +706,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
@@ -787,7 +785,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
@@ -874,7 +871,7 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
matrix:
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps:
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:${{matrix.language}}"
+3
View File
@@ -78,6 +78,7 @@ esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_connection/* @bdraco @jesserockz
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis
esphome/components/bme280_base/* @esphome/core
@@ -288,6 +289,7 @@ esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu
esphome/components/ld24xx/* @kbx81
esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter
esphome/components/libretiny/* @kuba2k2
esphome/components/libretiny_pwm/* @kuba2k2
@@ -351,6 +353,7 @@ esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz
esphome/components/mmc5603/* @benhoff
esphome/components/mmc5983/* @agoode
esphome/components/modbus_client/* @exciton
esphome/components/modbus_controller/* @martgras
esphome/components/modbus_controller/binary_sensor/* @martgras
esphome/components/modbus_controller/number/* @martgras
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.2
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.4
RUN \
platformio settings set enable_telemetry No \
+2
View File
@@ -2601,6 +2601,8 @@ def run_esphome(argv):
config = read_config(
command_line_substitutions,
skip_external_update=skip_external,
# Snapshot only needed by `esphome config --no-defaults`.
snapshot_user_config=getattr(args, "no_defaults", False),
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
+12 -9
View File
@@ -1,23 +1,26 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
CODEOWNERS = ["@jeromelaban"]
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
AirthingsListener = airthings_ble_ns.class_(
"AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener
"AirthingsListener", ble_device_base.ESPBTDeviceListener
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(AirthingsListener),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("airthings_ble"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AirthingsListener),
}
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
@@ -2,15 +2,13 @@
#include "esphome/core/log.h"
#include <cinttypes>
#ifdef USE_ESP32
namespace esphome::airthings_ble {
static const char *const TAG = "airthings_ble";
bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
for (auto &it : device.get_manufacturer_datas()) {
if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) {
if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) {
if (it.data.size() < 4)
continue;
@@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
}
} // namespace esphome::airthings_ble
#endif
@@ -1,17 +1,13 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/component.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::airthings_ble {
class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener {
class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
};
} // namespace esphome::airthings_ble
#endif
@@ -1,8 +1,6 @@
#include "atc_mithermometer.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::atc_mithermometer {
static const char *const TAG = "atc_mithermometer";
@@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() {
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_);
}
bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
@@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device
return success;
}
optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) {
optional<ParseResult> ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) {
ParseResult result;
if (!service_data.uuid.contains(0x1A, 0x18)) {
ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes.");
@@ -132,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional<ParseResult> &result, cons
}
} // namespace esphome::atc_mithermometer
#endif
@@ -2,12 +2,10 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include <vector>
#ifdef USE_ESP32
namespace esphome::atc_mithermometer {
struct ParseResult {
@@ -18,11 +16,11 @@ struct ParseResult {
int raw_offset;
};
class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { address_ = address; };
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; }
@@ -40,11 +38,9 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
uint8_t last_frame_count_{0};
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
optional<ParseResult> parse_header_(const ble_device_base::ServiceData &service_data);
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
bool report_results_(const optional<ParseResult> &result, const char *address);
};
} // namespace esphome::atc_mithermometer
#endif
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, sensor
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
@@ -24,14 +24,15 @@ from esphome.const import (
CODEOWNERS = ["@ahpohl"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer")
ATCMiThermometer = atc_mithermometer_ns.class_(
"ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
"ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("atc_mithermometer"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ATCMiThermometer),
@@ -71,15 +72,15 @@ CONFIG_SCHEMA = (
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
+1 -5
View File
@@ -1,8 +1,6 @@
#include "b_parasite.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::b_parasite {
static const char *const TAG = "b_parasite";
@@ -16,7 +14,7 @@ void BParasite::dump_config() {
LOG_SENSOR(" ", "Illuminance", this->illuminance_);
}
bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
@@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
}
} // namespace esphome::b_parasite
#endif // USE_ESP32
+3 -7
View File
@@ -2,18 +2,16 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#ifdef USE_ESP32
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::b_parasite {
class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { address_ = address; };
void set_bindkey(const std::string &bindkey);
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; }
@@ -35,5 +33,3 @@ class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceL
};
} // namespace esphome::b_parasite
#endif // USE_ESP32
+7 -6
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, sensor
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_VOLTAGE,
@@ -23,14 +23,15 @@ from esphome.const import (
CODEOWNERS = ["@rbaron"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
b_parasite_ns = cg.esphome_ns.namespace("b_parasite")
BParasite = b_parasite_ns.class_(
"BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
"BParasite", ble_device_base.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("b_parasite"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BParasite),
@@ -68,15 +69,15 @@ CONFIG_SCHEMA = (
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
@@ -44,6 +44,8 @@ DEPENDENCIES = ["bk72xx"]
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
CODEOWNERS = ["@Bl00d-B0b"]
ble_device_base.register_hub_provider("bk72xx_ble_tracker")
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
@@ -142,6 +144,9 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_BK72XX_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -159,7 +159,7 @@ void BK72xxBLETracker::dump_config() {
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
// Raw callback (the raw-advertisement path).
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.mac = report.mac,
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(report.mac),
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
@@ -45,7 +45,6 @@ namespace esphome::bk72xx_ble_tracker {
// ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER
@@ -93,15 +92,15 @@ class BK72xxBLETracker : public Component,
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The Beken BDK exposes no active-scan path (passive scanning only), so the
// controller never solicits scan responses and never merges them; consumers
// relying on scan-response fields (device names) get them only where the
@@ -110,21 +109,21 @@ class BK72xxBLETracker : public Component,
// path there is no mode to switch to.
return {.active_scan = false, .merges_scan_response = false, .gatt = false, .scan_mode_switch = false};
}
bool request_scan_mode(bool active) override {
bool request_scan_mode(bool active) {
// Passive-only controller: a passive request is already honored, an active
// one cannot be.
return !active;
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override {
void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return false; } // BK72xx scan is passive-only
bool scan_running() { return this->scan_running_; }
bool scan_active() { return false; } // BK72xx scan is passive-only
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
+147 -19
View File
@@ -3,26 +3,41 @@ ble_device_base — the platform-neutral BLE layer.
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
(BLEHub, in ble_hub.h) on every platform.
(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h)
on every platform.
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
subclass, so there is no platform table here and no dependency in either
direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and
calls register_ble_device() in to_code; a tracker component subclasses BLEHub
(C++ and codegen class). Adding a new BLE chip requires only a new tracker
component.
subclass, so there is no Python platform table here and no dependency in
either direction (C++-side, the compile-time alias header ble_hub_impl.h and the
defines.h mirror are the deliberate exceptions). A sensor extends
BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a
declared key even on strict schemas) and calls register_ble_device() in
to_code; a tracker component declares BLEHub as its codegen-class parent and
MUST call register_hub_provider() at import time — without it _require_hub
rejects configs that bind through the generated id (an explicit ble_hub_id:
bypasses the registry). Adding a new BLE chip requires a new in-tree tracker
component plus its alias arm and define (see above); out-of-tree BLE hubs
are not supported.
AES-CCM decryption for encrypted advertisements is provided portably in
ble_aes_ccm.h.
"""
from collections.abc import Callable
import re
import esphome.codegen as cg
from esphome.components.const import CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_CONTINUOUS, CONF_DURATION, CONF_INTERVAL
from esphome.const import (
CONF_ACTIVE,
CONF_CONTINUOUS,
CONF_DURATION,
CONF_INTERVAL,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID, KEY_CORE
from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"]
@@ -36,25 +51,101 @@ LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
# The neutral tracker contract. Every tracker's codegen class declares this as a
# parent, which is what lets cv.use_id(BLEHub) resolve any of them.
# The neutral tracker contract. Every tracker's codegen class declares this as
# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python
# only: C++-side the name is a per-platform alias (ble_hub_impl.h).
BLEHub = ble_device_base_ns.class_("BLEHub")
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
def inject_ble_hub(config: ConfigType) -> ConfigType:
"""Validator: auto-resolve the configured BLE tracker into the config.
# Config keys that provide a BLEHub, registered by each tracker component at
# import time (a tracker's module is imported iff it can end up in the build).
# Used only to phrase an actionable error when a BLE consumer is configured
# without any tracker — the binding itself resolves any BLEHub subclass and
# needs no platform table. Out-of-tree BLE hubs are not supported; the
# registry and the messages below deal in in-tree trackers only.
_HUB_PROVIDERS: set[str] = set()
Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID +
cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on
any platform; multiple trackers can be disambiguated with an explicit
ble_hub_id.
"""
return cv.Schema(
{cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA
)(config)
# The in-tree trackers per target platform, so the missing-tracker error names
# them even in a fresh process where no tracker module has been imported yet (a
# consumer imports only ble_device_base, so the registry is empty exactly in
# the most common failure: the tracker was simply forgotten). Filtered by the
# current platform so an esp32 config is not told to add a Beken tracker; an
# unknown/absent platform falls back to every in-tree name.
_IN_TREE_HUB_PROVIDERS: dict[str, str] = {
"esp32": "esp32_ble_tracker",
"bk72xx": "bk72xx_ble_tracker",
"rp2": "rp2_ble_tracker",
"ln882x": "ln882h_ble_tracker",
}
def register_hub_provider(component: str) -> None:
"""Called at import time by every component whose config key declares a BLEHub."""
_HUB_PROVIDERS.add(component)
def _require_hub(value: ID) -> ID:
# Without this check a missing tracker surfaces at ID resolution as
# "Couldn't find any component that can be used for 'ble_device_base::BLEHub'"
# — a C++ class name the user never types. Component final validation cannot
# phrase it better: the ID pass runs first and its error skips all later
# steps. All explicitly configured components are loaded before any schema
# validates, so a registered provider in loaded_integrations is exact here.
if value.id is not None:
# Explicit ble_hub_id: — the user is pointing at a specific hub (the
# multi-hub disambiguation case). Let the ID pass judge it; its error
# names the missing id, which is accurate.
return value
if not _HUB_PROVIDERS & CORE.loaded_integrations:
# Defensive lookup rather than CORE.target_platform: the property
# raises when no platform is registered, and this message must never
# be the thing that crashes. In a real run the platform is always set
# (LoadTargetPlatformValidationStep runs before any other domain), so
# the unfiltered all-platforms fallback is reachable only from tests.
platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM)
if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS:
# Known platform with no in-tree hub (esp8266, host, rtl87xx, …):
# listing the other platforms' trackers would misdirect, and
# out-of-tree BLE hubs are not supported.
raise cv.Invalid(
f"No BLE tracker exists for {platform}; BLE components are "
"not supported on this platform"
)
in_tree = (
{tracker}
if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform))
else set(_IN_TREE_HUB_PROVIDERS.values())
)
# in_tree only: _HUB_PROVIDERS is import-time state that outlives
# CORE.reset() in a long-lived process (dashboard), so a tracker from
# an earlier build of another platform must not leak into the message.
# The gate above is immune — loaded_integrations resets per run.
names = ", ".join(sorted(in_tree))
raise cv.Invalid(f"No BLE tracker configured — add one of: {names}")
return value
# Schema fragment binding a consumer to the configured BLE tracker: extend a
# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a
# trailing validator after a PREVENT_EXTRA schema would reject the explicit
# form before ever running. An omitted id resolves to the single declared
# tracker on any platform; multiple trackers are disambiguated with an
# explicit ble_hub_id.
BLE_DEVICE_SCHEMA = cv.Schema(
{cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)}
)
def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]:
"""Transitional alias for the pre-migration binding key: esp32_ble_id ->
ble_hub_id. Warns and auto-migrates until removal; every migrated platform
prepends this to its CONFIG_SCHEMA so existing configs keep validating."""
return cv.rename_key(
"esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component
)
def request_irk_support() -> None:
@@ -63,6 +154,21 @@ def request_irk_support() -> None:
cg.add_define("USE_BLE_DEVICE_IRK")
# Number of GATT client connection slots in this build; sizes the platform
# backend's connection storage.
GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT"
_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
def request_gatt_client() -> None:
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
claim one connection slot. Called by bluetooth_proxy once per connection
it instantiates on a hub platform."""
cg.add_define("USE_BLE_GATT_CLIENT")
_request_gatt_connection_slot()
_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
@@ -217,3 +323,25 @@ def as_hex_array(value: str) -> cg.RawExpression:
def as_reversed_hex_array(value: str) -> cg.RawExpression:
return _hex_array_expression(value, reverse=True)
def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None:
"""Emit the width-matched service-UUID setter for a consumer.
16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte
array (BLE wire order). Shared here so every sensor platform dispatches the
same way instead of carrying its own if/elif copy.
"""
if len(service_uuid) == len(BT_UUID16_FORMAT):
cg.add(var.set_service_uuid16(as_hex(service_uuid)))
elif len(service_uuid) == len(BT_UUID32_FORMAT):
cg.add(var.set_service_uuid32(as_hex(service_uuid)))
elif len(service_uuid) == len(BT_UUID128_FORMAT):
cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid)))
else:
# bt_uuid restricts lengths to exactly these three formats; if that
# ever loosens, fail the build instead of emitting no setter (a
# sensor whose match_by_ is unset silently never matches). ValueError,
# not cv.Invalid: this runs from to_code, after validation, where
# voluptuous errors surface as raw tracebacks.
raise ValueError(f"Unsupported UUID format: {service_uuid}")
@@ -1,11 +1,12 @@
// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses
// registered on a BLEHub, exposed by each tracker under its own automation
// names. parse_device()'s return feeds the "Found device" suppression.
// Constructors are templated on the hub type so this header also builds with
// no tracker present (host unit tests).
#pragma once
#include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
@@ -18,7 +19,7 @@ namespace esphome::ble_device_base {
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
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; }
@@ -39,7 +40,7 @@ class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public
// data for the given UUID. Optional single-MAC filter.
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
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_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.
class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
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_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).
class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener {
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; }
void on_scan_end() override { this->trigger(); }
@@ -0,0 +1,26 @@
#include "ble_client_state.h"
namespace esphome::ble_device_base {
const char *client_state_to_string(ClientState state) {
switch (state) {
case ClientState::INIT:
return "INIT";
case ClientState::DISCONNECTING:
return "DISCONNECTING";
case ClientState::IDLE:
return "IDLE";
case ClientState::DISCOVERED:
return "DISCOVERED";
case ClientState::CONNECTING:
return "CONNECTING";
case ClientState::CONNECTED:
return "CONNECTED";
case ClientState::ESTABLISHED:
return "ESTABLISHED";
default:
return "UNKNOWN";
}
}
} // namespace esphome::ble_device_base
@@ -0,0 +1,75 @@
// ble_client_state.h
//
// Platform-neutral GATT client connection state types, shared by every
// platform's GATT client implementation (esp32_ble_client, bluetooth_connection
// backends). Moved here from esp32_ble_tracker, which re-exports them under its
// own namespace for backward compatibility.
#pragma once
#include <cstdint>
namespace esphome::ble_device_base {
/// ESPHome-private errors for the API's plain-int error fields, outside the
/// ATT code range so they cannot be mistaken for spec errors. -1 is
/// understood by API clients as "not connected". Shared by every GATT
/// client backend.
static constexpr int GATT_ERR_NOT_CONNECTED = -1;
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
// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency
// 0). FAST covers connection setup and service discovery; MEDIUM is the
// steady state once established. Stack defaults (12.5-15 ms) are too slow for
// stable connections through WiFi-based BLE proxies, causing disconnections;
// MEDIUM balances responsiveness with bandwidth usage.
static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms
static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms
// The timeout value was increased from 6s to 8s to address stability issues observed
// in certain BLE devices when operating through WiFi-based BLE proxies. The longer
// timeout reduces the likelihood of disconnections during periods of high latency.
static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s
// Fastest connection parameters for devices with short discovery timeouts
static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum)
static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms
static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s
enum class ClientState : uint8_t {
// Connection is allocated
INIT,
// Client is disconnecting
DISCONNECTING,
// Connection is idle, no device detected.
IDLE,
// Device advertisement found.
DISCOVERED,
// Connection in progress.
CONNECTING,
// Initial connection established.
CONNECTED,
// The client and sub-clients have completed setup.
ESTABLISHED,
};
// Helper function to convert ClientState to string
const char *client_state_to_string(ClientState state);
enum class ConnectionType : uint8_t {
// The default connection type, we hold all the services in ram
// for the duration of the connection.
V1,
// The client has a cache of the services and mtu so we should not
// fetch them again
V3_WITH_CACHE,
// The client does not need the services and mtu once we send them
// so we should wipe them from memory as soon as we send them
V3_WITHOUT_CACHE
};
} // namespace esphome::ble_device_base
@@ -8,6 +8,7 @@
#include "ble_aes_ccm.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -265,16 +266,21 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) {
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) {
// iBeacon manufacturer specific data (after company-ID bytes have been stripped):
// [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes
// Parity with esp32_ble_tracker: gate on the Apple company ID and length only.
// (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior
// change; it belongs to a follow-up, not this refactor.)
if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
return {};
if (data.data.size() != 23)
return {};
// Require the iBeacon sub-type/length prefix — stricter than the legacy
// esp32 parser, which accepted any 23-byte Apple payload and surfaced
// non-iBeacon frames as garbage beacons.
if (data.data[0] != 0x02 || data.data[1] != 0x15) {
if (prefix_rejected != nullptr)
*prefix_rejected = true;
return {};
}
return ESPBLEiBeacon(data.data.data());
}
@@ -282,6 +288,44 @@ optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData
// ESPBTDevice
// ---------------------------------------------------------------------------
optional<ESPBLEiBeacon> ESPBTDevice::get_ibeacon() const {
bool prefix_rejected = false;
uint8_t rejected_sub_type = 0;
uint8_t rejected_len = 0;
for (const auto &it : this->manufacturer_datas_) {
bool rejected = false;
auto res = ESPBLEiBeacon::from_manufacturer_data(it, &rejected);
if (res.has_value())
return res;
if (rejected && !prefix_rejected) {
prefix_rejected = true;
rejected_sub_type = it.data[0];
rejected_len = it.data[1];
}
}
if (prefix_rejected) {
// Only when no beacon was found at all: these frames were accepted before
// the prefix check, so their disappearance must be observable at the
// default log level. Throttled so a chatty non-iBeacon Apple advertiser
// cannot flood the log; a different address may bypass the shared window
// so that advertiser cannot mask the device that actually regressed — but
// with a 1 s floor, or two alternating advertisers log every frame.
static uint32_t last_log = 0;
static uint64_t last_addr = 0;
const uint32_t now = millis();
const uint64_t addr = this->address_uint64();
const uint32_t since = now - last_log;
if (last_log == 0 || since > 60000 || (addr != last_addr && since > 1000)) {
last_log = now;
last_addr = addr;
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD(TAG, "%s: 23-byte Apple frame without iBeacon prefix ignored (sub-type 0x%02X len 0x%02X)",
this->address_str_to(addr_buf), rejected_sub_type, rejected_len);
}
}
return {};
}
const char *ESPBTDevice::address_type_str() const {
switch (this->address_type_) {
case BLE_ADDR_TYPE_PUBLIC:
@@ -305,7 +349,8 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
this->address_[i] = mac[5 - i];
this->address_type_ = addr_type;
this->rssi_ = rssi;
this->name_.clear();
this->name_len_ = 0;
this->name_[0] = '\0';
this->service_uuids_.clear();
this->manufacturer_datas_.clear();
this->service_datas_.clear();
@@ -321,7 +366,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
" Address: %s (%s)\n"
" RSSI: %d\n"
" Name: '%s'",
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str());
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_);
for (auto &it : this->tx_powers_) {
ESP_LOGVV(TAG, " TX Power: %d", it);
}
@@ -433,8 +478,12 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
// Keep the longest name seen — a merged adv + scan-response frame may carry both the
// shortened and the complete name, and the shortened form must never replace the
// complete one (same rule as esp32_ble_tracker's parse_adv_).
if (ad_data_len > this->name_.length())
this->name_.assign(reinterpret_cast<const char *>(ad_data), ad_data_len);
if (ad_data_len > this->name_len_) {
uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast<uint8_t>(ad_data_len);
memcpy(this->name_, ad_data, name_len);
this->name_[name_len] = '\0';
this->name_len_ = name_len;
}
break;
case 0x0A: // TX Power Level
+31 -17
View File
@@ -14,6 +14,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include <cstdint>
#include <cstring>
@@ -129,7 +130,12 @@ class ESPBLEiBeacon {
public:
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
explicit ESPBLEiBeacon(const uint8_t *data);
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data);
/// prefix_rejected: caller must initialise to false; set to true ONLY when a
/// 23-byte Apple frame was refused for lacking the 0x02/0x15 iBeacon prefix —
/// the case the legacy esp32 parser accepted. Never written on accept or on
/// the non-Apple/wrong-size rejects. The caller with the device address does
/// the logging (see ESPBTDevice::get_ibeacon()).
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr);
uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
@@ -148,12 +154,9 @@ class ESPBLEiBeacon {
};
/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks.
///
/// The result is the printable-order value esp32 has always sent
/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire.
/// This takes the raw controller order delivered by BLEHub's raw-advertisement
/// callback; ESPBTDevice::address_uint64() is the equivalent for an already
/// parsed device, whose address is stored MSB-first.
/// Trackers with LSB-native SDKs call this at the emit site before filling
/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent
/// for an already parsed device, whose address is stored MSB-first.
inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
@@ -161,6 +164,13 @@ inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
return addr;
}
/// Unpack a uint64 BLE address into printable (MSB-first) byte order —
/// the order bd_addr_t / esp_bd_addr_t style APIs expect.
inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) {
for (int i = 0; i < 6; i++)
out[i] = (address >> ((5 - i) * 8)) & 0xFF;
}
// ---------------------------------------------------------------------------
// ESPBTDevice — parsed BLE advertisement
// ---------------------------------------------------------------------------
@@ -193,6 +203,8 @@ class ESPBTDevice {
// Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t.
esp_ble_addr_type_t get_address_type() const { return static_cast<esp_ble_addr_type_t>(this->address_type_); }
/// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result.
/// Prefer ESPBTDevice::from_scan_result(); deprecation is a follow-up pending
/// consumer feedback on the raw scan-result fields.
void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
// Exposed through a function for use in lambdas
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
@@ -204,7 +216,9 @@ class ESPBTDevice {
const char *address_type_str() const;
int get_rssi() const { return rssi_; }
const std::string &get_name() const { return name_; }
/// Advertised name as a view into the fixed buffer (always NUL-terminated,
/// so c_str() is safe); converts implicitly to std::string where needed.
StringRef get_name() const { return StringRef(this->name_, this->name_len_); }
const std::vector<ESPBTUUID> &get_service_uuids() const { return service_uuids_; }
const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; }
@@ -218,22 +232,22 @@ class ESPBTDevice {
/// decryptor; compiled only when a sensor configures irk: (request_irk_support).
bool resolve_irk(const uint8_t *irk) const;
optional<ESPBLEiBeacon> get_ibeacon() const {
for (const auto &it : this->manufacturer_datas_) {
auto res = ESPBLEiBeacon::from_manufacturer_data(it);
if (res.has_value())
return res;
}
return {};
}
optional<ESPBLEiBeacon> get_ibeacon() const;
protected:
void parse_adv_(const uint8_t *payload, uint16_t len);
// Max name bytes in a legacy advertisement AD element (31-byte PDU minus
// the 2-byte element header); every in-tree tracker scans legacy PDUs only.
static constexpr uint8_t MAX_ADV_NAME_LEN = 29;
uint8_t address_[6]{0};
uint8_t address_type_{0};
int rssi_{0};
std::string name_{};
// Fixed buffer instead of std::string: no per-advertisement heap churn on
// the scan path, and no libstdc++ string/exception machinery in the image.
char name_[MAX_ADV_NAME_LEN + 1]{};
uint8_t name_len_{0};
std::vector<ESPBTUUID> service_uuids_{};
std::vector<ServiceData> manufacturer_datas_{};
std::vector<ServiceData> service_datas_{};
@@ -0,0 +1,194 @@
// ble_gatt_client.h
//
// Platform-neutral GATT client connection contract.
//
// Exactly one GATT backend exists per build, so BLEGattConnection is a
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
// interface.
// A consumer - a streaming consumer that forwards the raw database (the hub
// 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
// valid only for the duration of the call.
//
// Error domain (plain int, forwarded to the API without translation):
// 0 success
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
// ESP_FAIL from the stack shares this value; both read as a
// failed, unusable connection on the client side)
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
// anything else: platform stack error/status code, surfaced opaquely.
// Connection events carry HCI status/disconnect reason codes (same code
// space on every controller).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "ble_client_state.h"
#include "ble_device.h"
#include <concepts>
#include <cstdint>
namespace esphome::ble_device_base {
// Materialized GATT database of a connected peer, discovered by the backend
// and streamed to the API by the consumer. Flat arrays with index ranges
// (not pointers): a service owns characteristics
// [first_characteristic, first_characteristic + characteristic_count) and a
// characteristic owns descriptors [first_descriptor, ...) — discovery is
// depth-first, so the ranges are naturally contiguous.
struct GattDescriptor {
ESPBTUUID uuid;
uint16_t handle;
};
struct GattCharacteristic {
ESPBTUUID uuid;
uint16_t value_handle;
// Needed to rebuild the stack's characteristic object for CCCD operations.
uint16_t end_handle;
uint8_t properties; // Bluetooth spec property bitfield
uint16_t first_descriptor;
uint16_t descriptor_count;
};
struct GattService {
ESPBTUUID uuid;
uint16_t start_handle;
uint16_t end_handle;
uint16_t first_characteristic;
uint16_t characteristic_count;
};
/// Borrowed view of the backend-owned service table. Valid from a successful
/// on_service_discovery_done() until release_services(). Characteristics and
/// descriptors are reached through the per-service/per-characteristic index
/// ranges; the array totals let a consumer bounds-check those ranges instead
/// of trusting the backend's discovery bookkeeping blindly.
struct GattServiceTable {
const GattService *services{nullptr};
const GattCharacteristic *characteristics{nullptr};
const GattDescriptor *descriptors{nullptr};
uint16_t service_count{0};
uint16_t characteristic_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
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
// through the listener) or a synchronous error (busy, not connected, stack
// rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// - disconnect: also cancels a connect in progress.
// - notify_characteristic: local registration only; the CCCD write is the
// API client's responsibility (a plain write_descriptor).
// - get_service_table/release_services: backend-owned transient storage,
// released after streaming (release is idempotent). 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,
// discover_services in on_service_discovery_done, pair in
// on_pairing_result, reads in on_read_result, notify_characteristic in
// on_notify_state, characteristic writes with response and descriptor
// writes in on_write_result.
template<typename T>
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
{ conn.read_descriptor(uint16_t{}) } -> std::same_as<int>;
{ conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as<int>;
{ conn.notify_characteristic(uint16_t{}, true) } -> std::same_as<int>;
{ conn.pair() } -> 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.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>;
};
// ---- service table lookup helpers ----
//
// Neutral, bounds-checked walks over a materialized GattServiceTable for
// direct consumers that resolve a known device's handles by UUID (streaming
// consumers forward the raw database and never need these). Linear search:
// the table exists only between discovery and release_services(), for one
// small known device.
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
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
#endif // USE_BLE_GATT_CLIENT
+63 -41
View File
@@ -1,13 +1,10 @@
// ble_hub.h
//
// BLEHub — the platform-neutral BLE tracker contract.
//
// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker,
// ln882h_ble_tracker, future chips) implements this interface; every BLE
// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via
// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares.
// Adding a new BLE chip therefore requires only a new tracker component that
// implements BLEHub: no consumer, registry, or base changes.
// The platform-neutral BLE tracker contract: shared types plus the method
// surface every tracker provides (documented below). Exactly one tracker
// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not
// an abstract interface — no vtable, every hub call inlinable. Consumers
// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub).
//
// Chip differences are expressed as data (HubCapabilities), never as
// platform conditionals in consumers.
@@ -15,7 +12,9 @@
#pragma once
#include "ble_device.h"
#include "esphome/core/defines.h"
#include <concepts>
#include <cstdint>
namespace esphome::ble_device_base {
@@ -23,8 +22,9 @@ namespace esphome::ble_device_base {
/// One raw advertisement as delivered by the controller — a borrowed view,
/// valid only for the duration of the invoke() callback.
struct RawAdvertisement {
/// Least-significant octet first (BLE controller convention).
const uint8_t *mac;
/// Producers convert their native byte order at the emit site, so no
/// byte-order convention crosses this contract.
uint64_t address;
const uint8_t *data;
uint16_t data_len;
int8_t rssi; // signed dBm
@@ -48,6 +48,27 @@ struct RawAdvertisementCallback {
void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); }
};
/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast
/// directly (pinned by static_asserts at the cast sites).
enum class ScannerState : uint8_t {
IDLE = 0,
STARTING = 1,
RUNNING = 2,
FAILED = 3,
STOPPING = 4,
STOPPED = 5,
};
/// Subscriber slot for scanner-state transitions; same shape as
/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs
/// that push provide the setter; consumers of the rest poll scan_running().
struct ScannerStateCallback {
void *instance{nullptr};
void (*fn)(void *instance, ScannerState state){nullptr};
bool is_set() const { return this->fn != nullptr; }
void invoke(ScannerState state) const { this->fn(this->instance, state); }
};
/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
struct HubCapabilities {
/// Controller can send scan requests (active scanning).
@@ -56,8 +77,10 @@ struct HubCapabilities {
/// frame. When false, consumers relying on scan-response fields (e.g. names)
/// may only see them where the receiver merges per address (Home Assistant does).
bool merges_scan_response;
/// GATT client connections are available (today: esp32 only, but a chip SDK
/// gaining GATT support only has to flip this bit).
/// GATT client connections are available: the platform has a
/// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in
/// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client).
/// Today: esp32 and rp2.
bool gatt;
/// request_scan_mode() is honored at runtime. Distinct from active_scan:
/// a passive-only controller (bk72xx) can never switch, and a hub may
@@ -66,35 +89,34 @@ struct HubCapabilities {
bool scan_mode_switch;
};
class BLEHub {
public:
virtual ~BLEHub() = default;
/// Register a parsed-advertisement consumer (BLE sensors, automation triggers).
virtual void register_listener(ESPBTDeviceListener *listener) = 0;
/// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time.
virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0;
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; }
// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias.
// Semantics beyond the signatures:
// - register_listener: parsed-advertisement consumers (sensors, triggers).
// - set_raw_advertisement_callback: raw stream, one consumer at a time.
// - get_adapter_mac: printable order, out[0] = MSB.
// - scan_active: the current/configured mode sends scan requests.
// - request_scan_mode: false = cannot honor, state untouched (the caller
// reports the real state back); true = applied immediately, restarting a
// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch.
// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback)
// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept
// requires both exactly when that define is set. A push hub must emit a
// transition for every accepted or refused mode request - consumers skip
// their own mode report on push builds.
template<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
hub.set_scanner_state_callback(ScannerStateCallback{});
{ hub.get_scanner_state() } -> std::same_as<ScannerState>;
#endif
};
} // 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,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, esp32_ble_tracker
from esphome.components import binary_sensor, ble_device_base
import esphome.config_validation as cv
from esphome.const import (
CONF_IBEACON_MAJOR,
@@ -13,14 +13,14 @@ from esphome.const import (
CONF_IRK = "irk"
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
ble_presence_ns = cg.esphome_ns.namespace("ble_presence")
BLEPresenceDevice = ble_presence_ns.class_(
"BLEPresenceDevice",
binary_sensor.BinarySensor,
cg.Component,
esp32_ble_tracker.ESPBTDeviceListener,
ble_device_base.ESPBTDeviceListener,
)
@@ -33,23 +33,24 @@ def _validate(config):
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_presence"),
binary_sensor.binary_sensor_schema(BLEPresenceDevice)
.extend(
{
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_IRK): cv.uuid,
cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_TIMEOUT, default="5min"): cv.positive_time_period,
cv.Optional(CONF_MIN_RSSI): cv.All(
cv.decibel, cv.int_range(min=-100, max=-30)
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
),
@@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds))
if min_rssi := config.get(CONF_MIN_RSSI):
@@ -70,20 +71,15 @@ async def to_code(config):
cg.add(var.set_address(mac_address.as_hex))
if irk := config.get(CONF_IRK):
irk = esp32_ble_tracker.as_hex_array(str(irk))
ble_device_base.request_irk_support()
irk = ble_device_base.as_hex_array(str(irk))
cg.add(var.set_irk(irk))
if service_uuid := config.get(CONF_SERVICE_UUID):
if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid)
cg.add(var.set_service_uuid128(uuid128))
ble_device_base.add_service_uuid(var, service_uuid)
if ibeacon_uuid := config.get(CONF_IBEACON_UUID):
ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid)
ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid)
cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
@@ -1,8 +1,6 @@
#include "ble_presence_device.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_presence {
static const char *const TAG = "ble_presence";
@@ -10,5 +8,3 @@ static const char *const TAG = "ble_presence";
void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); }
} // namespace esphome::ble_presence
#endif
@@ -1,15 +1,17 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#ifdef USE_ESP32
// No platform #ifdef: ble_device_base provides the BLE types on every platform,
// and this component is only compiled when configured — which requires a BLE
// hub — so it builds on any platform with a BLEHub tracker without a per-chip
// guard.
namespace esphome::ble_presence {
class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
public esp32_ble_tracker::ESPBTDeviceListener,
public ble_device_base::ESPBTDeviceListener,
public Component {
public:
void set_address(uint64_t address) {
@@ -22,19 +24,19 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
}
void set_service_uuid16(uint16_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid);
this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid);
}
void set_service_uuid32(uint32_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid);
this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid);
}
void set_service_uuid128(uint8_t *uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_uuid(uint8_t *uuid) {
this->match_by_ = MATCH_BY_IBEACON_UUID;
this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_major(uint16_t major) {
this->check_ibeacon_major_ = true;
@@ -49,7 +51,7 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
this->minimum_rssi_ = rssi;
}
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) {
return false;
}
@@ -119,9 +121,9 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
uint64_t address_;
uint8_t *irk_;
esp32_ble_tracker::ESPBTUUID uuid_;
ble_device_base::ESPBTUUID uuid_;
esp32_ble_tracker::ESPBTUUID ibeacon_uuid_;
ble_device_base::ESPBTUUID ibeacon_uuid_;
uint16_t ibeacon_major_{0};
uint16_t ibeacon_minor_{0};
@@ -137,5 +139,3 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
};
} // namespace esphome::ble_presence
#endif
@@ -1,8 +1,6 @@
#include "ble_rssi_sensor.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_rssi {
static const char *const TAG = "ble_rssi";
@@ -10,5 +8,3 @@ static const char *const TAG = "ble_rssi";
void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); }
} // namespace esphome::ble_rssi
#endif
+13 -13
View File
@@ -1,14 +1,16 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/sensor/sensor.h"
#ifdef USE_ESP32
// No platform #ifdef: ble_device_base provides the BLE types on every platform,
// and this component is only compiled when configured — which requires a BLE
// hub — so it builds on any platform with a BLEHub tracker without a per-chip
// guard.
namespace esphome::ble_rssi {
class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component {
class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBTDeviceListener, public Component {
public:
void set_address(uint64_t address) {
this->match_by_ = MATCH_BY_MAC_ADDRESS;
@@ -20,19 +22,19 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP
}
void set_service_uuid16(uint16_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid);
this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid);
}
void set_service_uuid32(uint32_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid);
this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid);
}
void set_service_uuid128(uint8_t *uuid) {
this->match_by_ = MATCH_BY_SERVICE_UUID;
this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_uuid(uint8_t *uuid) {
this->match_by_ = MATCH_BY_IBEACON_UUID;
this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid);
this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid);
}
void set_ibeacon_major(uint16_t major) {
this->check_ibeacon_major_ = true;
@@ -47,7 +49,7 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP
this->publish_state(NAN);
this->found_ = false;
}
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
switch (this->match_by_) {
case MATCH_BY_MAC_ADDRESS:
if (device.address_uint64() == this->address_) {
@@ -109,9 +111,9 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP
uint64_t address_;
uint8_t *irk_;
esp32_ble_tracker::ESPBTUUID uuid_;
ble_device_base::ESPBTUUID uuid_;
esp32_ble_tracker::ESPBTUUID ibeacon_uuid_;
ble_device_base::ESPBTUUID ibeacon_uuid_;
uint16_t ibeacon_major_;
uint16_t ibeacon_minor_;
@@ -120,5 +122,3 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP
};
} // namespace esphome::ble_rssi
#endif
+13 -17
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, sensor
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_IBEACON_MAJOR,
@@ -14,11 +14,11 @@ from esphome.const import (
CONF_IRK = "irk"
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi")
BLERSSISensor = ble_rssi_ns.class_(
"BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener
"BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener
)
@@ -31,6 +31,7 @@ def _validate(config):
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_rssi"),
sensor.sensor_schema(
BLERSSISensor,
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
@@ -42,14 +43,14 @@ CONFIG_SCHEMA = cv.All(
{
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_IRK): cv.uuid,
cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid,
cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid,
cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid,
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
),
@@ -60,26 +61,21 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
if mac_address := config.get(CONF_MAC_ADDRESS):
cg.add(var.set_address(mac_address.as_hex))
if irk := config.get(CONF_IRK):
irk = esp32_ble_tracker.as_hex_array(str(irk))
ble_device_base.request_irk_support()
irk = ble_device_base.as_hex_array(str(irk))
cg.add(var.set_irk(irk))
if service_uuid := config.get(CONF_SERVICE_UUID):
if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid)))
elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid)
cg.add(var.set_service_uuid128(uuid128))
ble_device_base.add_service_uuid(var, service_uuid)
if ibeacon_uuid := config.get(CONF_IBEACON_UUID):
ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid)
ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid)
cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
@@ -1,8 +1,6 @@
#include "ble_scanner.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_scanner {
static const char *const TAG = "ble_scanner";
@@ -10,5 +8,3 @@ static const char *const TAG = "ble_scanner";
void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); }
} // namespace esphome::ble_scanner
#endif
+8 -10
View File
@@ -7,23 +7,23 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/text_sensor/text_sensor.h"
#ifdef USE_ESP32
// No platform #ifdef: ble_device_base provides the BLE types on every platform,
// and this component is only compiled when configured — which requires a BLE
// hub — so it builds on any platform with a BLEHub tracker without a per-chip
// guard.
namespace esphome::ble_scanner {
class BLEScanner final : public text_sensor::TextSensor,
public esp32_ble_tracker::ESPBTDeviceListener,
public Component {
class BLEScanner final : public text_sensor::TextSensor, public ble_device_base::ESPBTDeviceListener, public Component {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
bool parse_device(const ble_device_base::ESPBTDevice &device) override {
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this
// sensor has always published.
char escaped_name[128];
json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false);
json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false);
char buf[256];
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
@@ -35,5 +35,3 @@ class BLEScanner final : public text_sensor::TextSensor,
};
} // namespace esphome::ble_scanner
#endif
@@ -1,25 +1,26 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, text_sensor
from esphome.components import ble_device_base, text_sensor
import esphome.config_validation as cv
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner")
BLEScanner = ble_scanner_ns.class_(
"BLEScanner",
text_sensor.TextSensor,
cg.Component,
esp32_ble_tracker.ESPBTDeviceListener,
ble_device_base.ESPBTDeviceListener,
)
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_scanner"),
text_sensor.text_sensor_schema(BLEScanner)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
@@ -0,0 +1,270 @@
"""Per-platform GATT connection backends and the helpers to embed one.
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
consumer's codegen declares and registers the backend instances — the
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().
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.config_helpers import filter_source_files_from_platform
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.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
DOMAIN = "bluetooth_connection"
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with, so consumers stay platform-blind. The platform-less arm
serves tooling that resolves the manifest without a target."""
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"]
CODEOWNERS = ["@bdraco", "@jesserockz"]
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
# raising this needs an upstream change (the layer itself supports N).
RP2_MAX_CONNECTIONS = 1
# Hub platforms with a GATT backend, mapped to their slot limit — the single
# registry of which hub platforms run the connection-capable proxy.
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
# The hub-platform wrapper and the backend codegen classes.
HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection")
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}
def _esp32_schema_fragment() -> cv.Schema:
from esphome.components import esp32_ble_tracker
return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA
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(
{
"bluetooth_connection_bluedroid.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
# Every hub platform the proxy admits (the file compiles empty where
# USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend
# cannot hit a missing-symbol trap here.
"bluetooth_connection_hub.cpp": {
PlatformFramework.RP2_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
}
)
@@ -0,0 +1,69 @@
#include "bluetooth_connection.h"
#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/core/log.h"
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection";
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str) {
// Calculate the actual size of just this service (+1 for the field tag)
size_t service_size = resp.services.back().calculate_size() + 1;
if (current_size + service_size > MAX_PACKET_SIZE) {
if (resp.services.size() > 1) {
// We would go over -- pop the last service and retry it in the next batch
resp.services.pop_back();
ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch",
connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size,
(unsigned) MAX_PACKET_SIZE);
// Don't advance send_service -- the popped service goes into the next batch
} else {
// This single service is too large, but we have to send it anyway;
// advance so we don't get stuck
ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str,
send_service, (unsigned) service_size);
send_service++;
}
return BatchClose::SEND;
}
current_size += service_size;
send_service++;
return BatchClose::CONTINUE;
}
} // namespace esphome::bluetooth_connection
#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
@@ -0,0 +1,158 @@
// Shared types and helpers for the per-platform GATT connection backends and
// the Bluetooth proxy that drives them.
#pragma once
#include "esphome/core/defines.h"
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include <array>
#include <cstddef>
#include <cstdint>
#ifdef USE_ESP32
#include <esp_err.h>
#endif
// The proxy-serving surface is compiled: a proxy is present and a GATT
// backend is wired by codegen (one slot per connection). This is the single
// spelling of that predicate - the hub wrapper, the connection-aware API
// request handlers, and the Bluedroid in-place streamer all gate on it.
// 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
namespace esphome::api {
class BluetoothGATTGetServicesResponse;
} // namespace esphome::api
namespace esphome::bluetooth_connection {
// Connection-owned error type for the API error fields, which are plain
// integers on the wire. Aliases esp_err_t on esp32 (where the values come from
// IDF calls); a bare int elsewhere. Owning the name instead of probing for
// esp_err_t keeps the header independent of how a platform's SDK spells its
// error type.
#ifdef USE_ESP32
using conn_err_t = esp_err_t;
static constexpr conn_err_t CONN_OK = ESP_OK;
#else
using conn_err_t = int;
static constexpr conn_err_t CONN_OK = 0;
#endif
// The ESPHome-private "not connected" wire value, shared with the neutral
// GATT contract so backend and wrapper cannot drift.
static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
// What the platform's connection backend supports beyond GATT operations;
// the proxy derives its feature flags and legacy version from these.
#if defined(USE_ESP32)
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = true;
#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
// The rp2 BTstack backend pairs (just works + bonding); it has no service
// cache to clear. Keyed on the backend, not the generic client define, so a
// future backend without pairing keeps the stub arm below.
static constexpr bool SUPPORTS_PAIRING = true;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#else
static constexpr bool SUPPORTS_PAIRING = false;
static constexpr bool SUPPORTS_CACHE_CLEARING = false;
#endif
// Address-scoped (not connection-scoped) maintenance requests.
#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT))
conn_err_t unpair_device(uint64_t address);
#else
inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; }
#endif
#ifdef USE_ESP32
conn_err_t clear_gatt_cache(uint64_t address);
#else
inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; }
#endif
// send_service_ cursor states; >= 0 is the next service index to stream.
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
// ---- Service-streaming size budget, shared by every platform's streamer ----
// Conservative MTU limit for API messages (accounts for WPA3 overhead)
static constexpr size_t MAX_PACKET_SIZE = 1360;
// Constants for size estimation
static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4)
static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7)
static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic
/// Estimate the wire size of a service (service overhead + its characteristics,
/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be
/// safe) before fetching/packing the full data.
inline size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) {
size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY;
return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count;
}
// ---- UUID wire packing, shared by every platform's streamer ----
// This function is allocation-free and directly packs UUIDs into the output
// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID
// stores its 128-bit form little-endian (same as Bluedroid).
inline void fill_128bit_uuid_array(std::array<uint64_t, 2> &out, const ble_device_base::ESPBTUUID &uuid) {
using ble_device_base::ESPBTUUID;
if (uuid.type() == ESPBTUUID::Type::UUID128) {
const uint8_t *u = uuid.uuid128();
// out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian)
out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) |
((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]);
out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) |
((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]);
return;
}
// 16/32-bit UUID inserted into the Bluetooth base UUID:
// 00000000-0000-1000-8000-00805F9B34FB
uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32();
out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11
out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7
}
/// Fill the UUID in the appropriate wire format based on client support and
/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form
/// otherwise).
inline void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uuid,
const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) {
using ble_device_base::ESPBTUUID;
if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) {
fill_128bit_uuid_array(uuid_128, uuid);
} else if (uuid.type() == ESPBTUUID::Type::UUID16) {
short_uuid = uuid.uuid16();
} else {
short_uuid = uuid.uuid32();
}
}
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// Result of close_service_batch: keep filling the batch or send it now.
/// An oversized service is packed alone; a failed (backpressured) send is
/// retried from the batch start, so no service is silently skipped.
enum class BatchClose : uint8_t { CONTINUE, SEND };
/// Close out the service just packed into resp (account its actual wire size,
/// advance the cursor) and decide whether the batch must be sent now. Shared
/// tail of both platform streamers so the budget logic and its log lines
/// cannot drift.
BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str);
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
} // 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
@@ -0,0 +1,66 @@
// bluetooth_connection_gatt_backend.h
//
// Binds ble_device_base::BLEGattConnection to the build's one GATT backend.
// Backend and consumer both live in this component, so the ladder does too;
// backends implement ble_gatt_client.h (the neutral contract).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#if defined(USE_RP2040_BLE)
#include "bluetooth_connection_rp2.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
#elif defined(USE_ESP32_BLE)
#include "bluetooth_connection_bluedroid.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
// Emitted only by the host unit-test manifest: the tests compile the hub
// wrapper standalone, so bind a do-nothing backend. Every other backend-less
// build hits the #error below.
namespace esphome::bluetooth_connection {
class StubGattBackend {
public:
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 disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
ble_device_base::GattServiceTable get_service_table() { return {}; }
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services() {}
};
} // namespace esphome::bluetooth_connection
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend
#else
#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here"
#endif
namespace esphome::ble_device_base {
using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE;
static_assert(BLEGattConnectionContract<BLEGattConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -0,0 +1,439 @@
// The proxy's per-slot connection wrapper, shared by every platform.
#include "bluetooth_connection_hub.h"
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection";
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);
this->address_ = address;
if (address == 0) {
this->address_str_[0] = '\0';
return;
}
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BluetoothConnection::start_connect_() {
// No connect timeout here: the API client's own timeout or
// the api-gone sweep drives disconnect().
this->state_ = ClientState::CONNECTING;
int err = this->backend_->connect(this->address_, this->remote_addr_type_);
if (err != 0) {
ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err);
this->reset_connection_(err);
}
}
void BluetoothConnection::disconnect() {
// Idempotent: the proxy's teardown loop calls this
// 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).
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
return;
}
int err = this->backend_->disconnect();
if (err == GATT_NOT_CONNECTED) {
// Backend already idle: free the slot so the client is not stuck.
ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_);
this->reset_connection_(err);
return;
}
if (err != 0) {
// Transient refusal: stay DISCONNECTING and let the safety timeout
// arbitrate rather than freeing a slot whose teardown is unresolved.
// Latch the refusal unless a GATT cause is already recorded (first wins).
ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
if (this->pending_error_ == 0) {
this->pending_error_ = err;
}
}
this->state_ = ClientState::DISCONNECTING;
this->disconnecting_started_ = millis();
}
void BluetoothConnection::check_disconnect_timeout_() {
// Safety net: if the backend's disconnect completion is lost (or a refusal
// left the teardown unresolved), force the slot free instead of leaking it.
// The caller already gates on DISCONNECTING.
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_);
this->reset_connection_(GATT_NOT_CONNECTED);
}
}
void BluetoothConnection::on_pairing_result(int status) {
if (this->address_ == 0) {
// A drop before completion already answered: reset_connection_slot_ sends
// the connection response, which the client's pair watcher raises on.
return;
}
this->paired_ = status == 0;
this->proxy_->send_device_pairing(this->address_, status == 0, status);
}
void BluetoothConnection::reset_connection_(conn_err_t reason) {
if (this->pending_error_ != 0) {
reason = this->pending_error_;
this->pending_error_ = 0;
}
this->state_ = ClientState::IDLE;
this->services_discovered_ = false;
this->paired_ = false;
this->backend_->release_services();
this->proxy_->reset_connection_slot_(this, reason);
}
// ---- backend event listener ----
void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected && this->address_ == 0) {
// Late completion for a slot that was already freed: nothing to report,
// and the api-gone sweep or a new reservation owns the slot now.
int err = this->backend_->disconnect();
if (err != 0 && err != GATT_NOT_CONNECTED) {
// Log only: re-arming a freed slot could clobber a new reservation.
ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err);
}
return;
}
if (connected && this->state_ == ClientState::DISCONNECTING) {
// The link came up after a disconnect request won the race; finish the
// teardown instead of reporting a connection the client no longer wants.
int err = this->backend_->disconnect();
// Fresh teardown attempt: give it the full safety window.
this->disconnecting_started_ = millis();
if (err == GATT_NOT_CONNECTED) {
// Nothing left to tear down after all.
this->reset_connection_(err);
} else if (err != 0) {
// Transient refusal while the link is up: keep DISCONNECTING and let
// the safety timeout arbitrate (same policy as disconnect()).
ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err);
}
return;
}
if (connected) {
this->mtu_ = mtu;
if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) {
// The API client has the services cached; never discover them. No
// discovery phase needs the fast interval, so settle straight into the
// 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;
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_CONN_TIMEOUT);
if (param_err != 0) {
// Survivable: the link just stays on the fast interval.
ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_,
param_err);
}
this->proxy_->send_device_connection(this->address_, true, mtu);
this->proxy_->send_connections_free();
return;
}
// V3_WITHOUT_CACHE: discover services first — the connected response is
// sent when discovery completes, mirroring the esp32 flow (MTU + services
// before the response).
this->state_ = ClientState::CONNECTED;
int err = this->backend_->discover_services();
if (err != 0) {
ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err);
// Latch the real cause for the disconnect report.
this->pending_error_ = err;
this->disconnect();
}
return;
}
// Disconnected, connect failed, or teardown complete
if (this->address_ == 0) {
return; // Slot already freed
}
ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
error);
this->reset_connection_(error);
}
void BluetoothConnection::on_service_discovery_done(int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error);
// Carry the GATT error into the disconnection report so the client sees
// the real cause instead of a generic HCI reason.
this->pending_error_ = error;
this->disconnect();
return;
}
ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_,
this->mtu_);
this->state_ = ClientState::ESTABLISHED;
this->services_discovered_ = true;
this->proxy_->send_device_connection(this->address_, true, this->mtu_);
this->proxy_->send_connections_free();
}
void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) {
ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_,
operation, handle, status);
}
void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
// Late completion for a freed slot; nothing to report.
if (this->address_ == 0)
return;
if (error != 0) {
this->log_gatt_operation_error_("reading char/descriptor", handle, error);
this->proxy_->send_gatt_error(this->address_, handle, error);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTReadResponse resp;
resp.address = this->address_;
resp.handle = handle;
resp.set_data(data, len);
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_);
}
}
void BluetoothConnection::on_write_result(uint16_t handle, int error) {
if (this->address_ == 0)
return;
if (error != 0) {
this->log_gatt_operation_error_("writing char/descriptor", handle, error);
this->proxy_->send_gatt_error(this->address_, handle, error);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTWriteResponse resp;
resp.address = this->address_;
resp.handle = handle;
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_);
}
}
void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) {
if (this->address_ == 0)
return;
if (error != 0) {
this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle,
error);
this->proxy_->send_gatt_error(this->address_, handle, error);
return;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = handle;
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_);
}
}
void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->address_ == 0)
return;
ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle);
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
return;
api::BluetoothGATTNotifyDataResponse resp;
resp.address = this->address_;
resp.handle = handle;
resp.set_data(data, len);
if (!api_connection->send_message(resp)) {
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_);
}
}
// ---- GATT operations ----
conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const {
if (this->connected()) {
return CONN_OK;
}
ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action,
type);
return GATT_NOT_CONNECTED;
}
conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) {
if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
return this->backend_->read_characteristic(handle);
}
conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length,
bool response) {
if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle);
return this->backend_->write_characteristic(handle, data, static_cast<uint16_t>(length), response);
}
conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) {
if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
return this->backend_->read_descriptor(handle);
}
// The neutral backend contract performs descriptor writes acknowledged, so
// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP).
conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length,
bool /*response*/) {
if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle);
return this->backend_->write_descriptor(handle, data, static_cast<uint16_t>(length));
}
conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) {
if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK)
return err;
ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_,
enable ? "Registering for" : "Unregistering for", handle);
return this->backend_->notify_characteristic(handle, enable);
}
conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) {
if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK)
return err;
return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout);
}
// ---- Service streaming ----
void BluetoothConnection::send_service_for_discovery_() {
auto table = this->backend_->get_service_table();
if (this->send_service_ >= table.service_count) {
this->send_service_ = DONE_SENDING_SERVICES;
this->proxy_->send_gatt_services_done(this->address_);
this->backend_->release_services();
return;
}
// The subscriber vanished mid-stream: park the cursor at done WITHOUT
// sending services-done (esp32 parity — a resubscribing client gets
// silence and its 30 s timeout, never an authoritative partial list) and
// free the table; the api-gone sweep tears the connection down anyway.
auto *api_conn = this->proxy_->get_api_connection();
if (api_conn == nullptr) {
ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_,
this->address_str_);
this->send_service_ = DONE_SENDING_SERVICES;
this->backend_->release_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, same contract as the esp32 streamer
size_t current_size = resp.calculate_size();
int16_t batch_start = this->send_service_;
while (this->send_service_ < table.service_count) {
const auto &service = table.services[this->send_service_];
// If this service likely won't fit, send current batch (unless it's the first)
size_t estimated_size = estimate_service_size(service.characteristic_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, service.uuid, use_efficient_uuids);
service_resp.handle = service.start_handle;
// Bounds-check the backend's index ranges against the table totals rather
// than trusting its discovery bookkeeping blindly. A miscounted non-empty
// range must not stream a truncated database as authoritative (V3 clients
// cache it permanently): abort and tear the connection down; the client
// times out and retries. Empty ranges are tolerated regardless of index.
uint16_t char_count = service.characteristic_count;
if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) {
ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream",
this->connection_index_, this->address_str_, this->send_service_);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
return;
}
if (char_count > 0) {
service_resp.characteristics.init(char_count);
for (uint16_t ci = 0; ci < char_count; ci++) {
const auto &chr = table.characteristics[service.first_characteristic + ci];
service_resp.characteristics.emplace_back();
auto &characteristic_resp = service_resp.characteristics.back();
fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids);
characteristic_resp.handle = chr.value_handle;
characteristic_resp.properties = chr.properties;
uint16_t desc_count = chr.descriptor_count;
if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) {
ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream",
this->connection_index_, this->address_str_, this->send_service_);
this->send_service_ = DONE_SENDING_SERVICES;
this->disconnect();
return;
}
if (desc_count == 0) {
continue;
}
characteristic_resp.descriptors.init(desc_count);
for (uint16_t di = 0; di < desc_count; di++) {
const auto &desc = table.descriptors[chr.first_descriptor + di];
characteristic_resp.descriptors.emplace_back();
auto &descriptor_resp = characteristic_resp.descriptors.back();
fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids);
descriptor_resp.handle = desc.handle;
}
}
}
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
// (bounded: a subscriber that stays gone ends streaming via the api-lost
// rewind above).
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;
}
}
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -0,0 +1,150 @@
// BluetoothConnection: drives the build's GATT backend (the
// ble_device_base::BLEGattConnection alias) and translates its events into
// the proxy's API messages. One wrapper for every platform; per-backend
// differences live behind the alias and the streamer cut-through.
#pragma once
#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 "bluetooth_connection_gatt_backend.h"
#include "esphome/core/helpers.h"
namespace esphome::bluetooth_proxy {
class BluetoothProxy;
} // namespace esphome::bluetooth_proxy
namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
class BluetoothConnection final : public ble_device_base::GattClientListener {
public:
/// Wire the platform backend. Called from codegen before setup.
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
}
// ---- proxy dispatch surface ----
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 read_descriptor(uint16_t handle);
conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response);
conn_err_t notify_characteristic(uint16_t handle, bool enable);
conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
/// Start connecting: record the API address type (BLE_ADDR_TYPE_* code
/// space) and open the connection through the backend. Failures report
/// through the same reset path a failed open takes on esp32.
void initiate_connection(uint8_t address_type) {
this->remote_addr_type_ = address_type;
this->start_connect_();
}
void disconnect();
bool is_paired() const { return this->paired_; }
void set_unpaired() { this->paired_ = false; }
conn_err_t pair() { return this->backend_->pair(); }
void set_address(uint64_t address);
uint64_t get_address() const { return this->address_; }
const char *address_str() const { return this->address_str_; }
uint8_t get_connection_index() const { return this->connection_index_; }
ClientState state() const { return this->state_; }
void set_state(ClientState st) { this->state_ = st; }
bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
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:
// streaming frees the table, and this must stay true for the connection's
// lifetime (a repeat GetServices is silently ignored, never answered with
// an authoritative empty database).
bool has_gatt_services() const { return this->services_discovered_; }
/// Stream any pending service-discovery batch and police the disconnect
/// safety timeout. Called from the proxy's loop — the wrapper has no
/// Component loop of its own.
void process_pending_services() {
if (this->send_service_ >= 0) {
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_();
}
}
// ---- backend event listener (called directly by the backend, main loop) ----
void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
void on_write_result(uint16_t handle, int error) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_pairing_result(int status) override;
protected:
friend class bluetooth_proxy::BluetoothProxy;
// The Bluedroid backend streams services in place from its stack cache.
friend class BluedroidGattClient;
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 check_disconnect_timeout_();
void reset_connection_(conn_err_t reason);
conn_err_t check_connected_op_(const char *action, const char *type) const;
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
bluetooth_proxy::BluetoothProxy *proxy_{nullptr};
ble_device_base::BLEGattConnection *backend_{nullptr};
// Group 2: 2-byte types
int16_t send_service_{INIT_SENDING_SERVICES};
uint16_t mtu_{23};
// Group 3: 8-byte and 4-byte types
uint64_t address_{0};
uint32_t disconnecting_started_{0};
conn_err_t pending_error_{0};
// Group 4: Arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Group 5: 1-byte types
ClientState state_{ClientState::IDLE};
bool paired_{false};
ConnectionType connection_type_{ConnectionType::V1};
uint8_t remote_addr_type_{0};
uint8_t connection_index_{0};
bool services_discovered_{false};
};
} // namespace esphome::bluetooth_connection
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack.
//
// The build's ble_device_base::BLEGattConnection backend (bound by alias in
// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the
// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy
// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call
// issued from the main loop is wrapped in BluetoothLock.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/rp2040_ble/rp2040_ble.h"
#include "esphome/core/component.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#include <btstack.h>
#include <array>
#include <cstdint>
namespace esphome::bluetooth_connection {
// Caps for the transient service table. Sized generously for real devices
// (typical peripherals expose < 8 services / < 30 characteristics); a peer
// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than
// streaming an incomplete database a V3 client would cache permanently.
static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16;
static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96;
static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96;
// Concurrent notify subscriptions per connection (enable fails with
// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful).
static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16;
// ATT spec maximum attribute value length; bounds the op buffer and
// notification payloads.
static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512;
// Control events from the BTstack handlers to loop().
struct RP2GattEvent {
enum Type : uint8_t {
CONNECTED, // status + con_handle (value)
DISCONNECTED, // status = HCI reason
MTU_EXCHANGED, // value = negotiated MTU
QUERY_COMPLETE, // status = ATT status of the finished query
WRITE_NO_RSP_DONE, // status = result of the deferred write
PAIRING_RESULT, // status = SM pairing status (0 = bonded)
};
Type type;
uint8_t status;
uint16_t value;
void release() {}
};
// One notification/indication from the peer.
struct RP2GattNotifyEvent {
uint16_t handle;
uint16_t len;
uint8_t data[RP2_GATT_MAX_ATTR_LEN];
void release() {}
};
static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
// Depth 4: the queue is drained every main-loop iteration and each slot is a
// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot.
static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4;
class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040BLE> {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
ble_device_base::GattServiceTable get_service_table();
// No connection-type branching on this backend.
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services();
protected:
// Link/engine state. Discovery and GATT ops have their own cursors below —
// the link stays READY while they run.
enum class EngineState : uint8_t {
IDLE,
CONNECTING, // gap_connect issued, waiting for connection complete
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
READY, // on_connection_state(true) delivered
DISCONNECTING,
};
enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS };
enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, WRITE_CHAR_NO_RSP, READ_DESC, WRITE_DESC };
// The whole table in one transient allocation (RAMAllocator, checked),
// freed after streaming.
struct ServiceArena {
ble_device_base::GattService services[RP2_GATT_MAX_SERVICES];
ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS];
ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS];
};
// BTstack packet handlers (IRQ context: copy-and-enqueue only).
static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size);
static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle);
void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet);
void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value);
void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len);
void assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len);
// Main-loop state machine.
void handle_event_(const RP2GattEvent &event);
void handle_connected_(uint8_t status, uint16_t con_handle);
void handle_disconnected_(uint8_t reason);
void handle_query_complete_(uint8_t att_status);
void advance_discovery_(uint8_t att_status);
int issue_characteristic_query_(uint16_t service_index);
int issue_descriptor_query_(uint16_t char_index);
void finish_discovery_(int error);
void fail_connection_(uint8_t reason);
void cleanup_link_state_();
bool notify_subscribed_(uint16_t handle) const;
static void can_write_no_rsp_trampoline(void *context);
void finish_write_no_rsp_(uint8_t status);
void release_scan_inhibit_();
bool op_in_flight_() const {
return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE;
}
// Group 1: containers / large storage
ble_device_base::GattClientListener *listener_{nullptr};
ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
esphome::LockFreeQueue<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE> notify_queue_;
esphome::EventPool<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE - 1> notify_pool_;
// Shared buffer for the single outstanding GATT op: write payloads (BTstack
// keeps the caller's pointer until the request is sent) and read results
// (written from the handler, read after QUERY_COMPLETE is drained).
uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN];
// BTstack registrations
gatt_client_notification_t notification_registration_{};
btstack_context_callback_registration_t can_write_registration_{};
// Group 3: 4-byte types
uint32_t connect_started_{0};
uint32_t disconnecting_started_{0};
uint32_t write_no_rsp_started_{0};
// Group 4: 2-byte types (table counters written from the handler during
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID};
uint16_t mtu_{23};
uint16_t op_handle_{0};
uint16_t op_len_{0};
uint16_t service_count_{0};
uint16_t char_count_{0};
uint16_t desc_count_{0};
uint16_t disc_service_cursor_{0};
uint16_t disc_char_cursor_{0};
// Group 5: arrays / 1-byte types
// Subscribed notify handles; the loop() drain filters the wildcard
// listener's deliveries on this list (esp32 parity for enable=false).
std::array<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> notify_subscriptions_{};
uint8_t notify_subscription_count_{0};
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
EngineState state_{EngineState::IDLE};
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
OpType op_type_{OpType::NONE};
bool truncated_{false};
// One cancel attempt per connect: the second timeout escalates to failure.
bool connect_cancel_attempted_{false};
// A disconnect request raced an in-flight connect; finish teardown on link-up.
bool cancel_requested_{false};
// This engine's own hold on the shared scan inhibit, so the pairing stays
// one-to-one per connection even with multiple slots.
bool holds_scan_inhibit_{false};
// Instance registry for routing BTstack events (IRQ context) to engines.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT];
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static uint8_t instance_count;
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static btstack_packet_callback_registration_t hci_event_registration;
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static btstack_packet_callback_registration_t sm_event_registration;
};
} // namespace esphome::bluetooth_connection
#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT
+163 -66
View File
@@ -2,19 +2,25 @@ import functools
import logging
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.components import ble_device_base, bluetooth_connection
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.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing
# those modules registers esp32-only automations (ble.enable, ble.disable, ...)
# as a side effect, and a module-scope import would leak them into every
# platform's registry the moment a config declares `bluetooth_proxy:` —
# degrading "Unable to find action" config errors into C++ compile failures.
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily
# inside _esp32_config_schema()/_to_code_esp32(): importing those modules
# registers esp32-only automations (ble.enable, ble.disable, ...) as a side
# effect, and a module-scope import would leak them into every platform's
# registry the moment a config declares `bluetooth_proxy:` — degrading
# "Unable to find action" config errors into C++ compile failures.
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
@@ -27,13 +33,17 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
target platform set, so it takes one of the concrete branches.
"""
if CORE.is_esp32:
return ["esp32_ble_client", "esp32_ble_tracker"]
return ["bluetooth_connection", "esp32_ble_tracker"]
if CORE.target_platform in _HUB_PLATFORMS:
return ["ble_device_base"]
return ["ble_device_base", "bluetooth_connection"]
# No target platform, or one this component does not support: tooling
# resolving the manifest (including the host-pinned dependency resolver) —
# expose every arm so the closure keeps the esp32 BLE stack.
return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"]
return [
"ble_device_base",
"bluetooth_connection",
"esp32_ble_tracker",
]
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
@@ -42,6 +52,8 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
# proxy would be misdriven — bk72xx follows once the API carries a feature
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
# Coupled to bluetooth_connection: platforms with a GATT backend are also
# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry.
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
DEPENDENCIES = ["api"]
@@ -58,16 +70,17 @@ bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable
# CONFIG_SCHEMA below can state the connection_slots range without importing the
# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together.
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32
# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/
# pins them together, and the outer walkable schema uses it as the
# connection_slots bound (per-platform schemas tighten it).
_IDF_MAX_CONNECTIONS = 9
@functools.cache
def _esp32_config_schema() -> cv.All:
"""Build the esp32 schema, importing the esp32 BLE stack only when used."""
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components import esp32_ble, esp32_ble_tracker
if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS:
raise cv.Invalid(
@@ -77,14 +90,7 @@ def _esp32_config_schema() -> cv.All:
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
def validate_connections(config):
if CONF_CONNECTIONS in config:
@@ -94,7 +100,7 @@ def _esp32_config_schema() -> cv.All:
)
elif config[CONF_ACTIVE]:
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
)
@@ -142,31 +148,97 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
return config
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
# callback feeds the same API batching. GATT/active connections are excluded at
# compile time — only the esp32 build compiles the connection stack; nothing
# reads HubCapabilities::gatt at runtime for this today.
# Keys both platform schemas must declare identically; each arm spreads this
# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays
# per-arm: its default differs (esp32 True, hub arms False — no GATT).
@functools.cache
def _rp2_config_schema() -> cv.All:
"""Full proxy on the rp2 BLE hub: active connections through the BTstack
GATT client backend in bluetooth_connection. The slot limit comes from the
prebuilt BTstack library (one connection today); the code is built for N."""
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
def populate_connections(config: ConfigType) -> ConfigType:
# One wrapper + backend pair per slot, declared during validation so
# their ids exist for codegen (the esp32 arm's `connections` pattern).
if not config[CONF_ACTIVE]:
return config
bluetooth_connection.consume_gatt_slot(
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
)(config)
return {
**config,
CONF_CONNECTIONS: [
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
],
}
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
schema = (
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional(
CONF_CONNECTION_SLOTS,
default=min(DEFAULT_CONNECTION_SLOTS, max_conn),
): cv.All(
cv.positive_int,
cv.Range(
min=1,
max=max_conn,
msg=f"rp2 supports at most {max_conn} connection slot(s); "
"the framework's BTstack library is built with "
f"MAX_NR_GATT_CLIENTS {max_conn}",
),
),
}
)
.extend(
# ble_hub_id with the friendly no-tracker-configured guard.
ble_device_base.BLE_DEVICE_SCHEMA
)
.extend(cv.COMPONENT_SCHEMA)
)
return cv.All(schema, populate_connections)
async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
"""One wrapper + backend pair per slot; the platform-specific backend
registration lives in bluetooth_connection.new_gatt_backend()."""
for connection_conf in config.get(CONF_CONNECTIONS, []):
backend = await bluetooth_connection.new_gatt_backend(
connection_conf, service_table=False
)
connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection))
# Per-platform schema builders; every key of
# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by
# tests/component_tests/bluetooth_proxy/). Connection codegen is shared.
_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema}
# Keys every platform arm declares identically; each arm spreads this dict so
# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default
# differs (esp32 True, rp2 True, advertisement-only False).
_COMMON_SCHEMA_KEYS = {
cv.GenerateID(): cv.declare_id(BluetoothProxy),
}
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
# callback feeds the same API batching, no connection stack compiled.
_BLE_HUB_CONFIG_SCHEMA = cv.All(
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
# Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator
# after a strict schema rejects an explicit `ble_hub_id` before it
# runs, and that key is the documented way to disambiguate once a
# platform has two trackers.
cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id(
ble_device_base.BLEHub
),
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA),
)
.extend(
# ble_hub_id with the friendly no-tracker-configured guard.
ble_device_base.BLE_DEVICE_SCHEMA
)
.extend(cv.COMPONENT_SCHEMA),
_validate_no_active,
)
@@ -175,9 +247,10 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All(
def _validate_platform(config: ConfigType) -> ConfigType:
"""Apply the schema for the platform actually being compiled.
esp32 keeps the full GATT proxy; every other platform gets the
advertisement-only shape, which rejects the connection-oriented options
above because its schema does not define them.
Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS
platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get
the advertisement-only shape; unsupported keys were already rejected by
name in _reject_unsupported_connection_keys.
"""
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform. Expose the esp32
@@ -193,18 +266,21 @@ def _validate_platform(config: ConfigType) -> ConfigType:
raise cv.Invalid(
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
"active-scan-capable BLE tracker hub is available for this "
"platform. It runs on esp32 (full proxy), and the ln882x and rp2 "
"families (advertisement-only)."
"platform. It runs on esp32 and rp2 (full proxy) and the ln882x "
"family (advertisement-only)."
)
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config)
return _BLE_HUB_CONFIG_SCHEMA(config)
def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
"""Reject connection-oriented options by name on hub-only platforms.
def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType:
"""Reject connection options a platform does not support, by name.
Runs before the walkable schema below so the user gets "this option does
not exist here" instead of the option's esp32 value range (which would
imply a smaller number is accepted).
GATT hub platforms keep connection_slots but reject the esp32-only keys;
advertisement-only hubs reject all three. Runs before the walkable schema
below so the user gets "this option does not exist here" instead of a
value-range error implying the option works.
"""
if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None:
return config
@@ -213,14 +289,28 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
# reports "not supported on {platform}" instead of a key-level message
# implying an advertisement-only proxy is available.
return config
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
# Full proxy: connection_slots is real here; the per-connection list
# exists internally but carries no user options, and the Bluedroid
# NVS service cache is esp32-only.
rejected = {
CONF_CONNECTIONS: (
"has no per-connection options on this platform; use "
"'connection_slots' to set the count"
),
CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)",
}
else:
reason = (
"requires active connection support; this platform runs the "
"advertisement-only proxy and has no such option"
)
rejected = dict.fromkeys(
(CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason
)
for key, reason in rejected.items():
if key in config:
raise cv.Invalid(
f"'{key}' requires active connection support, which needs the "
"esp32 GATT stack; this platform runs the advertisement-only "
"proxy and has no such option",
path=[key],
)
raise cv.Invalid(f"'{key}' {reason}", path=[key])
return config
@@ -238,11 +328,14 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
CONFIG_SCHEMA = cv.All(
_reject_connection_keys_off_esp32,
_reject_unsupported_connection_keys,
cv.Schema(
{
cv.Optional(CONF_ACTIVE): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
# Bounded by the loosest platform cap so range walkers (the
# device-builder field-range sync) see a real Range; the
# per-platform schemas tighten it (1 on rp2) with their own error.
cv.Optional(CONF_CONNECTION_SLOTS): cv.All(
cv.positive_int,
cv.Range(min=1, max=_IDF_MAX_CONNECTIONS),
@@ -266,18 +359,18 @@ async def _to_code_esp32(config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_active(config[CONF_ACTIVE]))
await esp32_ble_tracker.register_raw_ble_device(var, config)
await esp32_ble_tracker.register_scanner_state_listener(var, config)
tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID])
cg.add(var.set_ble_hub(tracker))
# Compiles the scanner-state push slot into the tracker and the matching
# registration into the proxy; the other hubs are polled instead.
cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
# Define max connections for protobuf fixed array
connection_count = len(config.get(CONF_CONNECTIONS, []))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
for connection_conf in config.get(CONF_CONNECTIONS, []):
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)
await _connections_to_code(var, config)
if config.get(CONF_CACHE_SERVICES):
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
@@ -292,8 +385,12 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
cg.add(var.set_ble_hub(hub))
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
# this define whenever a proxy is present; no connections off-esp32.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0)
# this define whenever a proxy is present. Zero on advertisement-only hubs.
# Sized from the instantiated connections so the define can never diverge
# from the loop below (the define sizes fixed storage in the proxy).
slots = len(config.get(CONF_CONNECTIONS, ()))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots)
await _connections_to_code(var, config)
async def to_code(config: ConfigType) -> None:
@@ -1,597 +0,0 @@
#include "bluetooth_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include "bluetooth_proxy.h"
namespace esphome::bluetooth_proxy {
static const char *const TAG = "bluetooth_proxy.connection";
// This function is allocation-free and directly packs UUIDs into the output array
// using precalculated constants for the Bluetooth base UUID
static void fill_128bit_uuid_array(std::array<uint64_t, 2> &out, esp_bt_uuid_t uuid_source) {
// Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB
// out[0] = bytes 8-15 (big-endian)
// - For 128-bit UUIDs: use bytes 8-15 as-is
// - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11
out[0] = uuid_source.len == ESP_UUID_LEN_128
? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) |
((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) |
((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) |
((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8]))
: (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32)
<< 32) |
0x00001000ULL); // Base UUID bytes 8-11
// out[1] = bytes 0-7 (big-endian)
// - For 128-bit UUIDs: use bytes 0-7 as-is
// - For 16/32-bit UUIDs: use precalculated base UUID constant
out[1] = uuid_source.len == ESP_UUID_LEN_128
? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) |
((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) |
((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) |
((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0])
: 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB
}
// Helper to fill UUID in the appropriate format based on client support and UUID type
static void fill_gatt_uuid(std::array<uint64_t, 2> &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid,
bool use_efficient_uuids) {
if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) {
// Use 128-bit format for old clients or when UUID is already 128-bit
fill_128bit_uuid_array(uuid_128, uuid);
} else if (uuid.len == ESP_UUID_LEN_16) {
short_uuid = uuid.uuid.uuid16;
} else if (uuid.len == ESP_UUID_LEN_32) {
short_uuid = uuid.uuid.uuid32;
}
}
// Constants for size estimation
static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4)
static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7)
static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1)
static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4)
static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic
// Helper to estimate service size before fetching all data
/**
* Estimate the size of a Bluetooth service based on the number of characteristics and UUID format.
*
* @param char_count The number of characteristics in the service.
* @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions.
* @return The estimated size of the service in bytes.
*
* This function calculates the size of a Bluetooth service by considering:
* - A service overhead, which depends on whether efficient UUIDs are used.
* - The size of each characteristic, assuming 128-bit UUIDs for safety.
* - The size of descriptors, assuming one 128-bit descriptor per characteristic.
*/
static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) {
size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY;
// Always assume 128-bit UUIDs for characteristics to be safe
size_t char_size = CHAR_SIZE_128BIT;
// Assume one 128-bit descriptor per characteristic
size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR;
return service_overhead + (char_size + desc_size) * char_count;
}
bool BluetoothConnection::supports_efficient_uuids_() const {
auto *api_conn = this->proxy_->get_api_connection();
return api_conn && api_conn->client_supports_api_version(1, 12);
}
void BluetoothConnection::dump_config() {
ESP_LOGCONFIG(TAG, "BLE Connection:");
BLEClientBase::dump_config();
}
void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) {
auto &allocated = this->proxy_->connections_free_response_.allocated;
for (auto &slot : allocated) {
if (slot == find_value) {
slot = set_value;
return;
}
}
}
void BluetoothConnection::set_address(uint64_t address) {
// If we're clearing an address (disconnecting), update the pre-allocated message
if (address == 0 && this->address_ != 0) {
this->proxy_->connections_free_response_.free++;
this->update_allocated_slot_(this->address_, 0);
}
// If we're setting a new address (connecting), update the pre-allocated message
else if (address != 0 && this->address_ == 0) {
this->proxy_->connections_free_response_.free--;
this->update_allocated_slot_(0, 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) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
// Important: If we were in the middle of sending services, we do NOT send
// send_gatt_services_done() here. This ensures the client knows that
// the service discovery was interrupted and can retry. The client
// (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT)
// to detect incomplete service discovery rather than relying on us to
// tell them about a partial list.
this->set_address(0);
this->send_service_ = INIT_SENDING_SERVICES;
this->proxy_->send_connections_free();
}
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->supports_efficient_uuids_();
// Prepare response
api::BluetoothGATTGetServicesResponse resp;
resp.address = this->address_;
// Dynamic batching based on actual size
// Conservative MTU limit for API messages (accounts for WPA3 overhead)
static constexpr size_t MAX_PACKET_SIZE = 1360;
// Keep running total of actual message size
size_t current_size = resp.calculate_size();
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, 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, 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, desc_result.uuid, use_efficient_uuids);
descriptor_resp.handle = desc_result.handle;
desc_offset++;
}
}
} // end if (total_char_count > 0)
// Calculate the actual size of just this service
size_t service_size = service_resp.calculate_size() + 1; // +1 for field tag
// Check if adding this service would exceed the limit
if (current_size + service_size > MAX_PACKET_SIZE) {
// We would go over - pop the last service if we have more than one
if (resp.services.size() > 1) {
resp.services.pop_back();
ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch",
this->connection_index_, this->address_str(), this->send_service_, current_size, service_size,
MAX_PACKET_SIZE);
// Don't increment send_service_ - we'll retry this service in next batch
} else {
// This single service is too large, but we have to send it anyway
ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_,
this->address_str(), this->send_service_, service_size);
// Increment so we don't get stuck
this->send_service_++;
}
// Send what we have
break;
}
// Now we know we're keeping this service, add its size
current_size += service_size;
// Successfully added this service, increment counter
this->send_service_++;
}
// Send the message with dynamically batched services
api_conn->send_message(resp);
}
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 ESP_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 ESP_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 ESP_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 ESP_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 ESP_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);
}
esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() {
return this->proxy_->get_advertisement_parser_type();
}
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
@@ -1,62 +0,0 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
namespace esphome::bluetooth_proxy {
class BluetoothProxy;
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;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
esp_err_t read_characteristic(uint16_t handle);
esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response);
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");
}
void set_address(uint64_t address) override;
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
void update_allocated_slot_(uint64_t find_value, uint64_t set_value);
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)
BluetoothProxy *proxy_;
// Group 2: 2-byte types
int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index
// Group 3: 1-byte types
bool seen_mtu_or_services_{false};
// 1 byte used, 1 byte padding
};
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
@@ -8,6 +8,7 @@
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
#include <limits>
@@ -24,57 +25,71 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
#ifdef USE_ESP32
// The neutral enum's values are the wire values.
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STARTING) ==
api::enums::BLUETOOTH_SCANNER_STATE_STARTING);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::RUNNING) ==
api::enums::BLUETOOTH_SCANNER_STATE_RUNNING);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::FAILED) ==
api::enums::BLUETOOTH_SCANNER_STATE_FAILED);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPING) ==
api::enums::BLUETOOTH_SCANNER_STATE_STOPPING);
static_assert(static_cast<uint32_t>(ble_device_base::ScannerState::STOPPED) ==
api::enums::BLUETOOTH_SCANNER_STATE_STOPPED);
bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) {
if (this->api_connection_ == nullptr)
return false;
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
return this->api_connection_->send_message(resp);
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::send_polled_scanner_state_() {
// One read feeds both the frame and the change detector; the detector only
// advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a
// full TX buffer) is retried from loop() instead of leaving a stale state.
const bool running = this->hub_->scan_running();
if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING
: ble_device_base::ScannerState::IDLE)) {
this->last_scan_running_ = running;
}
}
#endif // !USE_BLE_SCANNER_STATE_CALLBACK
void BluetoothProxy::setup() {
// BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy.
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->parent_->get_scan_active();
}
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
if (this->api_connection_ != nullptr) {
this->send_bluetooth_scanner_state_(state);
}
}
void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) {
api::BluetoothScannerStateResponse resp;
resp.state = static_cast<api::enums::BluetoothScannerState>(state);
resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
this->api_connection_->send_message(resp);
}
#else // !USE_ESP32
void BluetoothProxy::setup() {
this->connections_free_response_.limit = 0;
this->connections_free_response_.free = 0;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->hub_->scan_active();
this->last_scan_running_ = this->hub_->scan_running();
// The hub delivers raw advertisements on the ESPHome main loop:
// mac is least-significant octet first (BLE controller convention).
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &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) {
static_cast<BluetoothProxy *>(self)->send_bluetooth_scanner_state_(state);
}});
#endif
}
// The hub delivers raw advertisements on the ESPHome main loop.
void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) {
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
// raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends.
adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac);
adv.address = raw.address;
adv.rssi = raw.rssi;
adv.address_type = raw.addr_type;
uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast<uint8_t>(raw.data_len);
@@ -83,8 +98,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
this->response_.advertisements_len++;
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4],
raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi);
ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
@@ -92,30 +106,16 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme
}
}
void BluetoothProxy::send_bluetooth_scanner_state_() {
api::BluetoothScannerStateResponse resp;
resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING
: api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE;
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
this->api_connection_->send_message(resp);
}
#endif // USE_ESP32
#ifdef USE_ESP32
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) {
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
connection->address_str(), espbt::client_state_to_string(state));
connection->address_str(), ble_device_base::client_state_to_string(state));
}
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);
}
#endif // USE_ESP32
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -124,104 +124,77 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ
void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action,
const char *type) {
this->log_not_connected_gatt_(action, type);
this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED);
this->send_gatt_error(address, handle, GATT_NOT_CONNECTED);
}
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// This method should never be called since bluetooth_proxy always uses raw advertisements
// but we need to provide an implementation to satisfy the virtual method requirement
return false;
}
#endif
bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) {
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return false;
auto &advertisements = this->response_.advertisements;
for (size_t i = 0; i < count; i++) {
auto &result = scan_results[i];
uint8_t length = result.adv_data_len + result.scan_rsp_len;
// Fill in the data directly at current position
auto &adv = advertisements[this->response_.advertisements_len];
adv.address = esp32_ble::ble_addr_to_uint64(result.bda);
adv.rssi = result.rssi;
adv.address_type = result.ble_addr_type;
adv.data_len = length;
std::memcpy(adv.data, result.ble_adv, length);
this->response_.advertisements_len++;
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0],
result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
this->flush_pending_advertisements_();
}
}
return true;
}
#endif // USE_ESP32
void BluetoothProxy::log_advertisement_flush_() {
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
}
void BluetoothProxy::dump_config() {
#ifdef USE_ESP32
// Print configured facts. dump_config runs right after setup, before the
// radio is up, so live scan state would always read "stopped" here — the
// loop's BluetoothScannerStateResponse carries the changing value instead.
char mac_str[18];
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 *scan_mode = this->configured_scan_active_ ? "active" : "passive";
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
" Connections: %d",
YESNO(this->active_), this->connection_count_);
" Connections: %d\n"
" Configured scan: %s\n"
" Adapter MAC: %s",
YESNO(this->active_), this->connection_count_, scan_mode, mac_out);
#else
// Advertisement-only: print configured facts. dump_config runs right after
// setup, before the radio is up, so live scan state would always read
// "stopped" here — the loop's BluetoothScannerStateResponse carries the
// changing value instead.
char mac_str[18];
this->get_bluetooth_mac_address_pretty(mac_str);
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Mode: advertisement-only (no GATT connections)\n"
" Configured scan: %s\n"
" Adapter MAC: %s",
this->configured_scan_active_ ? "active" : "passive",
mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)");
scan_mode, mac_out);
#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_();
// 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) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) {
// Cannot happen with codegen-sized registration; a silent drop would
// surface later as a null proxy_ dereference, so refuse loudly.
ESP_LOGE(TAG, "Connection registry full, dropping registration");
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();
}
}
// The hub wrapper has no Component lifecycle, so the index is assigned here.
connection->connection_index_ = this->connection_count_;
this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this;
#endif
}
esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() {
return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS;
void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); }
void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) {
for (auto &slot : this->connections_free_response_.allocated) {
if (slot == find_value) {
slot = set_value;
return;
}
}
// The accounting arrays are only mutated here and sized to the slot count,
// so a miss means the bookkeeping already drifted — say so.
ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value);
}
void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) {
this->send_device_connection(connection->get_address(), false, 0, reason);
connection->set_address(0);
connection->send_service_ = INIT_SENDING_SERVICES;
this->send_connections_free();
}
BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) {
@@ -239,7 +212,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese
// We only set the state if we allocate the connection
// to avoid a race where multiple connection attempts
// are made.
connection->set_state(espbt::ClientState::INIT);
connection->set_state(ClientState::INIT);
return connection;
}
}
@@ -262,34 +235,25 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_device_connection(msg.address, false);
return;
}
if (connection->state() == espbt::ClientState::CONNECTED ||
connection->state() == espbt::ClientState::ESTABLISHED) {
if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) {
this->log_connection_request_ignored_(connection, connection->state());
this->send_device_connection(msg.address, true);
this->send_connections_free();
return;
} else if (connection->state() == espbt::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() != espbt::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());
return;
}
if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) {
connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE);
connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE);
this->log_connection_info_(connection, "v3 with cache");
} else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE
connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE);
connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE);
this->log_connection_info_(connection, "v3 without cache");
}
connection->set_remote_addr_type(static_cast<esp_ble_addr_type_t>(msg.address_type));
connection->set_state(espbt::ClientState::DISCOVERED);
connection->initiate_connection(static_cast<uint8_t>(msg.address_type));
this->send_connections_free();
break;
}
@@ -300,7 +264,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_connections_free();
return;
}
if (connection->state() != espbt::ClientState::IDLE) {
if (connection->state() != ClientState::IDLE) {
connection->disconnect();
} else {
connection->set_address(0);
@@ -310,32 +274,40 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: {
// The connection wrapper exposes the pairing surface; success is
// reported when the platform's pairing completion arrives.
auto *connection = this->get_connection_(msg.address, false);
if (connection != nullptr) {
if (!connection->is_paired()) {
auto err = connection->pair();
if (err != ESP_OK) {
if (err != CONN_OK) {
this->send_device_pairing(msg.address, false, err);
}
} else {
this->send_device_pairing(msg.address, true);
}
} else {
// Answer instead of leaving the client to time out.
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
}
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_remove_bond_device(address);
this->send_device_unpairing(msg.address, ret == ESP_OK, ret);
conn_err_t ret = bluetooth_connection::unpair_device(msg.address);
if (ret == CONN_OK) {
// The bond is gone; a live connection must not short-circuit the
// next PAIR as already paired.
auto *connection = this->get_connection_(msg.address, false);
if (connection != nullptr) {
connection->set_unpaired();
}
}
this->send_device_unpairing(msg.address, ret == CONN_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_gattc_cache_clean(address);
// Shares the sender with the neutral path, which also null-checks api_connection_.
this->send_device_clear_cache(msg.address, ret == ESP_OK, ret);
conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address);
this->send_device_clear_cache(msg.address, ret == CONN_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
@@ -354,7 +326,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms
}
auto err = connection->read_characteristic(msg.handle);
if (err != ESP_OK) {
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -367,7 +339,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &
}
auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response);
if (err != ESP_OK) {
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -380,7 +352,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead
}
auto err = connection->read_descriptor(msg.handle);
if (err != ESP_OK) {
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -393,7 +365,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri
}
auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true);
if (err != ESP_OK) {
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -404,8 +376,8 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
return;
}
if (!connection->service_count_) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str());
if (!connection->has_gatt_services()) {
ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str());
this->send_gatt_services_done(msg.address);
return;
}
@@ -421,7 +393,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
}
auto err = connection->notify_characteristic(msg.handle, msg.enable);
if (err != ESP_OK) {
if (err != CONN_OK) {
this->send_gatt_error(msg.address, msg.handle, err);
}
}
@@ -429,6 +401,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
if (this->api_connection_ == nullptr)
return;
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
auto *connection = this->get_connection_(msg.address, false);
api::BluetoothSetConnectionParamsResponse resp;
@@ -436,9 +409,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
if (connection == nullptr || !connection->connected()) {
ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected",
connection ? static_cast<int>(connection->connection_index_) : -1,
connection ? static_cast<int>(connection->get_connection_index()) : -1,
connection ? connection->address_str() : "unknown");
resp.error = ESP_GATT_NOT_CONNECTED;
resp.error = GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
return;
}
@@ -453,52 +426,102 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp);
}
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifdef USE_ESP32
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->parent_->get_scan_active() == active) {
// esp32 only: BLEHub is the concrete tracker here, so these calls reach
// tracker-native methods beyond the neutral contract.
if (this->hub_->get_scan_active() == active) {
return;
}
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->parent_->set_scan_active(active);
this->parent_->stop_scan();
this->parent_->set_scan_continuous(
this->hub_->set_scan_active(active);
this->hub_->stop_scan();
this->hub_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up.
}
#else // !USE_ESP32
// Advertisement-only proxy. GATT client connections are excluded at compile
// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults
// HubCapabilities at runtime today — so every connection-oriented request is
// answered with a clean error instead of silence, and Home Assistant treats
// the proxy as passive.
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() {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Stream pending service-discovery batches every iteration; the streamer
// handles a vanished API connection itself.
for (uint8_t i = 0; i < this->connection_count_; i++) {
this->connections_[i]->process_pending_services();
}
#endif
// Run advertisement flush / scanner-state poll 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)
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// The API subscriber is gone: tear down any connections it left behind
// (disconnect() on an already-disconnecting slot is a no-op).
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() != 0) {
connection->disconnect();
}
}
#endif
return;
// The hub has no scanner-state listener interface; poll and report on change.
bool running = this->hub_->scan_running();
if (running != this->last_scan_running_) {
this->last_scan_running_ = running;
this->send_bluetooth_scanner_state_();
}
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
// This hub doesn't push scanner-state transitions; poll and report on
// change. A hub gaining push emits the define and drops this poll.
if (this->hub_->scan_running() != this->last_scan_running_) {
this->send_polled_scanner_state_();
}
#endif
this->flush_pending_advertisements_();
}
#ifndef BLUETOOTH_CONNECTION_SERVES_PROXY
// Advertisement-only proxy. GATT client connections are excluded at compile
// time (no connection backend on this platform, or active: false), so every
// connection-oriented request is answered with a clean error instead of
// silence, and Home Assistant treats the proxy as passive.
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
switch (msg.request_type) {
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
ESP_LOGW(TAG, "Active connections are not supported on this platform");
this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED);
this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
// Not an error: the device is already disconnected, which is the requested state.
@@ -506,14 +529,20 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
this->send_connections_free();
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR:
this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: {
// 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;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE:
this->send_device_clear_cache(msg.address, false, ESP_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;
}
}
}
@@ -544,32 +573,14 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
if (this->api_connection_ == nullptr)
return;
// Send results unchecked (esp32 parity): a drop resolves via the client timeout.
api::BluetoothSetConnectionParamsResponse resp;
resp.address = msg.address;
resp.error = ESP_GATT_NOT_CONNECTED;
resp.error = GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
}
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) {
// Keep loop()'s change detector in step with the state sent here, so a
// failed restart (scan_running_ dropped by the tracker) is not reported
// twice — once now and again on the next tick.
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
}
}
#endif // USE_ESP32
#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
@@ -585,12 +596,11 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
this->api_connection_->get_peername_to(old_peername));
}
this->api_connection_ = api_connection;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state());
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
// get_scanner_state() is part of the push-hub surface (see BLEHubContract).
this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state());
#else
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
this->send_polled_scanner_state_();
#endif
}
@@ -600,12 +610,9 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
#endif
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) {
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
@@ -633,7 +640,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) {
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothGATTErrorResponse call;
@@ -643,7 +650,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_er
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) {
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDevicePairingResponse call;
@@ -654,7 +661,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_er
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) {
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceUnpairingResponse call;
@@ -667,7 +674,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) {
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceClearCacheResponse call;
@@ -5,48 +5,30 @@
#ifdef USE_BLUETOOTH_PROXY
#include <array>
#include <map>
#include <vector>
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "bluetooth_connection.h"
#include "esphome/components/ble_device_base/ble_hub_impl.h"
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#include <esp_bt.h>
#endif
#include <esp_bt_device.h>
#else
#include "esphome/components/ble_device_base/ble_hub.h"
#endif // USE_ESP32
#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h"
namespace esphome::bluetooth_proxy {
// Proxy-owned error type for the API error fields, which are plain integers on
// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls);
// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps
// the header independent of how a hub platform's SDK spells its error type.
#ifdef USE_ESP32
using proxy_err_t = esp_err_t;
static constexpr proxy_err_t PROXY_OK = ESP_OK;
#else
using proxy_err_t = int;
static constexpr proxy_err_t PROXY_OK = 0;
#endif
// The connection-domain types live in the bluetooth_connection component;
// re-exported here so the proxy code reads unqualified.
using bluetooth_connection::CONN_OK;
using bluetooth_connection::conn_err_t;
using bluetooth_connection::GATT_NOT_CONNECTED;
using bluetooth_connection::INIT_SENDING_SERVICES;
static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = -1;
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
#ifdef USE_ESP32
using namespace esp32_ble_client;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
using BluetoothConnection = bluetooth_connection::BluetoothConnection;
using ClientState = ble_device_base::ClientState;
#endif
// Legacy versions:
@@ -56,6 +38,8 @@ using namespace esp32_ble_client;
// Version 4: Pairing support
// Version 5: Cache clear support
static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5;
static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4;
static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3;
static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1;
enum BluetoothProxyFeature : uint32_t {
@@ -73,46 +57,28 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef USE_ESP32
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
public esp32_ble_tracker::BLEScannerStateListener,
public Component {
friend class BluetoothConnection; // Allow connection to update connections_free_response_
#else
class BluetoothProxy final : public Component {
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Allow the connection to update connections_free_response_
friend bluetooth_connection::BluetoothConnection;
#endif
public:
BluetoothProxy();
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
#endif
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
#endif // USE_ESP32
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
void dump_config() override;
void setup() override;
void loop() override;
#ifdef USE_ESP32
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
this->connections_[this->connection_count_++] = connection;
connection->proxy_ = this;
}
#endif
}
#else
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
void register_connection(BluetoothConnection *connection);
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
#ifndef USE_ESP32
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and
// the BLEHub contract does not promise those are settled any earlier than
// the hub's own setup().
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
#endif // USE_ESP32
#endif // !USE_ESP32
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
@@ -126,42 +92,37 @@ class BluetoothProxy final : public Component {
void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags);
void unsubscribe_api_connection(api::APIConnection *api_connection);
api::APIConnection *get_api_connection() { return this->api_connection_; }
/// Whether the subscribed API client understands 16/32-bit UUID fields.
bool client_supports_efficient_uuids() const {
return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12);
}
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK);
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK);
void send_connections_free();
void send_connections_free(api::APIConnection *api_connection);
void send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error);
void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK);
void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error);
void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK);
void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK);
void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK);
void bluetooth_scanner_set_mode(bool active);
#ifdef USE_ESP32
static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) {
bd_addr[0] = (address >> 40) & 0xff;
bd_addr[1] = (address >> 32) & 0xff;
bd_addr[2] = (address >> 24) & 0xff;
bd_addr[3] = (address >> 16) & 0xff;
bd_addr[4] = (address >> 8) & 0xff;
bd_addr[5] = (address >> 0) & 0xff;
}
#endif
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
#ifdef USE_ESP32
/// BLEScannerStateListener interface
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
#endif
uint32_t get_legacy_version() const {
if (this->active_) {
if (!this->active_) {
return LEGACY_PASSIVE_ONLY_VERSION;
}
// Legacy clients (which predate the feature flags) map versions to
// capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active
// connections only.
if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) {
return LEGACY_ACTIVE_CONNECTIONS_VERSION;
}
return LEGACY_PASSIVE_ONLY_VERSION;
return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION
: LEGACY_ACTIVE_NO_PAIRING_VERSION;
}
uint32_t get_feature_flags() const {
@@ -175,53 +136,46 @@ class BluetoothProxy final : public Component {
// scan_mode_switch is the capability bit for exactly that (#18079) —
// active_scan alone is not enough, a hub may support active scanning yet
// refuse the runtime switch.
if (this->hub_->get_capabilities().scan_mode_switch) {
if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) {
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
}
#endif
if (this->active_) {
// REMOTE_CACHING is mandatory for active connections: API clients
// refuse to connect without it (it selects which V3 connect request
// they send, not device-side caching).
flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS;
flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING;
flags |= BluetoothProxyFeature::FEATURE_PAIRING;
flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING;
flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING;
if (bluetooth_connection::SUPPORTS_PAIRING) {
flags |= BluetoothProxyFeature::FEATURE_PAIRING;
}
if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) {
flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING;
}
}
return flags;
}
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
#ifdef USE_ESP32
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#else
uint8_t mac[6] = {};
this->hub_->get_adapter_mac(mac);
// Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs
// (rp2040's BTstack) only learn the address once the link layer is up, and
// report all-zero until then.
bool nonzero = false;
for (uint8_t b : mac)
nonzero |= b != 0;
if (nonzero) {
// Unavailable -> empty string: some hubs (rp2040's BTstack) only learn
// the address once the link layer is up, and report all-zero until then.
if (mac_address_is_valid(mac)) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#endif
}
protected:
#ifdef USE_ESP32
void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state);
#else
void send_bluetooth_scanner_state_();
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state);
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
void send_polled_scanner_state_();
#endif
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
/// Caller must ensure api_connection_ is non-null and API server is connected.
void flush_pending_advertisements_() {
@@ -235,24 +189,59 @@ class BluetoothProxy final : public Component {
}
void log_advertisement_flush_();
#ifdef USE_ESP32
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state);
void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state);
void log_connection_info_(BluetoothConnection *connection, const char *message);
#endif
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);
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
/// Keep the pre-allocated connections-free message in step when a
/// connection slot changes address (0 = free). Called from the connection
/// classes' set_address().
// maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the
// body is removed, and the free < MAX compare would trip -Wtype-limits.
void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) {
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
auto &resp = this->connections_free_response_;
if (new_address == 0 && old_address != 0) {
if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
resp.free++;
} else {
this->log_slot_accounting_mismatch_();
}
this->replace_allocated_slot_(old_address, 0);
} else if (new_address != 0 && old_address == 0) {
if (resp.free > 0) {
resp.free--;
} else {
this->log_slot_accounting_mismatch_();
}
this->replace_allocated_slot_(0, new_address);
}
#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
}
void replace_allocated_slot_(uint64_t find_value, uint64_t set_value);
void log_slot_accounting_mismatch_();
/// Free a connection slot after teardown: notify the API client and reset
/// the streaming cursor. Important: does NOT send send_gatt_services_done()
/// when service streaming was interrupted -- the client (aioesphomeapi) has
/// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service
/// discovery and retry, rather than being told a partial list is complete.
void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason);
#endif
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
api::APIConnection *api_connection_{nullptr};
#ifdef USE_ESP32
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
#else
ble_device_base::BLEHub *hub_{nullptr};
#endif
ble_device_base::BLEHub *hub_{nullptr};
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
@@ -267,7 +256,7 @@ class BluetoothProxy final : public Component {
bool active_;
uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML
#ifndef USE_ESP32
#ifndef USE_BLE_SCANNER_STATE_CALLBACK
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};
@@ -1,24 +1,24 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS
from esphome.core import HexInt
CODEOWNERS = ["@nagyrobi"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA
bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer")
BTHomeMiThermometer = bthome_mithermometer_ns.class_(
"BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
"BTHomeMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component
)
def bthome_mithermometer_base_schema(extra_schema=None):
if extra_schema is None:
extra_schema = {}
return (
return cv.All(
ble_device_base.rename_legacy_hub_id("bthome_mithermometer"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer),
@@ -26,15 +26,15 @@ def bthome_mithermometer_base_schema(extra_schema=None):
cv.Optional(CONF_BINDKEY): cv.bind_key,
}
)
.extend(BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(extra_schema)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def setup_bthome_mithermometer(var, config):
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
if bindkey := config.get(CONF_BINDKEY):
bindkey_bytes = [
@@ -8,13 +8,20 @@
#include <cstring>
#include <span>
// AES-CCM backend for encrypted-advertisement (bindkey) decryption:
// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed.
// - every other platform -> the portable software AES-CCM in ble_device_base, so
// decryption never depends on the SDK exposing mbedtls/PSA to application code
// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform.
#ifdef USE_ESP32
#include <esp_idf_version.h>
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <psa/crypto.h>
#else
#include "mbedtls/ccm.h"
#define BTHOME_CRYPTO_PSA
#endif
#endif
#ifndef BTHOME_CRYPTO_PSA
#include "esphome/components/ble_device_base/ble_aes_ccm.h"
#endif
namespace esphome::bthome_mithermometer {
@@ -157,7 +164,7 @@ void BTHomeMiThermometer::dump_config() {
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_);
}
bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool BTHomeMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) {
bool matched = false;
for (auto &service_data : device.get_service_datas()) {
if (this->handle_service_data_(service_data, device)) {
@@ -204,7 +211,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector<uint8_t> &da
const uint8_t *ciphertext = data.data() + 1;
const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#if defined(BTHOME_CRYPTO_PSA)
// PSA AEAD expects ciphertext + tag concatenated
// BLE advertisement max payload is 31 bytes, so this is always sufficient
static constexpr size_t MAX_CT_WITH_TAG = 32;
@@ -236,29 +243,18 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector<uint8_t> &da
return false;
}
#else
mbedtls_ccm_context ctx;
mbedtls_ccm_init(&ctx);
int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->bindkey_, BTHOME_BINDKEY_SIZE * 8);
if (ret) {
ESP_LOGVV(TAG, "mbedtls_ccm_setkey() failed.");
mbedtls_ccm_free(&ctx);
return false;
}
ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_size, nonce.data(), nonce.size(), nullptr, 0, ciphertext,
payload.data(), mic, BTHOME_MIC_SIZE);
mbedtls_ccm_free(&ctx);
if (ret) {
ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret);
// Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency.
if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext,
ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) {
ESP_LOGVV(TAG, "BTHome decryption failed.");
return false;
}
#endif
return true;
}
bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data,
const esp32_ble_tracker::ESPBTDevice &device) {
bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data,
const ble_device_base::ESPBTDevice &device) {
if (!service_data.uuid.contains(0xD2, 0xFC)) {
return false;
}
@@ -439,5 +435,3 @@ bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceD
}
} // namespace esphome::bthome_mithermometer
#endif
@@ -1,6 +1,6 @@
#pragma once
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
@@ -8,11 +8,12 @@
#include <initializer_list>
#include <vector>
#ifdef USE_ESP32
// No platform #ifdef: ble_device_base provides the BLE types on every platform; this
// component is only compiled when configured (which requires a BLE hub). bindkey (AES-CCM)
// decryption availability is selected per platform in the .cpp.
namespace esphome::bthome_mithermometer {
class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component {
class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component {
public:
void set_address(uint64_t address) { this->address_ = address; }
void set_bindkey(std::initializer_list<uint8_t> bindkey);
@@ -24,11 +25,11 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener,
void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; }
void dump_config() override;
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
protected:
bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data,
const esp32_ble_tracker::ESPBTDevice &device);
bool handle_service_data_(const ble_device_base::ServiceData &service_data,
const ble_device_base::ESPBTDevice &device);
bool decrypt_bthome_payload_(const std::vector<uint8_t> &data, uint64_t source_address,
std::vector<uint8_t> &payload) const;
@@ -45,5 +46,3 @@ class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener,
};
} // namespace esphome::bthome_mithermometer
#endif
@@ -23,9 +23,9 @@ from esphome.const import (
from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer
CODEOWNERS = ["@nagyrobi"]
AUTO_LOAD = ["ble_device_base"]
DEPENDENCIES = ["esp32_ble_tracker"]
CODEOWNERS = ["@nagyrobi"]
CONFIG_SCHEMA = bthome_mithermometer_base_schema(
{
+1
View File
@@ -37,6 +37,7 @@ CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SHA256 = "sha256"
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
CONF_STOP_BITS = "stop_bits"
CONF_TARGET_COUNT = "target_count"
CONF_USE_PSRAM = "use_psram"
CONF_VOC_INDEX = "voc_index"
CONF_VOLUME_INCREMENT = "volume_increment"
@@ -311,9 +311,12 @@ bool HOT EPaperT133A01::transfer_data() {
this->current_data_index_ = half;
if (millis() - start_time > MAX_TRANSFER_TIME) {
return false;
break;
}
}
if (half < total_rows) {
return false;
}
ESP_LOGD(TAG, "CS phase done");
this->disable();
this->cs_pin_->digital_write(true); // deselect CS
@@ -346,9 +349,12 @@ bool HOT EPaperT133A01::transfer_data() {
this->current_data_index_ = half;
if (millis() - start_time > MAX_TRANSFER_TIME) {
return false;
break;
}
}
if (half < total_rows * 2) {
return false;
}
ESP_LOGD(TAG, "CS1 phase done");
this->disable();
this->cs1_pin_->digital_write(true); // deselect CS1
+100 -65
View File
@@ -142,10 +142,14 @@ ASSERTION_LEVELS = {
"SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT",
}
SIGNING_SCHEME_RSA3072 = "rsa3072"
SIGNING_SCHEME_ECDSA256 = "ecdsa256"
SIGNING_SCHEME_ECDSA_V1 = "ecdsa_v1"
SIGNING_SCHEMES = {
"rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME",
"ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME",
"ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
SIGNING_SCHEME_RSA3072: "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME",
SIGNING_SCHEME_ECDSA256: "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME",
SIGNING_SCHEME_ECDSA_V1: "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
}
# A Secure Boot v2 image carries at most three signature blocks, and hardware
@@ -1263,7 +1267,7 @@ _SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema(
cv.ensure_list(_validate_trusted_key),
cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS),
),
cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of(
cv.Optional(CONF_SIGNING_SCHEME, default=SIGNING_SCHEME_RSA3072): cv.one_of(
*SIGNING_SCHEMES, lower=True
),
}
@@ -1313,7 +1317,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
path=[CONF_VERIFICATION_KEY],
)
if has_verification_keys:
if scheme != "rsa3072":
if scheme != SIGNING_SCHEME_RSA3072:
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme "
f"'rsa3072' (externally-signed RSA images). With '{scheme}' the "
@@ -1340,7 +1344,7 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
f"keys add nothing and waste a trusted-set slot).",
path=[CONF_VERIFICATION_KEYS],
)
if scheme == "ecdsa_v1":
if scheme == SIGNING_SCHEME_ECDSA_V1:
if not has_signing_key and not has_verification_key:
raise cv.Invalid(
f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' "
@@ -1496,7 +1500,10 @@ def final_validate(config):
]
# V1 ECDSA is only available on the original ESP32
if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS:
if (
scheme == SIGNING_SCHEME_ECDSA_V1
and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS
):
errs.append(
cv.Invalid(
f"Signing scheme 'ecdsa_v1' is only supported on "
@@ -1509,7 +1516,9 @@ def final_validate(config):
# On ESP32, V2 RSA requires minimum_chip_revision >= 3.0
# Note: string comparison works here because cv.one_of constrains
# min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1").
if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"):
if scheme == SIGNING_SCHEME_RSA3072 and (
min_rev is None or min_rev < "3.0"
):
errs.append(
cv.Invalid(
f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} "
@@ -1520,7 +1529,7 @@ def final_validate(config):
)
)
# ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC)
elif scheme == "ecdsa256":
elif scheme == SIGNING_SCHEME_ECDSA256:
errs.append(
cv.Invalid(
f"Signing scheme 'ecdsa256' is not supported on "
@@ -1530,7 +1539,11 @@ def final_validate(config):
)
)
# V1 on rev 3.0+ -- suggest V2 RSA for stronger security
elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0":
elif (
scheme == SIGNING_SCHEME_ECDSA_V1
and min_rev is not None
and min_rev >= "3.0"
):
_LOGGER.info(
"Using Secure Boot V1 ECDSA on %s rev %s. "
"Consider using 'rsa3072' (Secure Boot V2 RSA) for "
@@ -1541,8 +1554,14 @@ def final_validate(config):
else:
# Non-ESP32 variants: check V2 scheme-variant compatibility
scheme_variant_conflicts = {
"ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"),
"rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"),
SIGNING_SCHEME_ECDSA256: (
SIGNED_OTA_V2_RSA_ONLY_VARIANTS,
SIGNING_SCHEME_RSA3072,
),
SIGNING_SCHEME_RSA3072: (
SIGNED_OTA_V2_ECC_ONLY_VARIANTS,
SIGNING_SCHEME_ECDSA256,
),
}
if (
conflict := scheme_variant_conflicts.get(scheme)
@@ -2229,6 +2248,62 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _reconcile_vfs_fatfs_sdkconfig(
disable_vfs_termios: bool,
disable_vfs_select: bool,
disable_vfs_dir: bool,
disable_fatfs: bool,
) -> None:
"""Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win."""
opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
def set_opt(name: str, value: SdkconfigValueType) -> None:
# User sdkconfig_options (applied during to_code) win.
if name not in opts:
add_idf_sdkconfig_option(name, value)
# USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off.
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
set_opt("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios)
# VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread);
# sockets use lwip_select() either way. ~2.7KB flash when off.
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_SELECT", True)
else:
set_opt("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select)
# Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off.
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
set_opt("CONFIG_VFS_SUPPORT_DIR", True)
else:
set_opt("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir)
# FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only;
# sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set
# any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack.
lfn_keys = (
"CONFIG_FATFS_LFN_NONE",
"CONFIG_FATFS_LFN_HEAP",
"CONFIG_FATFS_LFN_STACK",
)
user_picked_lfn = any(k in opts for k in lfn_keys)
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", False)
set_opt("CONFIG_FATFS_LFN_HEAP", True)
set_opt("CONFIG_FATFS_MAX_LFN", 255)
set_opt("CONFIG_FATFS_VOLUME_COUNT", 4)
elif disable_fatfs:
if not user_picked_lfn:
set_opt("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
set_opt("CONFIG_FATFS_VOLUME_COUNT", 1)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
@@ -2584,47 +2659,6 @@ async def to_code(config):
if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False)
# Disable VFS support for termios (terminal I/O functions)
# USB Serial JTAG VFS functions require termios support.
# Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected
# as the logger output) call require_vfs_termios().
# Saves approximately 1.8KB of flash when disabled (default).
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
# Component requires VFS termios - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS]
)
# Disable VFS support for select() with file descriptors
# ESPHome only uses select() with sockets via lwip_select(), which still works.
# VFS select is only needed for UART/eventfd file descriptors.
# Components that need it (e.g., openthread) call require_vfs_select().
# Saves approximately 2.7KB of flash when disabled (default).
if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False):
# Component requires VFS select - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT]
)
# Disable VFS support for directory functions (opendir, readdir, mkdir, etc.)
# ESPHome doesn't use directory functions on ESP32.
# Components that need it (e.g., storage components) call require_vfs_dir().
# Saves approximately 0.5KB+ of flash when disabled (default).
if CORE.data.get(KEY_VFS_DIR_REQUIRED, False):
# Component requires VFS directory support - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR]
)
if use_platformio:
cg.add_platformio_option("board_build.partitions", "partitions.csv")
if CONF_PARTITIONS in config:
@@ -2703,7 +2737,9 @@ async def to_code(config):
# n; the 4 KiB padding and reserved signature sector the verifier
# depends on survive only because --secure-pad-v2 keys off
# CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol.
external_rsa = scheme == "rsa3072" and CONF_SIGNING_KEY not in signed_ota
external_rsa = (
scheme == SIGNING_SCHEME_RSA3072 and CONF_SIGNING_KEY not in signed_ota
)
verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS)
# verification_keys is accepted only for external RSA (rsa3072 with no
# signing_key), enforced in _validate_signed_ota_keys. Assert the
@@ -2857,6 +2893,16 @@ async def to_code(config):
# FINAL priority: runs after every network/coexistence request_*() call
CORE.add_job(_reconcile_network_sdkconfig)
# FINAL: require_*() calls can come from to_code at or below this priority, so an
# inline read would be iteration-order-dependent; reconcile once after every job ran.
CORE.add_job(
_reconcile_vfs_fatfs_sdkconfig,
advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS],
advanced[CONF_DISABLE_VFS_SUPPORT_SELECT],
advanced[CONF_DISABLE_VFS_SUPPORT_DIR],
advanced[CONF_DISABLE_FATFS],
)
# Disable regi2c control functions in IRAM
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
@@ -2872,17 +2918,6 @@ async def to_code(config):
):
add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True)
# Disable FATFS support
# Components that need FATFS (SD card, etc.) can call require_fatfs()
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
# Component called require_fatfs() - enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
+14 -2
View File
@@ -674,11 +674,23 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat
}
#endif
void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const {
// The running stack owns the address (on hosted controllers it lives in
// the remote chip's efuse); null before init becomes all-zero.
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
memcpy(out, mac, 6);
} else {
memset(out, 0, 6);
}
}
float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; }
void ESP32BLE::dump_config() {
const uint8_t *mac_address = esp_bt_dev_get_address();
if (mac_address) {
uint8_t mac_address[6];
this->get_mac_msb_first(mac_address);
if (mac_address_is_valid(mac_address)) {
const char *io_capability_s;
switch (this->io_cap_) {
case ESP_IO_CAP_OUT:
+2
View File
@@ -108,6 +108,8 @@ class ESP32BLE final : public Component {
void setup() override;
void loop() override;
void dump_config() override;
/// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up.
void get_mac_msb_first(uint8_t out[6]) const;
float get_setup_priority() const override;
void set_name(const char *name) { this->name_ = name; }
@@ -13,20 +13,14 @@ namespace esphome::esp32_ble_client {
static const char *const TAG = "esp32_ble_client";
// Intermediate connection parameters for standard operation
// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies,
// causing disconnections. These medium parameters balance responsiveness with bandwidth usage.
static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms
static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms
// The timeout value was increased from 6s to 8s to address stability issues observed
// in certain BLE devices when operating through WiFi-based BLE proxies. The longer
// timeout reduces the likelihood of disconnections during periods of high latency.
static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s
// Fastest connection parameters for devices with short discovery timeouts
static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum)
static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms
static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s
// Connection parameters are shared with the other GATT client backends
// (ble_device_base/ble_client_state.h) so the platforms cannot drift.
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;
static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s
static const esp_bt_uuid_t NOTIFY_DESC_UUID = {
.len = ESP_UUID_LEN_16,
@@ -92,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
uint16_t get_conn_id() const { return this->conn_id_; }
uint64_t get_address() const { return this->address_; }
bool is_paired() const { return this->paired_; }
// The proxy clears this when a bond is removed while the link is up.
void set_unpaired() { this->paired_ = false; }
uint8_t get_connection_index() const { return this->connection_index_; }
@@ -43,6 +43,8 @@ AUTO_LOAD = ["ble_device_base", "esp32_ble"]
DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@bdraco"]
ble_device_base.register_hub_provider("esp32_ble_tracker")
CONF_ESP32_BLE_ID = "esp32_ble_id"
CONF_SOFTWARE_COEXISTENCE = "software_coexistence"
@@ -64,12 +66,9 @@ def _get_required_features() -> set[BLEFeatures]:
# Slot counters sizing the tracker's StaticVector storage; one request per
# registered listener, client, or scanner state listener.
# registered listener or client.
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
_request_scanner_state_listener_slot = cg.slot_counter(
"ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT"
)
def register_ble_features(features: set[BLEFeatures]) -> None:
@@ -206,6 +205,9 @@ async def to_code(config):
# available on esp32 (sensors with irk: worked without opting in).
ble_device_base.request_irk_support()
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_ESP32_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -372,20 +374,6 @@ async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExp
return var
async def register_raw_ble_device(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
"""Register a BLE device listener that only needs raw advertisement data.
This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice
will not be compiled in if this is the only registration method used.
"""
_request_listener_slot()
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_listener(var))
return var
async def register_raw_client(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
@@ -398,17 +386,3 @@ async def register_raw_client(
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_client(var))
return var
async def register_scanner_state_listener(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
"""Register a listener for scanner state changes.
The slot request here is what sizes the tracker's listener storage; a
build with no registrations compiles the storage out entirely.
"""
_request_scanner_state_listener_slot()
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.add_scanner_state_listener(var))
return var
@@ -36,27 +36,6 @@ static const char *const TAG = "esp32_ble_tracker";
ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
const char *client_state_to_string(ClientState state) {
switch (state) {
case ClientState::INIT:
return "INIT";
case ClientState::DISCONNECTING:
return "DISCONNECTING";
case ClientState::IDLE:
return "IDLE";
case ClientState::DISCOVERED:
return "DISCOVERED";
case ClientState::CONNECTING:
return "CONNECTING";
case ClientState::CONNECTED:
return "CONNECTED";
case ClientState::ESTABLISHED:
return "ESTABLISHED";
default:
return "UNKNOWN";
}
}
float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
void ESP32BLETracker::setup() {
@@ -292,7 +271,9 @@ void ESP32BLETracker::register_client(ESPBTClient *client) {
// Safe because ESP32BLETracker (singleton) outlives all registered clients.
client->set_tracker_state_version(&this->state_version_);
this->clients_.push_back(client);
this->recalculate_advertisement_parser_types();
// Registration is add-only, so the flag is a monotonic OR.
if (client->wants_parsed_advertisements())
this->parse_advertisements_ = true;
#endif
}
@@ -304,48 +285,11 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li
#endif
}
void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) {
get_mac_address_raw(out); // WiFi base MAC, MSB-first
// BT MAC = base MAC + 2 on the last octet only, wrapping without carry —
// exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET.
out[5] += 2;
}
void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
listener->set_parent(this);
this->listeners_.push_back(listener);
this->recalculate_advertisement_parser_types();
#endif
}
void ESP32BLETracker::recalculate_advertisement_parser_types() {
this->raw_advertisements_ = false;
this->parse_advertisements_ = false;
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in
// listeners_; without this, any later esp32-path registration (e.g. the proxy's
// GATT clients) would recompute the flags and silently drop parsed dispatch.
if (!this->neutral_listeners_.empty())
this->parse_advertisements_ = true;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
this->parse_advertisements_ = true;
} else {
this->raw_advertisements_ = true;
}
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
this->parse_advertisements_ = true;
} else {
this->raw_advertisements_ = true;
}
}
this->parse_advertisements_ = true;
#endif
}
@@ -443,9 +387,9 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i
void ESP32BLETracker::set_scanner_state_(ScannerState state) {
this->scanner_state_ = state;
this->state_version_++;
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
for (auto *listener : this->scanner_state_listeners_) {
listener->on_scanner_state(state);
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
if (this->scanner_state_callback_.is_set()) {
this->scanner_state_callback_.invoke(state);
}
#endif
}
@@ -483,24 +427,23 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) {
#endif // USE_ESP32_BLE_DEVICE
void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
// Process raw advertisements
if (this->raw_advertisements_) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
listener->parse_devices(&scan_result, 1);
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->parse_devices(&scan_result, 1);
}
#endif
// Neutral raw-advertisement subscriber (the bluetooth_proxy path).
if (this->raw_advertisement_callback_.is_set()) {
ble_device_base::RawAdvertisement adv;
adv.address = esp32_ble::ble_addr_to_uint64(scan_result.bda);
adv.data = scan_result.ble_adv;
adv.data_len = static_cast<uint16_t>(scan_result.adv_data_len) + scan_result.scan_rsp_len;
adv.rssi = scan_result.rssi;
adv.addr_type = scan_result.ble_addr_type;
this->raw_advertisement_callback_.invoke(adv);
}
// Process parsed advertisements
if (this->parse_advertisements_) {
#ifdef USE_ESP32_BLE_DEVICE
ESPBTDevice device;
// The historical ingest keeps the raw scan-result fields populated for
// external components.
device.parse_scan_rst(scan_result);
bool found = false;
@@ -18,6 +18,7 @@
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/esp32_ble/ble.h"
@@ -34,11 +35,6 @@ using namespace esp32_ble;
using adv_data_t = ble_device_base::adv_data_t;
enum AdvertisementParserType {
PARSED_ADVERTISEMENTS,
RAW_ADVERTISEMENTS,
};
#ifdef USE_ESP32_BLE_UUID
using ServiceData = ble_device_base::ServiceData;
#endif
@@ -62,10 +58,6 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener {
// Raw-only build: no parsed-device support is compiled in.
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
#endif
virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; };
virtual AdvertisementParserType get_advertisement_parser_type() {
return AdvertisementParserType::PARSED_ADVERTISEMENTS;
};
void set_parent(ESP32BLETracker *parent) { parent_ = parent; }
protected:
@@ -88,60 +80,14 @@ struct ClientStateCounts {
bool operator!=(const ClientStateCounts &other) const { return !(*this == other); }
};
enum class ClientState : uint8_t {
// Connection is allocated
INIT,
// Client is disconnecting
DISCONNECTING,
// Connection is idle, no device detected.
IDLE,
// Device advertisement found.
DISCOVERED,
// Connection in progress.
CONNECTING,
// Initial connection established.
CONNECTED,
// The client and sub-clients have completed setup.
ESTABLISHED,
};
// The client connection state types are owned by the platform-neutral
// ble_device_base layer; re-exported here for backward compatibility.
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
using ble_device_base::client_state_to_string;
enum class ScannerState {
// Scanner is idle, init state
IDLE,
// Scanner is starting
STARTING,
// Scanner is running
RUNNING,
// Scanner failed to start
FAILED,
// Scanner is stopping
STOPPING,
};
/** Listener interface for BLE scanner state changes.
*
* Components can implement this interface to receive scanner state updates
* without the overhead of std::function callbacks.
*/
class BLEScannerStateListener {
public:
virtual void on_scanner_state(ScannerState state) = 0;
};
// Helper function to convert ClientState to string
const char *client_state_to_string(ClientState state);
enum class ConnectionType : uint8_t {
// The default connection type, we hold all the services in ram
// for the duration of the connection.
V1,
// The client has a cache of the services and mtu so we should not
// fetch them again
V3_WITH_CACHE,
// The client does not need the services and mtu once we send them
// so we should wipe them from memory as soon as we send them
V3_WITHOUT_CACHE
};
// Neutral scanner lifecycle re-exported for backward compatibility.
using ScannerState = ble_device_base::ScannerState;
/// Base class for BLE GATT clients that connect to remote devices.
///
@@ -158,6 +104,10 @@ enum class ConnectionType : uint8_t {
/// The pointer may be null if the client is not registered with a tracker.
class ESPBTClient : public ESPBTDeviceListener {
public:
/// False keeps the tracker from building parsed ESPBTDevice objects on
/// this client's account (raw consumers use the hub callback).
virtual bool wants_parsed_advertisements() { return true; }
virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) = 0;
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0;
@@ -211,7 +161,6 @@ class ESPBTClient : public ESPBTDeviceListener {
};
class ESP32BLETracker final : public Component,
public ble_device_base::BLEHub,
#ifdef USE_OTA_STATE_LISTENER
public ota::OTAGlobalStateListener,
#endif
@@ -234,22 +183,29 @@ class ESP32BLETracker final : public Component,
// esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer).
void register_listener(ESPBTDeviceListener *listener);
void register_client(ESPBTClient *client);
void recalculate_advertisement_parser_types();
// ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override;
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
void register_listener(ble_device_base::ESPBTDeviceListener *listener);
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) {
this->scanner_state_callback_ = callback;
}
#endif
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// scan_mode_switch is false: the mode is driven through this tracker's own
// API (set_scan_active + restart), not the neutral request_scan_mode().
return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true,
/* scan_mode_switch = */ false};
}
void get_adapter_mac(uint8_t out[6]) override;
bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; }
bool scan_active() override { return this->scan_active_; }
void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); }
bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; }
bool scan_active() { return this->scan_active_; }
// The mode is driven through this tracker's own API (see get_capabilities);
// the neutral request refuses without changing any state.
bool request_scan_mode(bool active) { return false; }
#ifdef USE_ESP32_BLE_DEVICE
void print_bt_device_info(const ESPBTDevice &device);
@@ -267,15 +223,6 @@ class ESP32BLETracker final : public Component,
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
/// Add a listener for scanner state changes. Only compiled when a consumer
/// requested a slot in codegen: register through
/// esp32_ble_tracker.register_scanner_state_listener() in your component's
/// to_code, which requests the slot and emits this call.
void add_scanner_state_listener(BLEScannerStateListener *listener) {
this->scanner_state_listeners_.push_back(listener);
}
#endif
ScannerState get_scanner_state() const { return this->scanner_state_; }
protected:
@@ -341,10 +288,6 @@ class ESP32BLETracker final : public Component,
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
StaticVector<ESPBTClient *, ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT> clients_;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT
StaticVector<BLEScannerStateListener *, ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT>
scanner_state_listeners_;
#endif
// Parsed listeners registered through the neutral BLEHub contract (migrated
// sensors); dispatched alongside listeners_.
@@ -352,6 +295,9 @@ class ESP32BLETracker final : public Component,
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> neutral_listeners_;
#endif
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
ble_device_base::ScannerStateCallback scanner_state_callback_{};
#endif
#ifdef USE_ESP32_BLE_DEVICE
/// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl)
ble_device_base::DiscoveredDeviceLog discovered_log_;
@@ -390,7 +336,6 @@ class ESP32BLETracker final : public Component,
bool scan_continuous_before_ota_{false};
#endif
bool ble_was_disabled_{true};
bool raw_advertisements_{false};
bool parse_advertisements_{false};
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false};
@@ -1,33 +1,59 @@
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_TRIGGER_ID
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
CODEOWNERS = ["@OttoWinter"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications")
ExposureNotification = exposure_notifications_ns.struct("ExposureNotification")
ExposureNotificationTrigger = exposure_notifications_ns.class_(
"ExposureNotificationTrigger",
esp32_ble_tracker.ESPBTDeviceListener,
ble_device_base.ESPBTDeviceListener,
automation.Trigger.template(ExposureNotification),
)
CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification"
_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications")
_VALIDATE_AUTOMATION = automation.validate_automation(
cv.Schema(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger),
}
# The trigger is the BLE listener, so the hub id lives on it.
).extend(ble_device_base.BLE_DEVICE_SCHEMA)
)
# validate_automation() needs a dict-based schema, so the rename cannot go
# inside it and has to run on the option value first. That value may also be a
# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so
# map over lists and let validate_automation() report anything else.
# schema_extractor keeps the key typed as a trigger in the generated editor
# schema; build_language_schema.py recurses into cv.All but not into a plain
# function.
@schema_extractor("automation")
def _validate_on_exposure_notification(value: Any) -> list[ConfigType]:
if value is SCHEMA_EXTRACT:
return _VALIDATE_AUTOMATION(value)
if isinstance(value, dict):
value = _RENAME_HUB_ID(value)
elif isinstance(value, list):
value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value]
return _VALIDATE_AUTOMATION(value)
CONFIG_SCHEMA = cv.Schema(
{
cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation(
cv.Schema(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
ExposureNotificationTrigger
),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
),
cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification,
}
)
@@ -36,4 +62,4 @@ async def to_code(config):
for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(ExposureNotification, "x")], conf)
await esp32_ble_tracker.register_ble_device(trigger, conf)
await ble_device_base.register_ble_device(trigger, conf)
@@ -2,11 +2,9 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::exposure_notifications {
using namespace esp32_ble_tracker;
using namespace ble_device_base;
static const char *const TAG = "exposure_notifications";
@@ -43,5 +41,3 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) {
}
} // namespace esphome::exposure_notifications
#endif
@@ -2,11 +2,9 @@
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include <array>
#ifdef USE_ESP32
namespace esphome::exposure_notifications {
struct ExposureNotification {
@@ -17,11 +15,9 @@ struct ExposureNotification {
};
class ExposureNotificationTrigger final : public Trigger<ExposureNotification>,
public esp32_ble_tracker::ESPBTDeviceListener {
public ble_device_base::ESPBTDeviceListener {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
};
} // namespace esphome::exposure_notifications
#endif
@@ -1,8 +1,6 @@
#include "inkbird_ibsth1_mini.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::inkbird_ibsth1_mini {
static const char *const TAG = "inkbird_ibsth1_mini";
@@ -15,7 +13,7 @@ void InkbirdIbstH1Mini::dump_config() {
LOG_SENSOR(" ", "Battery Level", this->battery_level_);
}
bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device) {
// The below is based on my research and reverse engineering of a single device
// It is entirely possible that some of that may be inaccurate or incomplete
@@ -32,7 +30,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false;
}
if (device.get_address_type() != BLE_ADDR_TYPE_PUBLIC) {
if (device.get_address_type() != ble_device_base::BLE_ADDR_TYPE_PUBLIC) {
ESP_LOGVV(TAG, "parse_device(): address is not public");
return false;
}
@@ -46,7 +44,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
return false;
}
const auto &mnf_data = mnf_datas[0];
if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) {
if (mnf_data.uuid.type() != ble_device_base::ESPBTUUID::Type::UUID16) {
ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16");
return false;
}
@@ -71,7 +69,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
auto external_temperature = NAN;
// Read bluetooth data into variable
auto measured_temperature = ((int16_t) mnf_data.uuid.get_uuid().uuid.uuid16) / 100.0f;
auto measured_temperature = ((int16_t) mnf_data.uuid.uuid16()) / 100.0f;
// Set temperature or external_temperature based on which sensor is in use
if (mnf_data.data[2] == 0) {
@@ -104,5 +102,3 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
}
} // namespace esphome::inkbird_ibsth1_mini
#endif
@@ -2,17 +2,15 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#ifdef USE_ESP32
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::inkbird_ibsth1_mini {
class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
void set_address(uint64_t address) { address_ = address; }
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
@@ -29,5 +27,3 @@ class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPB
};
} // namespace esphome::inkbird_ibsth1_mini
#endif
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import esp32_ble_tracker, sensor
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
@@ -18,14 +18,15 @@ from esphome.const import (
)
CODEOWNERS = ["@fkirill"]
DEPENDENCIES = ["esp32_ble_tracker"]
AUTO_LOAD = ["ble_device_base"]
inkbird_ibsth1_mini_ns = cg.esphome_ns.namespace("inkbird_ibsth1_mini")
InkbirdIbstH1Mini = inkbird_ibsth1_mini_ns.class_(
"InkbirdIbstH1Mini", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
"InkbirdIbstH1Mini", ble_device_base.ESPBTDeviceListener, cg.Component
)
CONFIG_SCHEMA = (
CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("inkbird_ibsth1_mini"),
cv.Schema(
{
cv.GenerateID(): cv.declare_id(InkbirdIbstH1Mini),
@@ -57,15 +58,15 @@ CONFIG_SCHEMA = (
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_ble_device(var, config)
await ble_device_base.register_ble_device(var, config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
+1 -1
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_TARGET_COUNT
import esphome.config_validation as cv
from esphome.const import (
CONF_ANGLE,
@@ -21,7 +22,6 @@ DEPENDENCIES = ["ld2450"]
CONF_MOVING_TARGET_COUNT = "moving_target_count"
CONF_STILL_TARGET_COUNT = "still_target_count"
CONF_TARGET_COUNT = "target_count"
ICON_ACCOUNT_GROUP = "mdi:account-group"
ICON_ACCOUNT_SWITCH = "mdi:account-switch"
+73
View File
@@ -0,0 +1,73 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_WAKEUP_PIN
from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE
CODEOWNERS = ["@hepter"]
DEPENDENCIES = ["uart"]
MULTI_CONF = True
ld6002b_ns = cg.esphome_ns.namespace("ld6002b")
LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice)
def _validate_wakeup_options(config):
"""Reject wake options that would silently do nothing.
Runs before the schema so the defaults for the keys below have not been
filled in yet and an explicit user value is still distinguishable from one.
"""
if not isinstance(config, dict):
return config
if CONF_WAKEUP_PIN in config:
return config
for key in (CONF_AUTO_WAKE, CONF_WAKEUP_PULSE):
if key in config:
raise cv.Invalid(
f"'{key}' requires '{CONF_WAKEUP_PIN}' to be configured", path=[key]
)
return config
CONFIG_SCHEMA = cv.All(
_validate_wakeup_options,
cv.Schema(
{
cv.GenerateID(): cv.declare_id(LD6002BComponent),
cv.Optional(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema,
cv.Optional(
CONF_WAKEUP_PULSE, default="50ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_AUTO_WAKE, default=True): cv.boolean,
}
)
.extend(uart.UART_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"ld6002b",
baud_rate=115200,
require_tx=True,
require_rx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if wakeup_pin_config := config.get(CONF_WAKEUP_PIN):
pin = await cg.gpio_pin_expression(wakeup_pin_config)
cg.add(var.set_wakeup_pin(pin))
cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds))
cg.add(var.set_auto_wake(config[CONF_AUTO_WAKE]))
@@ -0,0 +1,38 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY
from . import LD6002BComponent
from .const import CONF_LD6002B_ID, MAX_TARGETS
DEPENDENCIES = ["ld6002b"]
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_OCCUPANCY,
),
}
).extend(
{
cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_OCCUPANCY,
)
for i in range(MAX_TARGETS)
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if target_config := config.get(CONF_TARGET):
sens = await binary_sensor.new_binary_sensor(target_config)
cg.add(hub.set_presence_binary_sensor(sens))
for i in range(MAX_TARGETS):
if target_config := config.get(f"target_{i + 1}"):
sens = await binary_sensor.new_binary_sensor(target_config)
cg.add(hub.set_target_presence_binary_sensor(i, sens))
@@ -0,0 +1,101 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_WAKEUP_PIN,
ENTITY_CATEGORY_CONFIG,
ENTITY_CATEGORY_DIAGNOSTIC,
)
import esphome.final_validate as fv
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_GET_DELAY,
CONF_GET_INSTALLATION,
CONF_GET_LOW_POWER_MODE,
CONF_GET_LOW_POWER_SLEEP_TIME,
CONF_GET_SENSITIVITY,
CONF_GET_TRIGGER_SPEED,
CONF_GET_Z_RANGE,
CONF_LD6002B_ID,
CONF_RESET_UNATTENDED,
CONF_WAKE,
)
DEPENDENCIES = ["ld6002b"]
LD6002BButton = ld6002b_ns.class_("LD6002BButton", button.Button)
ButtonType = ld6002b_ns.enum("ButtonType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_GET_DELAY): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_SENSITIVITY): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_Z_RANGE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_INSTALLATION): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_RESET_UNATTENDED): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_WAKE): button.button_schema(
LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
}
)
def final_validate(config):
full_config = fv.full_config.get()
hub_id = config[CONF_LD6002B_ID]
if config.get(CONF_WAKE):
hub_path = full_config.get_path_for_id(hub_id)
hub_config = full_config.get_config_for_path(hub_path[:-1])
if hub_config.get(CONF_WAKEUP_PIN) is None:
raise cv.Invalid(
f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component",
path=[CONF_WAKE],
)
return config
FINAL_VALIDATE_SCHEMA = final_validate
BUTTON_MAP = {
CONF_GET_DELAY: ButtonType.GET_DELAY,
CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY,
CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED,
CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE,
CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION,
CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE,
CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME,
CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED,
CONF_WAKE: ButtonType.WAKE,
}
async def to_code(config):
for key, button_type in BUTTON_MAP.items():
if button_config := config.get(key):
b = cg.new_Pvariable(button_config[CONF_ID], button_type)
await button.register_button(b, button_config)
await cg.register_parented(b, config[CONF_LD6002B_ID])
@@ -0,0 +1,7 @@
#include "ld6002b_button.h"
namespace esphome::ld6002b {
void LD6002BButton::press_action() { this->parent_->press_button(this->type_); }
} // namespace esphome::ld6002b
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/components/button/button.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BButton : public button::Button, public Parented<LD6002BComponent> {
public:
explicit LD6002BButton(ButtonType type) : type_(type) {}
protected:
void press_action() override;
ButtonType type_;
};
} // namespace esphome::ld6002b
+29
View File
@@ -0,0 +1,29 @@
CONF_AUTO_WAKE = "auto_wake"
CONF_CLUSTER_ID = "cluster_id"
CONF_DOPPLER_INDEX = "doppler_index"
CONF_GET_DELAY = "get_delay"
CONF_GET_INSTALLATION = "get_installation"
CONF_GET_LOW_POWER_MODE = "get_low_power_mode"
CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time"
CONF_GET_SENSITIVITY = "get_sensitivity"
CONF_GET_TRIGGER_SPEED = "get_trigger_speed"
CONF_GET_Z_RANGE = "get_z_range"
CONF_HOLD_DELAY = "hold_delay"
CONF_INSTALLATION_MODE = "installation_mode"
CONF_LD6002B_ID = "ld6002b_id"
CONF_LOW_POWER = "low_power"
CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time"
CONF_OTA_VERSION = "ota_version"
CONF_POINT_CLOUD = "point_cloud"
CONF_POINT_COUNT = "point_count"
CONF_RESET_UNATTENDED = "reset_unattended"
CONF_TARGET_DISPLAY = "target_display"
CONF_TRIGGER_SPEED = "trigger_speed"
CONF_WAKE = "wake"
CONF_WAKEUP_PULSE = "wakeup_pulse"
CONF_WORK_MODE = "work_mode"
CONF_Z = "z"
CONF_Z_MAX = "z_max"
CONF_Z_MIN = "z_min"
MAX_TARGETS = 3
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/core/gpio.h"
#include "esphome/components/uart/uart.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#ifdef USE_NUMBER
#include "esphome/components/number/number.h"
#endif
#ifdef USE_SELECT
#include "esphome/components/select/select.h"
#endif
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
#include <array>
#include <cmath>
namespace esphome::ld6002b {
static constexpr uint8_t MAX_TARGETS = 3;
static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024;
static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096;
// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes.
static constexpr size_t CMD_MAX_DATA_LEN = 28;
enum class NumberType : uint8_t {
HOLD_DELAY,
Z_MIN,
Z_MAX,
LOW_POWER_SLEEP,
};
enum class SelectType : uint8_t {
SENSITIVITY,
TRIGGER_SPEED,
INSTALLATION_MODE,
};
enum class SwitchType : uint8_t {
LOW_POWER,
POINT_CLOUD,
TARGET_DISPLAY,
};
enum class ButtonType : uint8_t {
GET_DELAY,
GET_SENSITIVITY,
GET_TRIGGER_SPEED,
GET_Z_RANGE,
GET_INSTALLATION,
GET_LOW_POWER_MODE,
GET_LOW_POWER_SLEEP_TIME,
RESET_UNATTENDED,
WAKE,
};
#ifdef USE_SENSOR
struct TargetSensors {
sensor::Sensor *x{nullptr};
sensor::Sensor *y{nullptr};
sensor::Sensor *z{nullptr};
sensor::Sensor *dop_idx{nullptr};
sensor::Sensor *cluster_id{nullptr};
};
#endif
struct VersionPref {
char value[20];
};
class LD6002BComponent : public Component, public uart::UARTDevice {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::DATA; }
void set_wakeup_pin(GPIOPin *pin) { this->wakeup_pin_ = pin; }
void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; }
void set_auto_wake(bool enable) { this->auto_wake_ = enable; }
#ifdef USE_SENSOR
void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; }
void set_point_count_sensor(sensor::Sensor *sensor) { this->point_count_sensor_ = sensor; }
void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) {
if (target >= MAX_TARGETS)
return;
this->targets_[target].x = sensor;
}
void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) {
if (target >= MAX_TARGETS)
return;
this->targets_[target].y = sensor;
}
void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) {
if (target >= MAX_TARGETS)
return;
this->targets_[target].z = sensor;
}
void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) {
if (target >= MAX_TARGETS)
return;
this->targets_[target].dop_idx = sensor;
}
void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) {
if (target >= MAX_TARGETS)
return;
this->targets_[target].cluster_id = sensor;
}
#endif
#ifdef USE_BINARY_SENSOR
void set_presence_binary_sensor(binary_sensor::BinarySensor *sensor) { this->presence_binary_sensor_ = sensor; }
void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) {
if (target >= MAX_TARGETS)
return;
this->target_presence_[target] = sensor;
}
#endif
#ifdef USE_TEXT_SENSOR
void set_work_mode_text_sensor(text_sensor::TextSensor *sensor) { this->work_mode_text_sensor_ = sensor; }
void set_ota_version_text_sensor(text_sensor::TextSensor *sensor) { this->ota_version_text_sensor_ = sensor; }
#endif
#ifdef USE_NUMBER
void set_hold_delay_number(number::Number *number) { this->hold_delay_number_ = number; }
void set_z_min_number(number::Number *number) { this->z_min_number_ = number; }
void set_z_max_number(number::Number *number) { this->z_max_number_ = number; }
void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; }
#endif
#ifdef USE_SELECT
void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; }
void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; }
void set_installation_select(select::Select *select) { this->installation_select_ = select; }
#endif
#ifdef USE_SWITCH
void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; }
void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; }
void set_target_display_switch(switch_::Switch *sw) { this->target_display_switch_ = sw; }
#endif
void set_number_value(NumberType type, float value);
void set_select_value(SelectType type, size_t index);
void set_switch_state(SwitchType type, bool state);
void press_button(ButtonType type);
protected:
enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD };
struct PendingCommand {
uint16_t type{0};
uint8_t len{0};
std::array<uint8_t, CMD_MAX_DATA_LEN> data{};
};
void parse_byte_(uint8_t byte);
void reset_parser_();
void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len);
void handle_target_report_(const uint8_t *data, uint16_t len);
void handle_point_cloud_(const uint8_t *data, uint16_t len);
void handle_delay_report_(const uint8_t *data, uint16_t len);
void handle_sensitivity_report_(const uint8_t *data, uint16_t len);
void handle_trigger_speed_report_(const uint8_t *data, uint16_t len);
void handle_z_range_report_(const uint8_t *data, uint16_t len);
void handle_installation_report_(const uint8_t *data, uint16_t len);
void handle_low_power_report_(const uint8_t *data, uint16_t len);
void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len);
void handle_work_mode_report_(const uint8_t *data, uint16_t len);
void handle_version_report_(const uint8_t *data, uint16_t len);
void update_work_mode_fallback_();
void publish_work_mode_(bool low_power);
// Drops every target-derived reading and the slot table they are indexed by.
void clear_target_state_();
#ifdef USE_SENSOR
void clear_target_slot_(uint8_t index);
#endif
#ifdef USE_NUMBER
void publish_number_clamped_(number::Number *number, float value);
#endif
void init_version_pref_();
void save_version_pref_(const char *value);
void queue_command_(uint16_t type, const uint8_t *data, uint8_t len);
void process_command_queue_();
void send_command_(uint16_t type, const uint8_t *data, uint8_t len);
void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track);
void send_control_command_(uint32_t command);
void send_z_range_();
void wake_();
static uint16_t read_u16_be(const uint8_t *data);
static uint32_t read_u32_le(const uint8_t *data);
static int32_t read_int32_le(const uint8_t *data);
static float read_f32_le(const uint8_t *data);
static void write_u32_le(uint8_t *data, uint32_t value);
static void write_f32_le(uint8_t *data, float value);
#ifdef USE_SENSOR
std::array<TargetSensors, MAX_TARGETS> targets_{};
sensor::Sensor *target_count_sensor_{nullptr};
sensor::Sensor *point_count_sensor_{nullptr};
#endif
#ifdef USE_BINARY_SENSOR
binary_sensor::BinarySensor *presence_binary_sensor_{nullptr};
std::array<binary_sensor::BinarySensor *, MAX_TARGETS> target_presence_{};
#endif
#ifdef USE_TEXT_SENSOR
text_sensor::TextSensor *work_mode_text_sensor_{nullptr};
text_sensor::TextSensor *ota_version_text_sensor_{nullptr};
ESPPreferenceObject version_pref_{};
bool version_pref_initialized_{false};
#endif
#ifdef USE_NUMBER
number::Number *hold_delay_number_{nullptr};
number::Number *z_min_number_{nullptr};
number::Number *z_max_number_{nullptr};
number::Number *low_power_sleep_number_{nullptr};
#endif
#ifdef USE_SELECT
select::Select *sensitivity_select_{nullptr};
select::Select *trigger_speed_select_{nullptr};
select::Select *installation_select_{nullptr};
#endif
#ifdef USE_SWITCH
switch_::Switch *low_power_switch_{nullptr};
switch_::Switch *point_cloud_switch_{nullptr};
switch_::Switch *target_display_switch_{nullptr};
#endif
GPIOPin *wakeup_pin_{nullptr};
uint32_t wakeup_pulse_ms_{50};
bool auto_wake_{true};
ParseState parse_state_{ParseState::SOF};
uint8_t header_pos_{0};
uint8_t header_xor_{0};
uint16_t data_len_{0};
uint16_t frame_type_{0};
uint16_t frame_id_{0};
uint16_t data_pos_{0};
uint8_t data_xor_{0};
uint32_t discard_remaining_{0};
bool frame_oversize_{false};
size_t max_data_len_{0};
uint8_t *data_buf_{nullptr};
uint16_t next_frame_id_{0};
// Sized for the boot burst: with every platform configured, setup() enqueues
// roughly ten GET/config commands back to back before the first ack lands.
static constexpr uint8_t CMD_QUEUE_SIZE = 16;
static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300;
// A sleeping module consumes the first frame to wake and answers only the one after it.
static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600;
// How long the module stays awake after any frame, and so still answers the next one.
static constexpr uint32_t MODULE_AWAKE_MS = 10000;
static constexpr uint8_t CMD_MAX_RETRIES = 3;
// Named so a repeated press replaces its own pending timeout instead of stacking
// another, and so the command path can cancel it when it takes the pin over.
static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button";
// A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms.
static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000;
std::array<PendingCommand, CMD_QUEUE_SIZE> cmd_queue_{};
uint8_t cmd_head_{0};
uint8_t cmd_tail_{0};
uint8_t cmd_count_{0};
bool command_active_{false};
bool command_sent_{false};
PendingCommand active_command_{};
// Frame a pending wake pulse will write, snapshotted because active_command_ may move on first.
std::array<uint8_t, CMD_MAX_DATA_LEN> wake_scratch_{};
bool wake_pulse_pending_{false};
uint8_t retries_left_{0};
uint32_t last_send_ms_{0};
// Last frame seen in either direction; any traffic keeps the module awake.
uint32_t last_traffic_ms_{0};
// Frames transmitted for the command in flight, including retries; drives the retry budget.
uint8_t attempts_sent_{0};
// Subset of those the module can actually answer: a frame that woke it is consumed, not replied to.
uint8_t acks_expected_{0};
// ACKs still owed for superseded attempts; they carry no id, only their arrival order.
uint16_t stale_ack_type_{0};
uint8_t stale_ack_count_{0};
// When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK.
uint32_t stale_ack_ms_{0};
// Bumped whenever the active command changes, so a deferred send can tell it was retired.
uint8_t send_generation_{0};
float z_min_{NAN};
float z_max_{NAN};
// Which person owns each target_N slot, so a slot survives the module re-sorting its array.
std::array<int32_t, MAX_TARGETS> slot_cluster_{};
std::array<bool, MAX_TARGETS> slot_occupied_{};
bool target_presence_any_{false};
// What the switches and setup asked the module for, which is not the same as
// what it is doing yet: a stream keeps sending until it acts on the command.
// The report handlers read these and drop anything a stopped stream still emits.
bool target_display_enabled_{false};
bool point_cloud_enabled_{false};
bool work_mode_reported_{false};
bool low_power_enabled_{false};
bool low_power_reported_{false};
bool last_work_mode_valid_{false};
bool last_work_mode_low_power_{false};
#ifdef USE_SENSOR
std::array<bool, MAX_TARGETS> last_target_presence_{}; // one-shot NAN clear for target sensors
// A cluster id names a person, so like the counts it is published on change, not per frame.
std::array<int32_t, MAX_TARGETS> last_cluster_id_{};
std::array<bool, MAX_TARGETS> last_cluster_id_valid_{};
uint32_t last_target_count_{0xFFFFFFFF};
uint32_t last_point_count_{0xFFFFFFFF};
#endif
};
} // namespace esphome::ld6002b
@@ -0,0 +1,82 @@
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_DURATION,
ENTITY_CATEGORY_CONFIG,
UNIT_METER,
UNIT_MILLISECOND,
UNIT_SECOND,
)
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_HOLD_DELAY,
CONF_LD6002B_ID,
CONF_LOW_POWER_SLEEP_TIME,
CONF_Z_MAX,
CONF_Z_MIN,
)
DEPENDENCIES = ["ld6002b"]
LD6002BNumber = ld6002b_ns.class_("LD6002BNumber", number.Number)
NumberType = ld6002b_ns.enum("NumberType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_HOLD_DELAY): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_SECOND,
device_class=DEVICE_CLASS_DURATION,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_Z_MIN): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_Z_MAX): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_METER,
device_class=DEVICE_CLASS_DISTANCE,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema(
LD6002BNumber,
unit_of_measurement=UNIT_MILLISECOND,
device_class=DEVICE_CLASS_DURATION,
entity_category=ENTITY_CATEGORY_CONFIG,
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
for key, number_type, setter, min_value, max_value, step in (
(CONF_HOLD_DELAY, NumberType.HOLD_DELAY, "set_hold_delay_number", 0, 65535, 1),
(CONF_Z_MIN, NumberType.Z_MIN, "set_z_min_number", -10, 10, 0.1),
(CONF_Z_MAX, NumberType.Z_MAX, "set_z_max_number", -10, 10, 0.1),
# 0x0205 carries a uint32 of milliseconds; the vendor documents 500 ms as
# the default and no upper bound, so the range ends at a minute rather
# than at a default the module is free to be sleeping past.
(
CONF_LOW_POWER_SLEEP_TIME,
NumberType.LOW_POWER_SLEEP,
"set_low_power_sleep_number",
0,
60000,
100,
),
):
if conf := config.get(key):
n = await number.new_number(
conf, number_type, min_value=min_value, max_value=max_value, step=step
)
await cg.register_parented(n, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(n))
@@ -0,0 +1,10 @@
#include "ld6002b_number.h"
namespace esphome::ld6002b {
void LD6002BNumber::control(float value) {
this->publish_state(value);
this->parent_->set_number_value(this->type_, value);
}
} // namespace esphome::ld6002b
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/components/number/number.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BNumber : public number::Number, public Parented<LD6002BComponent> {
public:
explicit LD6002BNumber(NumberType type) : type_(type) {}
protected:
void control(float value) override;
NumberType type_;
};
} // namespace esphome::ld6002b
@@ -0,0 +1,60 @@
import esphome.codegen as cg
from esphome.components import select
import esphome.config_validation as cv
from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG
from .. import LD6002BComponent, ld6002b_ns
from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED
DEPENDENCIES = ["ld6002b"]
LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select)
SelectType = ld6002b_ns.enum("SelectType", is_class=True)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_SENSITIVITY): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_TRIGGER_SPEED): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
cv.Optional(CONF_INSTALLATION_MODE): select.select_schema(
LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG
),
}
)
SELECT_MAP = (
(
CONF_SENSITIVITY,
SelectType.SENSITIVITY,
"set_sensitivity_select",
["low", "medium", "high"],
),
(
CONF_TRIGGER_SPEED,
SelectType.TRIGGER_SPEED,
"set_trigger_speed_select",
["slow", "medium", "fast"],
),
(
CONF_INSTALLATION_MODE,
SelectType.INSTALLATION_MODE,
"set_installation_select",
["top", "side"],
),
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
for key, select_type, setter, options in SELECT_MAP:
if conf := config.get(key):
s = await select.new_select(conf, select_type, options=options)
await cg.register_parented(s, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(s))
@@ -0,0 +1,10 @@
#include "ld6002b_select.h"
namespace esphome::ld6002b {
void LD6002BSelect::control(size_t index) {
this->publish_state(index);
this->parent_->set_select_value(this->type_, index);
}
} // namespace esphome::ld6002b
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BSelect : public select::Select, public Parented<LD6002BComponent> {
public:
explicit LD6002BSelect(SelectType type) : type_(type) {}
protected:
void control(size_t index) override;
SelectType type_;
};
} // namespace esphome::ld6002b
+114
View File
@@ -0,0 +1,114 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_TARGET_COUNT
import esphome.config_validation as cv
from esphome.const import (
CONF_X,
CONF_Y,
DEVICE_CLASS_DISTANCE,
STATE_CLASS_MEASUREMENT,
UNIT_METER,
)
from . import LD6002BComponent
from .const import (
CONF_CLUSTER_ID,
CONF_DOPPLER_INDEX,
CONF_LD6002B_ID,
CONF_POINT_COUNT,
CONF_Z,
MAX_TARGETS,
)
DEPENDENCIES = ["ld6002b"]
# The ld2450 defaults for a streamed value: hold the last reading for a second so a
# dropped frame does not read as absence, then rate-limit what reaches the frontend.
_VALUE_SENSOR_FILTERS = [
{
"timeout": {
"timeout": cv.TimePeriod(milliseconds=1000),
"value": "last",
}
},
{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)},
]
TARGET_SCHEMA = cv.Schema(
{
cv.Optional(CONF_X): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
filters=_VALUE_SENSOR_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_Y): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
filters=_VALUE_SENSOR_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_Z): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
device_class=DEVICE_CLASS_DISTANCE,
filters=_VALUE_SENSOR_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema(
accuracy_decimals=0,
filters=_VALUE_SENSOR_FILTERS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema(
accuracy_decimals=0,
),
}
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
).extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)})
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if target_count_config := config.get(CONF_TARGET_COUNT):
sens = await sensor.new_sensor(target_count_config)
cg.add(hub.set_target_count_sensor(sens))
if point_count_config := config.get(CONF_POINT_COUNT):
sens = await sensor.new_sensor(point_count_config)
cg.add(hub.set_point_count_sensor(sens))
for i in range(MAX_TARGETS):
if target_config := config.get(f"target_{i + 1}"):
if x_config := target_config.get(CONF_X):
sens = await sensor.new_sensor(x_config)
cg.add(hub.set_target_x_sensor(i, sens))
if y_config := target_config.get(CONF_Y):
sens = await sensor.new_sensor(y_config)
cg.add(hub.set_target_y_sensor(i, sens))
if z_config := target_config.get(CONF_Z):
sens = await sensor.new_sensor(z_config)
cg.add(hub.set_target_z_sensor(i, sens))
if doppler_index_config := target_config.get(CONF_DOPPLER_INDEX):
sens = await sensor.new_sensor(doppler_index_config)
cg.add(hub.set_target_dop_idx_sensor(i, sens))
if cluster_id_config := target_config.get(CONF_CLUSTER_ID):
sens = await sensor.new_sensor(cluster_id_config)
cg.add(hub.set_target_cluster_id_sensor(i, sens))
@@ -0,0 +1,60 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG
from .. import LD6002BComponent, ld6002b_ns
from ..const import (
CONF_LD6002B_ID,
CONF_LOW_POWER,
CONF_POINT_CLOUD,
CONF_TARGET_DISPLAY,
)
DEPENDENCIES = ["ld6002b"]
LD6002BSwitch = ld6002b_ns.class_("LD6002BSwitch", switch.Switch)
SwitchType = ld6002b_ns.enum("SwitchType", is_class=True)
# None of these three carry an inversion. They name what the module is doing, not
# how something is wired to it, so an inverted one would only report the opposite
# of the truth -- and the boot restore, which applies a state nothing reports back,
# is where that would be hardest to spot.
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_LOW_POWER): switch.switch_schema(
LD6002BSwitch,
block_inverted=True,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_POINT_CLOUD): switch.switch_schema(
LD6002BSwitch,
block_inverted=True,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
),
cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema(
LD6002BSwitch,
block_inverted=True,
device_class=DEVICE_CLASS_SWITCH,
entity_category=ENTITY_CATEGORY_CONFIG,
default_restore_mode="RESTORE_DEFAULT_ON",
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
for key, switch_type, setter in (
(CONF_LOW_POWER, SwitchType.LOW_POWER, "set_low_power_switch"),
(CONF_POINT_CLOUD, SwitchType.POINT_CLOUD, "set_point_cloud_switch"),
(CONF_TARGET_DISPLAY, SwitchType.TARGET_DISPLAY, "set_target_display_switch"),
):
if conf := config.get(key):
s = await switch.new_switch(conf, switch_type)
await cg.register_parented(s, config[CONF_LD6002B_ID])
cg.add(getattr(hub, setter)(s))
@@ -0,0 +1,10 @@
#include "ld6002b_switch.h"
namespace esphome::ld6002b {
void LD6002BSwitch::write_state(bool state) {
this->parent_->set_switch_state(this->type_, state);
this->publish_state(state);
}
} // namespace esphome::ld6002b
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/components/switch/switch.h"
#include "../ld6002b.h"
namespace esphome::ld6002b {
class LD6002BSwitch : public switch_::Switch, public Parented<LD6002BComponent> {
public:
explicit LD6002BSwitch(SwitchType type) : type_(type) {}
protected:
void write_state(bool state) override;
SwitchType type_;
};
} // namespace esphome::ld6002b
+31
View File
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC
from . import LD6002BComponent
from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE
DEPENDENCIES = ["ld6002b"]
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent),
cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema(
entity_category=ENTITY_CATEGORY_DIAGNOSTIC
),
}
)
async def to_code(config):
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if work_mode_config := config.get(CONF_WORK_MODE):
sens = await text_sensor.new_text_sensor(work_mode_config)
cg.add(hub.set_work_mode_text_sensor(sens))
if ota_config := config.get(CONF_OTA_VERSION):
sens = await text_sensor.new_text_sensor(ota_config)
cg.add(hub.set_ota_version_text_sensor(sens))
@@ -29,6 +29,8 @@ DEPENDENCIES = ["ln882x"]
AUTO_LOAD = ["ble_device_base", "ln882h_ble"]
CODEOWNERS = ["@Bl00d-B0b"]
ble_device_base.register_hub_provider("ln882h_ble_tracker")
ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker")
LN882HBLETracker = ln882h_ble_tracker_ns.class_(
"LN882HBLETracker", ble_device_base.BLEHub, cg.Component
@@ -125,6 +127,9 @@ async def stop_scan_action_to_code(
async def to_code(config: ConfigType) -> None:
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
cg.add_define("USE_LN882H_BLE_TRACKER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -240,8 +240,11 @@ void LN882HBLETracker::process_adv_(const uint8_t *mac, int8_t rssi, uint8_t add
// Raw callback (the raw-advertisement path). Both full advertisements and
// unmatched scan responses (raw_only) are forwarded.
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{
.mac = mac, .data = data, .data_len = data_len, .rssi = rssi, .addr_type = addr_type};
const ble_device_base::RawAdvertisement adv{.address = ble_device_base::mac_lsb_first_to_uint64(mac),
.data = data,
.data_len = data_len,
.rssi = rssi,
.addr_type = addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
@@ -26,7 +26,6 @@ namespace esphome::ln882h_ble_tracker {
// ---------------------------------------------------------------------------
class LN882HBLETracker : public Component,
public ble_device_base::BLEHub,
public Parented<ln882h_ble::LN882HBLE>,
public ln882h_ble::BLEScanListener
#ifdef USE_OTA_STATE_LISTENER
@@ -75,15 +74,15 @@ class LN882HBLETracker : public Component,
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The LN882H controller supports active scanning; adv + scan response arrive
// as separate reports and are merged by this tracker (Bluedroid semantics).
// The SDK's GATT client is not exposed.
@@ -92,15 +91,15 @@ class LN882HBLETracker : public Component,
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override {
void get_adapter_mac(uint8_t out[6]) {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return this->scan_active_; }
bool request_scan_mode(bool active) override;
bool scan_running() { return this->scan_running_; }
bool scan_active() { return this->scan_active_; }
bool request_scan_mode(bool active);
// ---- ln882h_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
@@ -18,6 +18,13 @@
namespace esphome::midea {
// Mirrors the ARDUINO switch in MideaUART Helpers/Platform.h: these types
// exist in the dudanov namespace exactly when the library is not on Arduino
#ifndef ARDUINO
using dudanov::Stream;
using dudanov::String;
#endif
/* Stream from UART component */
class UARTStream : public Stream {
public:
+1 -1
View File
@@ -313,5 +313,5 @@ async def to_code(config):
cg.add_library(
name="MideaUART",
version=None,
repository="https://github.com/dudanov/MideaUART.git#7a4d1e9a4b6f07a3464c2453ee828c0c7b7e1bcf",
repository="https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815",
)
+30 -3
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import Literal
from typing import Any, Literal
from esphome import pins
import esphome.codegen as cg
@@ -14,6 +14,20 @@ import esphome.final_validate as fv
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ["uart"]
# Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is
# generated unless a config uses one).
AUTO_LOAD = ["modbus_client"]
# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC.
MAX_PDU_SIZE = 253
# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the
# C++ constants of the same name; the spec sets a different ceiling for each function code.
MAX_NUM_OF_COILS_TO_READ = 2000
MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000
MAX_NUM_OF_COILS_TO_WRITE = 1968
MAX_NUM_OF_REGISTERS_TO_READ = 125
MAX_NUM_OF_REGISTERS_TO_WRITE = 123
modbus_ns = cg.esphome_ns.namespace("modbus")
Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice)
@@ -85,15 +99,28 @@ async def to_code(config):
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
def _validate_server_address(value: Any) -> int:
address = cv.hex_uint8_t(value)
# The broadcast address (0) is delivered to every device and is never answered (Modbus 4.1),
# so it cannot identify an individual server device.
if address == 0:
raise cv.Invalid(
"Address 0 is the Modbus broadcast address and cannot be used as a "
"server device address. Assign a unique unit address instead."
)
return address
def modbus_device_schema(default_address, role: Literal["client", "server"] = "client"):
hub_type = ModbusClient if role == "client" else ModbusServer
address_validator = _validate_server_address if role == "server" else cv.hex_uint8_t
schema = {
cv.GenerateID(CONF_MODBUS_ID): cv.use_id(hub_type),
}
if default_address is None:
schema[cv.Required(CONF_ADDRESS)] = cv.hex_uint8_t
schema[cv.Required(CONF_ADDRESS)] = address_validator
else:
schema[cv.Optional(CONF_ADDRESS, default=default_address)] = cv.hex_uint8_t
schema[cv.Optional(CONF_ADDRESS, default=default_address)] = address_validator
return cv.Schema(schema)
+215 -53
View File
@@ -15,6 +15,9 @@ static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11;
// Milliseconds per second
static constexpr uint32_t MS_PER_SEC = 1000;
// Shortest gap between two "no device accepted broadcast" warnings
static constexpr uint32_t UNACCEPTED_BROADCAST_WARN_INTERVAL_MS = 60 * MS_PER_SEC;
void Modbus::setup() {
if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->setup();
@@ -183,6 +186,10 @@ void ModbusServerHub::parse_modbus_frames() {
size_t size = this->rx_buffer_.size();
ESP_LOGVV(TAG, "Parsing frames buffer size = %" PRIu32, size);
bool retry_as_client = false;
// A broadcast is a client request, never a peer response; clear any stale expectation (RTU is half-duplex).
const bool is_broadcast = this->rx_buffer_[0] == BROADCAST_ADDRESS;
if (is_broadcast)
this->expecting_peer_response_ = 0;
if (this->expecting_peer_response_ != 0) {
if (!this->parse_modbus_server_frame_()) {
ESP_LOGV(TAG, "Stop expecting peer response from %" PRIu8 " due to parse failure, and retry parse",
@@ -277,11 +284,17 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
// This requires copying the frame data to a local buffer beforehand.
uint8_t data_offset = helpers::client_frame_data_offset(this->rx_buffer_.data(), this->rx_buffer_.size());
uint16_t data_len = frame_length - 2 - data_offset;
uint8_t data[MAX_FRAME_SIZE] = {};
std::memcpy(data, this->rx_buffer_.data() + data_offset, data_len);
uint8_t data_buffer[MAX_FRAME_SIZE] = {};
std::memcpy(data_buffer, this->rx_buffer_.data() + data_offset, data_len);
std::span<const uint8_t> data(data_buffer, data_len);
this->clear_rx_buffer_(LOG_STR("parse succeeded"), false, frame_length);
this->process_modbus_client_frame_(address, function_code, data);
if (address == BROADCAST_ADDRESS) {
// Keep the unicast response buffers out of the broadcast call chain.
this->process_broadcast_frame_(function_code, data);
} else {
this->process_modbus_client_frame_(address, function_code, data);
}
return true;
}
@@ -365,18 +378,148 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) {
return nullptr;
}
bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers) {
ResponseStatus ModbusServerHub::check_register_range_(uint16_t start_address, uint16_t number_of_registers) {
if ((uint32_t) start_address + number_of_registers > 0x10000u) {
ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address,
number_of_registers);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_ADDRESS);
return ExceptionCode::ILLEGAL_DATA_ADDRESS;
}
return std::nullopt;
}
// Write PDU layout after the function code: start address(2) [+ quantity(2) + byte count(1)] + register values.
// The value subspans taken at these offsets stay in range because client_pdu_length() clamps the byte count to the
// same maximum the callers' number_of_registers * 2 == number_of_bytes guard enforces.
static constexpr size_t WRITE_SINGLE_VALUES_OFFSET = 2;
static constexpr size_t WRITE_MULTIPLE_VALUES_OFFSET = 5;
// FC 0x17 writes follow read start(2) + read quantity(2) + write start(2) + write quantity(2) + byte count(1).
static constexpr size_t READ_WRITE_VALUES_OFFSET = 9;
ResponseStatus ModbusServerHub::parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers) {
start_address = helpers::get_data<uint16_t>(data.data(), 0);
// No range check needed: one register can never push start_address + 1 past the address space.
this->assemble_registers_(data.subspan(WRITE_SINGLE_VALUES_OFFSET, sizeof(uint16_t)), registers);
return std::nullopt;
}
ResponseStatus ModbusServerHub::parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers) {
start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 4);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE ||
number_of_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, number_of_bytes);
return ExceptionCode::ILLEGAL_DATA_VALUE;
}
if (ResponseStatus status = this->check_register_range_(start_address, number_of_registers); status.has_value()) {
return status;
}
this->assemble_registers_(data.subspan(WRITE_MULTIPLE_VALUES_OFFSET, number_of_bytes), registers);
return std::nullopt;
}
void ModbusServerHub::assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers) {
for (size_t offset = 0; offset + 1 < values.size(); offset += 2) {
registers.push_back(helpers::get_data<uint16_t>(values.data(), offset));
}
}
void ModbusServerHub::process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data) {
// Broadcasts are only meaningful for register writes and are never answered (Modbus 4.1 / 6.12), so an
// unsupported function code or a validation failure is silently dropped instead of replying with an exception.
// Coil writes (FC 0x05/0x0F) are also broadcastable by spec, but server coil handlers are not implemented yet.
uint16_t start_address;
RegisterValues registers;
ResponseStatus status;
switch (static_cast<FunctionCode>(function_code)) {
case FunctionCode::WRITE_SINGLE_REGISTER:
status = this->parse_write_single_(data, start_address, registers);
break;
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
status = this->parse_write_multiple_(data, start_address, registers);
break;
default:
// Reads and read/write require a reply, so they are not valid as broadcasts.
ESP_LOGV(TAG, "Ignoring broadcast with unsupported function code %" PRIu8, function_code);
return;
}
if (status.has_value()) {
return;
}
// A broadcast is never answered, so a rejecting device has no other feedback channel: report the
// per-device outcome at V, and warn if the write reached nobody at all.
bool accepted = false;
for (auto *device : this->devices_) {
if (ResponseStatus device_status = device->on_broadcast_write_registers(start_address, registers);
device_status.has_value()) {
ESP_LOGV(TAG, "Device %" PRIu8 " rejected broadcast write with exception %" PRIu8, device->get_address(),
static_cast<uint8_t>(device_status.value()));
} else {
accepted = true;
}
}
if (!accepted && !this->devices_.empty()) {
// Warn at most once per interval, then drop to VERBOSE: on a shared bus a broadcast aimed at other nodes
// repeats forever, so warning per frame would flood the log.
const uint32_t now = millis();
if (this->last_unaccepted_broadcast_warn_ == 0 ||
now - this->last_unaccepted_broadcast_warn_ > UNACCEPTED_BROADCAST_WARN_INTERVAL_MS) {
this->last_unaccepted_broadcast_warn_ = now;
ESP_LOGW(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
} else {
ESP_LOGV(TAG, "No device accepted broadcast write of %zu registers at 0x%04X", registers.size(), start_address);
}
}
}
bool ModbusServerHub::build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len) {
// A handler that returns an exception leaves registers partially filled, so check the exception
// first and forward it before validating the register count on the success path.
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return false;
}
if (registers.size() != number_of_registers) {
ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size());
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return false;
}
// The byte count is a single byte, so the count must stay within the protocol read limit; above it the
// static_cast<uint8_t>(number_of_registers * 2) below would silently truncate the byte count.
if (number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGE(TAG, "Read response of %" PRIu16 " registers exceeds the limit of %" PRIu16, number_of_registers,
MAX_NUM_OF_REGISTERS_TO_READ);
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return false;
}
// Byte count(1) + two bytes per register. Checked here rather than at the call sites so the bound travels with
// the write itself: a future caller starting at a non-zero response_len, or passing a smaller buffer, is
// rejected instead of overrunning it before send_response_'s size guard can fire.
const size_t required = static_cast<size_t>(response_len) + 1 + static_cast<size_t>(number_of_registers) * 2;
if (required > response_buffer.size()) {
ESP_LOGE(TAG, "Read response needs %zu bytes but only %zu are available", required, response_buffer.size());
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return false;
}
response_buffer[response_len++] = static_cast<uint8_t>(number_of_registers * 2); // actual byte count
for (auto r : registers) {
auto register_bytes = decode_value(r);
response_buffer[response_len++] = register_bytes[0];
response_buffer[response_len++] = register_bytes[1];
}
return true;
}
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) {
void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code,
std::span<const uint8_t> data) {
ModbusServerDevice *device = this->find_device_(address);
if (device == nullptr) {
this->expecting_peer_response_ = address;
@@ -393,14 +536,16 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS: {
// PDU data: start address(2) + quantity(2).
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data, 2);
uint16_t start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
status = this->check_register_range_(start_address, number_of_registers);
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
RegisterValues registers;
@@ -410,61 +555,78 @@ void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t func
status = device->on_read_input_registers(start_address, number_of_registers, registers);
}
// A handler that returns an exception leaves registers partially filled, so check the exception
// first and forward it before validating the register count on the success path.
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers,
response_buffer, response_len)) {
return;
}
if (registers.size() != number_of_registers) {
ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size());
this->send_exception_(address, function_code, ExceptionCode::SERVICE_DEVICE_FAILURE);
return;
}
response_buffer[response_len++] = static_cast<uint8_t>(number_of_registers * 2); // actual byte count
for (auto r : registers) {
auto register_bytes = decode_value(r);
response_buffer[response_len++] = register_bytes[0];
response_buffer[response_len++] = register_bytes[1];
}
break;
}
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_REGISTERS: {
// PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values.
// A single-register write always targets one register; for a multiple-register write the
// quantity is in the frame and its byte count must equal quantity * 2. The register values are
// assembled into registers below so the handler doesn't have to know the request framing.
uint16_t start_address = helpers::get_data<uint16_t>(data, 0);
uint16_t number_of_registers = 1;
uint16_t values_offset = 2; // single write: values follow the 2-byte start address
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_MULTIPLE_REGISTERS) {
number_of_registers = helpers::get_data<uint16_t>(data, 2);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data, 4);
values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1)
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE ||
number_of_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers,
number_of_bytes);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) {
return;
}
}
// Assemble the register values (host byte order) so the handler never sees wire framing.
// Parse and validate the write PDU into host-order register values; reply with an exception on failure.
uint16_t start_address;
RegisterValues registers;
for (uint16_t i = 0; i < number_of_registers; i++) {
registers.push_back(helpers::get_data<uint16_t>(data, values_offset + i * 2));
if (static_cast<FunctionCode>(function_code) == FunctionCode::WRITE_SINGLE_REGISTER) {
status = this->parse_write_single_(data, start_address, registers);
} else {
status = this->parse_write_multiple_(data, start_address, registers);
}
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
status = device->on_write_registers(start_address, registers);
response_data = data; // echo the request header per Modbus 6.6, 6.12
response_data = data.data(); // echo the request header per Modbus 6.6, 6.12
response_len = 4;
break;
}
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: {
// PDU data: read start address(2) + read quantity(2) + write start address(2) + write quantity(2) +
// write byte count(1) + write register values. Per Modbus 6.17 the write is performed before the read.
uint16_t read_start_address = helpers::get_data<uint16_t>(data.data(), 0);
uint16_t number_of_registers = helpers::get_data<uint16_t>(data.data(), 2);
uint16_t write_start_address = helpers::get_data<uint16_t>(data.data(), 4);
uint16_t number_of_write_registers = helpers::get_data<uint16_t>(data.data(), 6);
uint8_t number_of_bytes = helpers::get_data<uint8_t>(data.data(), 8);
if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ ||
number_of_write_registers == 0 || number_of_write_registers > MAX_NUM_OF_REGISTERS_TO_WRITE_RW ||
number_of_write_registers * 2 != number_of_bytes) {
ESP_LOGW(TAG, "Invalid number of registers (read %" PRIu16 ", write %" PRIu16 ") or bytes %" PRIu8,
number_of_registers, number_of_write_registers, number_of_bytes);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
status = this->check_register_range_(read_start_address, number_of_registers);
if (!status.has_value()) {
status = this->check_register_range_(write_start_address, number_of_write_registers);
}
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
// Perform the write first (Modbus 6.17). Scoped so the write values are off the stack before the read
// values are allocated, keeping only one RegisterValues buffer live at a time.
{
RegisterValues write_registers;
this->assemble_registers_(data.subspan(READ_WRITE_VALUES_OFFSET, number_of_bytes), write_registers);
// Dispatch to the standalone write and read handlers so any device implementing those supports 0x17
// without a dedicated handler; a device that maps registers by address reconstructs the read response
// from the values it just stored.
status = device->on_write_registers(write_start_address, write_registers);
}
if (status.has_value()) {
this->send_exception_(address, function_code, status.value());
return;
}
RegisterValues registers;
status = device->on_read_holding_registers(read_start_address, number_of_registers, registers);
if (!this->build_or_reject_read_response_(address, function_code, status, number_of_registers, registers,
response_buffer, response_len)) {
return;
}
break;
}
default:
ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code);
this->send_exception_(address, function_code, ExceptionCode::ILLEGAL_FUNCTION);
+45 -13
View File
@@ -312,6 +312,14 @@ class ModbusClientHub : public Modbus {
std::deque<ModbusDeviceCommand> tx_buffer_;
};
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
class ModbusServerHub : public Modbus {
public:
ModbusServerHub() = default;
@@ -322,27 +330,47 @@ class ModbusServerHub : public Modbus {
void parse_modbus_frames() override;
bool parse_modbus_client_frame_();
void process_modbus_server_frame(uint8_t address, std::span<const uint8_t> pdu) override;
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data);
void process_modbus_client_frame_(uint8_t address, uint8_t function_code, std::span<const uint8_t> data);
// Dispatches a broadcast (address 0) write to every registered device; broadcasts are never answered.
void process_broadcast_frame_(uint8_t function_code, std::span<const uint8_t> data);
// Parses a WRITE_SINGLE_REGISTER / WRITE_MULTIPLE_REGISTERS PDU into start_address and the host-order register
// values, validating the register count and address range. Returns std::nullopt on success, otherwise the Modbus
// exception code describing the failure. Shared by unicast writes (which reply with the exception) and broadcast
// writes (which silently drop invalid frames).
ResponseStatus parse_write_single_(std::span<const uint8_t> data, uint16_t &start_address, RegisterValues &registers);
ResponseStatus parse_write_multiple_(std::span<const uint8_t> data, uint16_t &start_address,
RegisterValues &registers);
// Appends the big-endian register values in values to registers, in host byte order.
void assemble_registers_(std::span<const uint8_t> values, RegisterValues &registers);
ModbusServerDevice *find_device_(uint8_t address);
// Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space.
// On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client.
bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers);
// Returns std::nullopt if [start_address, start_address + number_of_registers) fits in the 16-bit address space,
// otherwise ILLEGAL_DATA_ADDRESS. The caller sends the exception reply if one is required.
ResponseStatus check_register_range_(uint16_t start_address, uint16_t number_of_registers);
// Builds the body of a register read response (byte count followed by the big-endian register values) into
// response_buffer. Shared by every function code that answers with register values, so the read reply stays
// identical across them. Returns false once an exception has been sent: the one the handler reported via
// status, or SERVICE_DEVICE_FAILURE if it returned the wrong number of registers, the count exceeds the
// protocol read limit, or the body does not fit.
bool build_or_reject_read_response_(uint8_t address, uint8_t function_code, ResponseStatus status,
uint16_t number_of_registers, const RegisterValues &registers,
std::span<uint8_t> response_buffer, uint16_t &response_len);
void send_raw_(const uint8_t *payload, uint16_t len);
void send_exception_(uint8_t address, uint8_t function_code, ExceptionCode exception_code);
void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len);
uint8_t expecting_peer_response_{0};
std::vector<ModbusServerDevice *> devices_;
// Stamp of the last "broadcast reached no device" warning, 0 until the first one is logged. Rate limiting
// on time rather than on address keeps the log bounded no matter how many addresses a shared bus carries.
uint32_t last_unaccepted_broadcast_warn_{0};
// Holds the raw payload of a single reply deferred for sending when tx was blocked at send time.
// Only one server reply can be waiting at once, so a single fixed buffer avoids heap allocation.
std::array<uint8_t, MAX_RAW_SIZE> deferred_payload_;
uint16_t deferred_payload_len_{0};
};
// Transaction status: std::nullopt on success, otherwise a Modbus exception code
using ResponseStatus = std::optional<ExceptionCode>;
/// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data),
/// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by
/// clear_tx_queue_for_address before transmission). A request refused at send_pdu() (false return)
@@ -563,11 +591,6 @@ class ESPDEPRECATED("Subclass ModbusClientDevice and override on_response()/on_e
}
};
// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol
// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by
// the capacity of this type.
using RegisterValues = StaticVector<uint16_t, MAX_NUM_OF_REGISTERS_TO_READ>;
class ModbusServerDevice {
public:
virtual ~ModbusServerDevice() = default;
@@ -594,9 +617,18 @@ class ModbusServerDevice {
virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues &registers) {
return ExceptionCode::ILLEGAL_FUNCTION;
};
// Hub entry point for broadcast (address 0) writes, which are never answered.
ResponseStatus on_broadcast_write_registers(uint16_t start_address, const RegisterValues &registers) {
this->broadcast_write_ = true;
ResponseStatus status = this->on_write_registers(start_address, registers);
this->broadcast_write_ = false;
return status;
}
protected:
uint8_t address_{0};
// Set while handling a broadcast write: the caller sends no reply, so a rejection has no wire consequence.
bool broadcast_write_{false};
};
} // namespace esphome::modbus
+10 -6
View File
@@ -33,12 +33,12 @@ enum class FunctionCode : uint8_t {
GET_COMM_EVENT_LOG = 0x0C, // not implemented
WRITE_MULTIPLE_COILS = 0x0F,
WRITE_MULTIPLE_REGISTERS = 0x10,
REPORT_SERVER_ID = 0x11, // not implemented
READ_FILE_RECORD = 0x14, // not implemented
WRITE_FILE_RECORD = 0x15, // not implemented
MASK_WRITE_REGISTER = 0x16, // not implemented
READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented
READ_FIFO_QUEUE = 0x18, // not implemented
REPORT_SERVER_ID = 0x11, // not implemented
READ_FILE_RECORD = 0x14, // not implemented
WRITE_FILE_RECORD = 0x15, // not implemented
MASK_WRITE_REGISTER = 0x16, // not implemented
READ_WRITE_MULTIPLE_REGISTERS = 0x17,
READ_FIFO_QUEUE = 0x18, // not implemented
};
// Remove before 2027.2.0
@@ -116,6 +116,10 @@ static constexpr uint16_t READ_PDU_SIZE = 5;
// A single-write PDU is always function code(1) + address(2) + value(2)
static constexpr uint16_t WRITE_SINGLE_PDU_SIZE = 5;
static constexpr uint16_t MAX_FRAME_SIZE = 256;
// 4.1 Address 0 is the broadcast address: the request is processed by every device and never answered.
static constexpr uint8_t BROADCAST_ADDRESS = 0;
// Both send paths bound their payload so the framed result lands exactly on the RTU limit: a client
// PDU gains an address byte and a CRC, a raw server frame gains a CRC. send_frame_() therefore never
// has to check the framed size - it cannot be exceeded.

Some files were not shown because too many files have changed in this diff Show More