Compare commits

..
Author SHA1 Message Date
J. Nick Koston ec681bb4fb Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-20 00:47:07 -05:00
J. Nick Koston f17b18d51a Merge remote-tracking branch 'upstream/dev' into neutral-ble-client 2026-08-20 00:45:33 -05:00
esphome[bot]andGitHub 5e9de7c94b Bump bundled esphome-device-builder to 1.12.1 (#18541) 2026-08-20 00:00:44 -05:00
Markus HällandGitHub b7d0b676fc [wifi] Take the lwIP core lock around sntp_servermode_dhcp() (#18511) 2026-08-19 23:31:27 -05:00
Jesse Hills 55b45fc6fb Merge branch 'release' into dev 2026-08-20 14:32:46 +12:00
Jesse HillsandGitHub 6f8dbb6fbc Merge pull request #18527 from esphome/bump-2026.8.0
2026.8.0
2026-08-20 14:32:15 +12:00
J. Nick KostonandJesse Hills ca97c86d65 [ci] Install requirements_dev.txt when the venv cache misses (#18502) 2026-08-20 13:12:33 +12:00
Jesse Hills 828eac90f3 Bump version to 2026.8.0 2026-08-20 11:51:59 +12:00
J. Nick KostonandJesse Hills d9359a70c1 [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) 2026-08-20 11:51:57 +12:00
Jesse Hills 29404a782c Merge branch 'beta' into dev 2026-08-20 10:32:46 +12:00
Jesse HillsandGitHub e75a7a61fa Merge pull request #18523 from esphome/bump-2026.8.0b6
2026.8.0b6
2026-08-20 10:32:26 +12:00
J. Nick KostonandGitHub 185f12266a [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) 2026-08-19 17:29:10 -05:00
J. Nick KostonandJesse Hills c455991962 [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) 2026-08-20 09:38:44 +12:00
Jesse Hills f735dcadc0 Bump version to 2026.8.0b6 2026-08-20 09:33:05 +12:00
J. Nick KostonandJesse Hills 78a65eabdc [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) 2026-08-20 09:33:03 +12:00
b3fda9973e [image] Restore defaults:/files: support for platform entries (#18032)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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-20 09:33:03 +12:00
esphome[bot]andJesse Hills 4a85c98285 Bump bundled esphome-device-builder to 1.12.0 (#18514) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 2c92a2498e Bump bundled esphome-device-builder to 1.11.5 (#18507) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 74e22b5ad7 Bump bundled esphome-device-builder to 1.11.4 (#18506) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills e9e77d02a0 Bump bundled esphome-device-builder to 1.11.3 (#18505) 2026-08-20 09:33:03 +12:00
Jonathan SwobodaandJesse Hills 7418fcce8d [ci] Stop persisting the integration test ccache (#18504) 2026-08-20 09:33:03 +12:00
Jonathan SwobodaandJesse Hills b768e2a1ce [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 6084314cc9 [vscode] Report the origin of an unexpected exception during validation (#18494) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 2df953f3d7 [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) 2026-08-20 09:33:03 +12:00
10e592fa3a [modbus] CRC scan all unknown function codes (#18483)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills a99a8f364e [api] Bump noise-c to 0.1.21 (#18484) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 200a1644a5 [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 9daae377fc [api] Bump noise-c to 0.1.20 (#18482) 2026-08-20 09:33:02 +12:00
J. Nick KostonandGitHub 0b1065feee [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) 2026-08-20 09:30:34 +12:00
Clyde StubbsandGitHub 47da743d11 [ai] Add instructions for concise comments (#18522) 2026-08-19 16:09:12 -05:00
470226ca03 [image] Restore defaults:/files: support for platform entries (#18032)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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-19 12:30:12 -05:00
esphome[bot]andGitHub 7b7107556f Bump bundled esphome-device-builder to 1.12.0 (#18514) 2026-08-19 12:21:58 -05:00
J. Nick KostonandGitHub f90b776071 [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) 2026-08-19 16:39:01 +00:00
J. Nick Koston 3ee3d1b353 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-19 10:30:50 -05:00
J. Nick Koston 5f11c430cc Emit the materializer define only for the backend that has one
The registry entry now carries its materializer gate, so rp2 builds stop
receiving a Bluedroid define. Also: parent before the capacity check in
register_gatt_node (neutral parity) and BluedroidServiceTable is
non-copyable (owning raw pointer).
2026-08-19 10:30:43 -05:00
J. Nick Koston 3fb5aa401b Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-19 09:18:06 -05:00
J. Nick Koston baf57195e2 Name the materializer define for what it gates
USE_BLUEDROID_GATT_SERVICE_TABLE compiles the Bluedroid materializer and
nothing else; the old name read like a cross-backend consumer capability
(rp2 always materializes and ignores it).
2026-08-19 09:15:17 -05:00
J. Nick Koston 27a97f36af Key backend and engine selection on their own defines
Each backend arm now has a codegen-emitted USE_BLE_GATT_BACKEND_<NAME>
define, making the alias ladder order-independent and letting the proxy
capability constants describe the backend instead of the platform (a second
esp32 backend carries its own facts). The legacy raw-gattc engine is
selected by USE_BLE_CLIENT_LEGACY_ENGINE instead of platform identity, so
esp32's future move to the neutral engine is one line per language. The
address-scoped maintenance calls key on the stack define they wrap.
2026-08-19 09:14:17 -05:00
J. Nick Koston 0a28a5b023 Document the contract facts a backend author needs
Connect-timing ownership (the backend integrates with its platform's
scan/connect arbitration), the asynchronous-completion rule for accepted
ops, and the reserved error ranges with the wire-frozen values - all learned
the hard way by the first out-of-tree backend.
2026-08-19 09:10:05 -05:00
esphome[bot]andGitHub e26237e57d Bump bundled esphome-device-builder to 1.11.5 (#18507) 2026-08-19 00:42:05 -05:00
esphome[bot]andGitHub d2c3f749ab Bump bundled esphome-device-builder to 1.11.4 (#18506) 2026-08-18 23:24:23 -05:00
esphome[bot]andGitHub b7cc271219 Bump bundled esphome-device-builder to 1.11.3 (#18505) 2026-08-18 21:57:51 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
17eed7055b Bump resvg-py from 0.3.4 to 0.4.0 (#18460)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 22:22:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b7121940c8 Update wheel requirement from <0.48,>=0.43 to >=0.43,<0.49 (#18459)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-18 22:08:23 -04:00
Jonathan SwobodaandGitHub 07fa16e2e7 [ci] Stop persisting the integration test ccache (#18504) 2026-08-18 21:38:18 -04:00
Jonathan SwobodaandGitHub 8aa7db15e5 [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) 2026-08-18 21:25:09 -04:00
J. Nick KostonandGitHub 8b637b339b [vscode] Report the origin of an unexpected exception during validation (#18494) 2026-08-18 19:37:10 -05:00
J. Nick KostonandGitHub 4f866c563b [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) 2026-08-18 19:36:57 -05:00
J. Nick KostonandGitHub 7a999f9a48 [ci] Install requirements_dev.txt when the venv cache misses (#18502) 2026-08-18 19:06:55 -05:00
esphome[bot]andGitHub 0a88c81d95 Bump aioesphomeapi from 45.11.0 to 45.12.0 (#18501) 2026-08-18 18:38:23 -05:00
J. Nick KostonandGitHub 804e8fb856 [socket] Remove constant duplicated by the beta merge (#18496) 2026-08-18 15:44:38 -05:00
Jesse Hills 43939a9199 Merge branch 'beta' into dev 2026-08-19 08:01:33 +12:00
Jesse HillsandGitHub f414a07bcd Merge pull request #18481 from esphome/bump-2026.8.0b5
2026.8.0b5
2026-08-19 08:01:14 +12:00
esphome[bot]andGitHub 5c9d050ebe Bump aioesphomeapi from 45.10.3 to 45.11.0 (#18493) 2026-08-18 17:02:54 +00:00
285a508e09 [modbus] CRC scan all unknown function codes (#18483)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 11:09:12 -05:00
J. Nick KostonandGitHub 92f55f721f [api] Bump noise-c to 0.1.21 (#18484) 2026-08-18 14:32:37 +00:00
Jonathan SwobodaandGitHub 476d540065 [ci] Fall back to files API when PR diff exceeds GitHub line limit (#18486) 2026-08-18 10:22:28 -04:00
J. Nick KostonandGitHub ae730d6357 [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) 2026-08-18 09:03:33 -05:00
J. Nick KostonandGitHub 9823205ef3 [api] Bump noise-c to 0.1.20 (#18482) 2026-08-18 09:03:14 -05:00
Jesse Hills d1391c2b10 Bump version to 2026.8.0b5 2026-08-18 16:04:49 +12:00
J. Nick KostonandJesse Hills 8b888f31e0 [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) 2026-08-18 16:04:47 +12:00
Jesse Hills 6a247dfe91 [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) 2026-08-18 16:01:54 +12:00
J. Nick KostonandJesse Hills 482869fbbe [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 4dea147386 Bump bundled esphome-device-builder to 1.11.2 (#18477) 2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 1fd6337254 [api] Bump noise-c to 0.1.19 (#18473) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 4ce6d59484 Bump bundled esphome-device-builder to 1.11.1 (#18475) 2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 014cc19902 [api] Bump noise-c to 0.1.18 (#18451) 2026-08-18 14:27:54 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
b9041566ea Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 3a403c40d5 [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 096e71bd67 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-18 14:27:54 +12:00
J. Nick KostonandGitHub fffa902a1a [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) 2026-08-18 13:32:03 +12:00
Jesse HillsandGitHub 463e3833da [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) 2026-08-18 13:19:48 +12:00
J. Nick KostonandGitHub 4416aacebb [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) 2026-08-18 13:09:29 +12:00
esphome[bot]andGitHub 15a626bcf3 Bump bundled esphome-device-builder to 1.11.2 (#18477) 2026-08-17 19:16:38 -05:00
J. Nick KostonandGitHub a347a2e879 [api] Bump noise-c to 0.1.19 (#18473) 2026-08-17 18:58:11 -05:00
dependabot[bot]andGitHub 7be4566b41 Bump platformdirs from 4.11.2 to 4.11.3 (#18468)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 18:33:13 -05:00
esphome[bot]andGitHub 96e26c6a5f Bump bundled esphome-device-builder to 1.11.1 (#18475) 2026-08-17 17:54:23 -05:00
J. Nick KostonandGitHub 7362c01c67 [core] Replace base64 lookup tables with arithmetic mapping (#18454) 2026-08-17 16:14:06 -05:00
Jesse HillsandGitHub e45b4e4938 [core] Make FINAL_VALIDATE_SCHEMA functions return None (#18457) 2026-08-18 08:26:24 +12:00
J. Nick KostonandGitHub e32329cc11 [core] Sync pre-commit ruff hook with requirements (0.16.3) (#18469) 2026-08-17 15:00:37 -05:00
dependabot[bot]andGitHub 199e368fe2 Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#18463)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:57:50 -05:00
dependabot[bot]andGitHub 55726120db Bump esphome/workflows/.github/workflows/lock.yml from 2026.7.0 to 2026.8.1 (#18465)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:47:28 -05:00
dependabot[bot]andGitHub 346ba7e831 Bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#18467)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:47:18 -05:00
dependabot[bot]andGitHub 9518006724 Bump ruff from 0.16.2 to 0.16.3 (#18458)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:46:04 -05:00
dependabot[bot]andGitHub 3d5f6f692f Bump filelock from 3.32.2 to 3.32.3 (#18461)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:45:41 -05:00
dependabot[bot]andGitHub 4712c15c75 Bump github/codeql-action/init from 4.37.6 to 4.37.7 (#18466)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:42:24 -05:00
dependabot[bot]andGitHub 9bc72529a6 Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 in /.github/actions/restore-python (#18462)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:42:08 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6d20943a9c Bump esphome/workflows/.github/workflows/stale.yml from 61fd37a044cad4e9aa4303027b2a61b6a34da855 to a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 (#18464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 15:38:06 -04:00
J. Nick KostonandGitHub a3af82867b [api] Bump noise-c to 0.1.18 (#18451) 2026-08-17 14:00:39 -05:00
J. Nick KostonandGitHub 27483a4101 [core] Retry gh CLI calls on transient network errors in CI scripts (#18292) 2026-08-17 13:15:31 -05:00
J. Nick KostonandGitHub 6d20ebc66b [socket] Shorten lwip TAG string (#18450) 2026-08-17 17:18:43 +00:00
J. Nick KostonandGitHub 031a038b49 [deep_sleep] Shorten bk72xx TAG string (#18445) 2026-08-17 11:56:37 -05:00
J. Nick KostonandGitHub f6c7434b2a [i2c] Shorten platform backend TAG strings (#18440) 2026-08-17 11:54:55 -05:00
J. Nick KostonandGitHub 3d9fecb562 [remote_receiver] Shorten esp32 TAG string (#18447) 2026-08-17 11:54:52 -05:00
J. Nick KostonandGitHub ebe93e2c68 [bluetooth_connection] Shorten platform TAG strings (#18449) 2026-08-17 11:54:35 -05:00
J. Nick KostonandGitHub 1f4fcead38 [nextion] Shorten upload TAG strings (#18448) 2026-08-17 11:54:29 -05:00
J. Nick KostonandGitHub 1f000ba668 [mqtt] Shorten esp32 backend TAG string (#18446) 2026-08-17 11:54:17 -05:00
J. Nick KostonandGitHub e0d28d7f5c [http_request] Shorten platform backend TAG strings (#18444) 2026-08-17 11:54:03 -05:00
J. Nick KostonandGitHub 47a58dd799 [internal_temperature] Shorten platform TAG strings (#18443) 2026-08-17 11:53:56 -05:00
J. Nick KostonandGitHub 37bea1c153 [spi] Shorten platform backend TAG strings (#18442) 2026-08-17 11:53:48 -05:00
J. Nick KostonandGitHub d1a7b8df8b [adc] Shorten platform TAG strings (#18441) 2026-08-17 11:53:42 -05:00
J. Nick KostonandGitHub c01f24553c [uart] Shorten platform backend TAG strings (#18439) 2026-08-17 11:53:29 -05:00
J. Nick KostonandGitHub 07e8b303b9 [ota] Shorten platform backend TAG strings (#18438) 2026-08-17 11:52:56 -05:00
esphome[bot]GitHubesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
ebb0923362 Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-16 22:34:00 -05:00
J. Nick KostonandGitHub e1c279718f [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) 2026-08-16 21:57:12 -05:00
J. Nick KostonandGitHub cf764740cf [api] Create the camera image reader lazily (#18421) 2026-08-16 21:56:58 -05:00
J. Nick KostonandGitHub 58d549ed4c [api] Move NoiseProtocolId off the connection object (#18420) 2026-08-16 21:56:37 -05:00
Jesse Hills 6b22d8068d Merge branch 'beta' into dev 2026-08-17 11:36:10 +12:00
Jesse HillsandGitHub 443d8f1f28 Merge pull request #18424 from esphome/bump-2026.8.0b4
2026.8.0b4
2026-08-17 11:33:27 +12:00
Jesse Hills 1ec21a2245 Bump version to 2026.8.0b4 2026-08-17 10:22:41 +12:00
J. Nick KostonandJesse Hills bb7d4c3630 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills f42fe9af29 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills 0bc2d71370 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-17 10:22:39 +12:00
luar123andJesse Hills 594c12b3d9 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-17 10:22:39 +12:00
Jonathan SwobodaandJesse Hills bca72e9b6d [sensor] Pass NaN through the delta filter again (#18400) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills ce09504c92 [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills dda4566b9e Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-17 10:22:39 +12:00
Samuel SiebandJesse Hills 46a5665a66 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills 9161f74bb1 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-17 10:22:39 +12:00
J. Nick KostonandGitHub 3f01f9895f [esp32_hosted] Require ESP-IDF 5.3 or newer (#18417) 2026-08-16 12:44:49 -07:00
J. Nick KostonandGitHub 801a1817b5 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-16 12:44:00 -07:00
J. Nick KostonandGitHub 32c76ae828 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-16 12:43:27 -07:00
J. Nick KostonandGitHub c664f5fc95 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-16 11:06:22 -07:00
luar123andGitHub 2bc4681fd6 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-16 13:33:25 -04:00
Jonathan SwobodaandGitHub 646501b0ef [sensor] Pass NaN through the delta filter again (#18400) 2026-08-16 12:36:51 -04:00
J. Nick KostonandGitHub de3e657d8b [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-16 08:42:47 -07:00
esphome[bot]andGitHub 1add726892 Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-15 13:36:52 -07:00
Samuel SiebandGitHub 5a000cf5e4 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-15 11:16:04 -07:00
Joppy FurrandGitHub 6ed676fe32 [lvgl] Restore long_press_repeat_time functionality (#18393) 2026-08-14 23:14:53 -07:00
esphome[bot]andGitHub 7cceddb8a3 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-15 01:48:27 +00:00
J. Nick KostonandGitHub 039b897e7b [ethernet] Defer clk_mode removal to 2026.11.0 (#18380) 2026-08-14 18:45:36 -07:00
J. Nick KostonandGitHub b178f74e5d [core] Save the validated config cache on the first upload or logs run (#18367) 2026-08-14 18:45:23 -07:00
Clyde StubbsandGitHub e5224e22ae [ci] Compare merge-branch base ref against the default branch (#18385) 2026-08-15 06:18:22 +10:00
Clyde StubbsandGitHub be66e8b99c [ci] Disable CodSpeed benchmarks job outside esphome/esphome (#18372) 2026-08-15 04:33:47 +10:00
Jesse Hills 4b91c8aff4 Merge branch 'beta' into dev 2026-08-14 23:15:57 +12:00
Jesse HillsandGitHub 9d7997f55a Merge pull request #18368 from esphome/bump-2026.8.0b3
2026.8.0b3
2026-08-14 22:49:40 +12:00
Jesse Hills 4db47de556 Bump version to 2026.8.0b3 2026-08-14 17:36:56 +12:00
Karl BeeckenandJesse Hills c8de632764 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills b794b7b1d1 [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills add18d4e35 [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 02c1810c3a [core] Load component aliases from a generated registry (#18335) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 4f3153375a [ota] Retry uploads that fail from network errors (#18332) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 1c3a67b5e8 [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 236ff33a09 [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 7c07fb48c5 [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills d72bab79d7 [web_server_base] Stop deleting the web server on captive portal teardown (#18324) 2026-08-14 17:36:54 +12:00
Karl BeeckenandGitHub 617e2ec1e0 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:23:34 +12:00
J. Nick KostonandGitHub 1afac0312d [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:22:22 +12:00
J. Nick KostonandGitHub b05465145f [core] Add preference key stability integration test (#18364) 2026-08-13 21:14:30 -05:00
dependabot[bot]andGitHub 990fc402fd Bump bleak from 2.1.1 to 3.0.2 (#16246)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 20:24:02 -05:00
J. Nick KostonandGitHub dd51624fbb [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) 2026-08-13 20:06:48 -05:00
dependabot[bot]andGitHub 45a056e337 Bump platformdirs from 4.11.1 to 4.11.2 (#18363)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 18:54:51 -05:00
dependabot[bot]andGitHub 37782f7206 Bump prek from 0.4.12 to 0.4.13 (#18362)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-13 18:54:37 -05:00
J. Nick KostonandGitHub 945c2458b3 [core] Load component aliases from a generated registry (#18335) 2026-08-13 13:31:19 -05:00
J. Nick KostonandGitHub 7420d23867 [ota] Retry uploads that fail from network errors (#18332) 2026-08-13 13:31:06 -05:00
J. Nick KostonandGitHub 191686c5b3 [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) 2026-08-13 13:30:53 -05:00
J. Nick KostonandGitHub 137351fa8d [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-13 13:27:01 -05:00
J. Nick KostonandGitHub db5173697a [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) 2026-08-13 13:22:48 -05:00
Oliver KleineckeGitHubOliver Kleineckepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
c7940382a9 [const] move CONF_LABEL to components/const (#18354)
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-13 14:18:22 -04:00
esphome[bot]andGitHub 87045ab9c0 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-13 11:14:14 -05:00
f1c4086778 [const] Move CONF_SLOT to components/const (#18350)
Co-authored-by: Oliver Kleinecke <p1ngb4ck@users.noreply.github.com>
2026-08-13 23:36:11 +12:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
f337d0acff Bump pylint from 4.0.6 to 4.0.7 (#18320)
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-13 02:13:29 +00:00
J. Nick KostonandGitHub 9cc05b30d4 [web_server_base] Stop deleting the web server on captive portal teardown (#18324) 2026-08-12 21:09:01 -05:00
Jesse Hills e0b68c4d6d Merge branch 'beta' into dev 2026-08-13 14:06:46 +12:00
Jesse HillsandGitHub 9c3407cfdd Merge pull request #18334 from esphome/bump-2026.8.0b2
2026.8.0b2
2026-08-13 14:06:28 +12:00
Jesse Hills d3e27054f6 Bump version to 2026.8.0b2 2026-08-13 13:30:47 +12:00
J. Nick KostonandJesse Hills bd58b5c8b3 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills c1a326f32e [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills a14ea0e8fa [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 13:30:46 +12:00
esphome[bot]andJesse Hills 48d6368ff9 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-13 13:30:46 +12:00
Kevin AhrendtandJesse Hills 83cff59fdd [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-13 13:30:46 +12:00
esphome[bot]andJesse Hills 89489b1f0d Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills 7569a7b5ce [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) 2026-08-13 13:30:45 +12:00
J. Nick Koston ae8760ded8 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:28:25 -05:00
J. Nick Koston 5c6e62ffe0 Pin the neutral cap branch a future hub platform will take 2026-08-12 19:28:21 -05:00
J. Nick KostonandGitHub 8e624b4117 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 12:14:50 +12:00
J. Nick Koston 588ca0de3c Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:12:12 -05:00
J. Nick Koston 0801b29617 Surface a degraded mixed client in component status
The table-build skip sets a warning cleared by the next successful fan-out.
2026-08-12 19:12:09 -05:00
J. Nick KostonandGitHub 787a909aa4 [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) 2026-08-13 12:11:32 +12:00
J. Nick Koston 674215f7f7 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:03:19 -05:00
J. Nick Koston b00ad7ca8a Name the mixed-client invariant and fold the promote loop
has_legacy_nodes_() lives next to the invariant it derives from; both
bailout outcomes reach one promotion loop; the carve assert relaxes to the
descending chain the comment claims; the notify op string names the IDF
call like its siblings.
2026-08-12 19:03:16 -05:00
J. Nick Koston b2dd602cef Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:59:00 -05:00
J. Nick Koston 7ad51b4b8f Pin the carve alignment, name the node-abort contract, fix strays
The static_assert enforces what the carve comment claims; the node header
now states that aborting inside on_connected() can leave on_disconnect
unpaired; the passive-proxy test comment names the current gate; the
pending-regs comment lands on the right member (lost to an aborted batch).
2026-08-12 18:58:56 -05:00
J. Nick KostonandGitHub 905485b673 [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 11:49:34 +12:00
J. Nick Koston 6f9b5993b9 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:42:07 -05:00
J. Nick Koston 65c1b06f7f Keep legacy nodes connected through a table-build failure
A failed search still poisons the whole discovery, but an allocation or walk
failure is table-specific: on a mixed client the legacy nodes keep the link
and only the gatt nodes skip that connection.
2026-08-12 18:42:03 -05:00
J. Nick Koston 93d352df83 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:38:08 -05:00
J. Nick Koston e158205e4c Surface the node-capacity guard in component status and log sync register failures 2026-08-12 18:38:04 -05:00
J. Nick Koston 014cccb67e Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:16:44 -05:00
J. Nick Koston f5debdad32 Drop the orphaned Automation TAG box and fix the tidy naming
The write action's own tag left Automation::TAG with zero readers;
automation.cpp existed only to define it. Also notes the unpaired legacy
disconnect trigger on the failed-discovery path and re-anchors the
gatt_nodes_ comment.
2026-08-12 18:16:40 -05:00
J. Nick Koston bbe643e16b Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:07:48 -05:00
J. Nick Koston 2a95fa44dc Pack ConnectBackoff into 4 bytes and regroup the table walk context
256 ms ticks in a uint16_t drop the struct from 8 bytes (3 padding) to 4 on
both engines; the materializer's uint8 fields move behind its uint16s.
2026-08-12 18:07:44 -05:00
J. Nick Koston 63ab68b58a Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:56:33 -05:00
J. Nick Koston 314557f83b Deduplicate the review-round additions
One ConnectBackoff shared by both engines replaces the forked hold-off
fields and constants (the duration is derived from the failure count). The
pending-registration scan lives once, sized by its own bound instead of the
node counter. The failure-suppressing SEARCH_CMPL arm moves to the call site
so the dispatcher stays void, notify-state logging is one helper on the
neutral wording, and the op-check helper takes the backend's spelling.
2026-08-12 17:56:29 -05:00
J. Nick Koston e5cc588ab5 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:47:09 -05:00
J. Nick Koston e4e464a3b9 Log GATT failures the bridge previously fanned out silently
Failed notify enables, reads, writes and unsubscribes now leave the same
breadcrumbs as the neutral engine; the shared-completion guarantee moves to
the node contract header.
2026-08-12 17:47:07 -05:00
J. Nick Koston 8ba15fa7af Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:24:38 -05:00
J. Nick Koston a41dbed69c Clear the bridge backoff on re-enable and fail the table build closed
set_enabled(true) resets the hold-off (neutral-engine parity), and every
failing build() return now resets the counts so a failed build can never
present a non-empty view.
2026-08-12 17:24:34 -05:00
J. Nick Koston 527ba05474 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:09:40 -05:00
J. Nick Koston ba10832ed7 Tighten the bridge comments to repo style 2026-08-12 17:09:37 -05:00
J. Nick Koston f2bae23b4e Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:04:51 -05:00
J. Nick Koston d6b35f8ccf Keep the on_connect trigger out of a failed discovery's teardown
A discovery the bridge classifies as failed now suppresses the legacy
SEARCH_CMPL fan-out, so user automations never observe the doomed link as a
connection (neutral-engine parity, including the mid-fan-out node abort).
Also documents that same-handle subscriptions from mixed node kinds are
unsupported during the migration window.
2026-08-12 17:04:46 -05:00
J. Nick Koston 7bc00c8eda Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:58:38 -05:00
J. Nick Koston 03efcf6bb5 Hold off reconnects after materializer failures on the esp32 bridge
A peer that reliably fails discovery (walk error, allocation failure) would
otherwise reconnect on its next sighting forever, holding a controller slot
each cycle - the neutral engine already backs off on this path.
2026-08-12 16:58:34 -05:00
J. Nick Koston dd76743909 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:38:37 -05:00
J. Nick Koston 0d46b1047e Name the failure in the discovery-bailout log
A failed search or count was indistinguishable from a service-less peer.
2026-08-12 16:38:33 -05:00
J. Nick Koston 313127d3dd Gate on the unified neutral-node-surface define 2026-08-12 16:33:50 -05:00
J. Nick Koston 50f2eb4459 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:33:21 -05:00
J. Nick Koston 456f5b3042 Apply the simplify pass to the single-interface rework
Neutral fan-out runs before the legacy nodes so on_connect triggers observe
resolved gatt nodes (the neutral engine's order). Pending notify
registrations move to a fixed array (no post-setup heap). Gatt nodes also
join nodes_, collapsing the twin state loops. One define now means 'the
neutral node surface is compiled in' on both engines. The service count
moves into BluedroidServiceTable, free() resets it, bridge ops log failures,
the write action carries its own tag, and the automation headers drop
includes that left with the write actions.
2026-08-12 16:32:46 -05:00
esphome[bot]andGitHub 99677390e0 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-12 16:27:14 -05:00
J. Nick Koston 349ba6a02f Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:11:36 -05:00
J. Nick Koston 83c9603b4b Address review: one overcommit message per platform and a stall breadcrumb
Platforms whose BLE stack owns a connection budget (esp32, rp2) report an
overcommit exactly once through that stack; the neutral cap check covers
future backend platforms without one. The write action logs unmatched
completions while a chain is parked, and the schema-dump gap is documented
as deliberate.
2026-08-12 16:11:20 -05:00
J. Nick Koston f9993aa010 Migrate radon_eye_rd200 onto the neutral ble_client node interface
One implementation replaces the raw-gattc one and runs on esp32 and rp2
alike; user YAML is unchanged. The node resolves the V1/V2 variant from the
service table, owns its CCCD write, and keeps the drop-the-link-between-polls
protocol.
2026-08-12 16:09:44 -05:00
J. Nick Koston ad9a41ed61 Cover the engine feature gates in the schema tests 2026-08-12 16:04:57 -05:00
J. Nick Koston e6b7b3a028 One ble_write action on the neutral node interface
The raw-gattc and neutral twins collapse into ble_write_action.h, registered
through register_gatt_node on both engines. On esp32 this is the in-tree
exerciser of the node bridge; builds without a ble_write action compile no
bridge code.
2026-08-12 16:02:50 -05:00
J. Nick Koston 9a4a550fb9 Express ble_client engine capabilities per platform
BLEClientFeatures (gatt_node / raw_gattc / security) with a per-platform
provider set, capability-named validation errors, NODE_BLE_CLIENT_SCHEMA +
register_gatt_node for migrated components, and the security actions gated on
the feature instead of the platform. esp32 auto-loads bluetooth_connection
for the shared materializer (compiles empty without a neutral node).
2026-08-12 15:58:25 -05:00
J. Nick Koston 42dc4025c9 Deliver the neutral node callbacks from the esp32 engine
Nodes registered through register_gatt_node() get the neutral surface:
service table at SEARCH_CMPL (honoring the search status the base ignores),
translated completion/notify fan-outs, pairing results, and teardown via
on_disconnect_complete. Bridge-initiated notify registrations are intercepted
before the base's automatic CCCD write - the CCCD is the node's job on the
neutral contract. Compiled only under USE_BLE_CLIENT_GATT_NODES; builds
without a neutral node are unchanged.
2026-08-12 15:55:56 -05:00
J. Nick Koston 901fd35a7e Extract the Bluedroid service-table materializer into a shared class
BluedroidServiceTable carries the two-pass build verbatim in its own TU; the
backend embeds one instance. ble_client's esp32 engine becomes the second
consumer next.
2026-08-12 15:53:14 -05:00
J. Nick Koston 4344ffd682 Share one BLEClientNode between the engines and round out the neutral surface
The neutral callbacks (plus a new on_pairing_result hook) live unconditionally
in ble_client_node.h; the raw esp32 surface stays beneath them for unmigrated
nodes. Adds find_descriptor beside find_cccd and pair()/unpair() on the
neutral client. Drops the node 'client' pointer and address, which nothing
reads.
2026-08-12 15:47:43 -05:00
J. Nick Koston 1c671be1bd Use the merged ledger accessor in the explicit-connections test 2026-08-12 15:04:26 -05:00
J. Nick Koston 52dc51c7c6 Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection.h
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/core/defines.h
#	tests/components/bluetooth_connection/__init__.py
2026-08-12 15:04:09 -05:00
Kevin AhrendtandGitHub f2121130f9 [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-12 12:49:54 -04:00
esphome[bot]andGitHub 1a01c34ec4 Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-12 16:33:45 +00:00
J. Nick KostonandGitHub 3c46cc9c35 [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) 2026-08-12 10:59:24 -04:00
Jesse Hills e192ec8fee Merge branch 'beta' into dev 2026-08-13 00:29:08 +12:00
Jesse HillsandGitHub ec6b4263a2 Merge pull request #18314 from esphome/bump-2026.8.0b1
2026.8.0b1
2026-08-13 00:28:40 +12:00
Jesse Hills 8a1aa5753d Bump version to 2026.9.0-dev 2026-08-12 22:53:42 +12:00
Jesse Hills 3e4661fe1e Bump version to 2026.8.0b1 2026-08-12 22:53:42 +12:00
J. Nick KostonandGitHub 22153be4cd [core] Add esphome logs over web_server HTTP SSE (#17110) 2026-08-12 22:00:07 +12:00
3f490fe1ed [internal_temperature] Read the RP2 on-die sensor directly instead of via the Arduino API (#18262)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-12 21:42:05 +12:00
J. Nick KostonandGitHub 58a42fe5c2 [core] Batch remote file downloads during config validation (#18069) 2026-08-12 18:44:35 +12:00
Josef ZweckGitHubJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
25c0c2c97b [hoermann_hcp] Add garage light control (#18190)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-12 06:10:23 +00:00
J. Nick KostonandGitHub 622942482c [rp2] Size the lwIP segment pool and heap for concurrent senders (#18257) 2026-08-12 17:30:17 +12:00
Keith BurzinskiandGitHub cffd775450 [ethernet][network][wifi] Arbitrate the default route from the network priority list (#17797) 2026-08-12 16:21:37 +12:00
J. Nick KostonandGitHub 8d87ba34d9 [api] Move the generic buffer-full log to very verbose (#18300) 2026-08-12 04:04:07 +00:00
J. Nick KostonandGitHub 08c6585915 [esp32_ble_tracker] Don't log an error when a scan stop is already in flight (#18306) 2026-08-12 16:03:28 +12:00
J. Nick KostonandGitHub 3ef74d17af [bluetooth_proxy] Give partial advertisement batches 200ms to fill on Wi-Fi (#18303) 2026-08-12 16:02:51 +12:00
J. Nick KostonandGitHub 5d04c1dc18 [esp32_ble_tracker] Demote scan-state echoes to verbose (#18302) 2026-08-12 16:01:45 +12:00
J. Nick KostonandGitHub a3d599ac69 [bluetooth_connection] Demote rp2 backend connection logs to verbose (#18301) 2026-08-12 16:00:37 +12:00
J. Nick KostonandGitHub a4e05cd1c8 [bluetooth_proxy] Move per-advertisement logging to very verbose (#18299) 2026-08-12 15:59:41 +12:00
J. Nick KostonandGitHub 1758653330 [voice_assistant] Do not consume the audio chunk when the send is refused (#18295) 2026-08-12 15:59:26 +12:00
J. Nick KostonandGitHub a2feff8f68 [api] Mark send_message nodiscard so refused frames are never silent (#18293) 2026-08-11 22:23:08 -05:00
J. Nick KostonandGitHub 37eae9b466 [api] Gate the bluetooth connection messages on their own define (#18281) 2026-08-11 21:31:13 -05:00
Jesse HillsandGitHub 9605b34c69 [adc] Deprecate pin: TEMPERATURE in favour of internal_temperature (#18304) 2026-08-12 14:20:41 +12:00
Jesse HillsandGitHub bab62b345b [adc] Fix internal temperature channel on RP2350A under arduino-pico (#18307) 2026-08-12 14:19:59 +12:00
Jesse HillsandGitHub 3349046c5d [rp2] Record the RP2350 die on generated board entries (#18305) 2026-08-12 01:56:05 +00:00
J. Nick KostonandGitHub 3f5b8139f3 [bluetooth_proxy] Latch the connection replies and tighten the send paths (#18278) 2026-08-12 13:52:25 +12:00
be5e28ea9e [core] Warn when running a different source tree than the one you are in (#18288)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 21:06:57 -04:00
J. Nick KostonandGitHub 9f28638ee7 [bluetooth_proxy] Reset every owed reply in one place (#18276) 2026-08-11 19:15:21 -05:00
J. Nick KostonandGitHub 201f843e95 [bluetooth_proxy] Latch the unpair reply (#18274) 2026-08-11 18:57:51 -05:00
J. Nick KostonandGitHub 37a59a07bc [bluetooth_proxy] Retry dropped GATT acks and stop the retry log spam (#18259) 2026-08-11 18:40:47 -05:00
9556c2bc4c [mitsubishi_cn105] Add vertical vane control action (#16737)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 18:39:05 -05:00
3540012529 [tests] Build tests from the tree they run in, not the venv's editable install (#18248)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-11 19:09:51 -04:00
e0b112c584 [sendspin] Clear metadata and controller state on disconnect (#18289)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 21:03:27 +00:00
Kevin AhrendtandGitHub 7f0d6a8696 [sendspin] Add image platform for artwork (#17937) 2026-08-11 15:49:08 -05:00
Kevin AhrendtandGitHub 4a7de87bff [audio] Bump microDecoder to v0.4.0 (#18291) 2026-08-11 15:33:37 -05:00
esphome[bot]andGitHub b9d1d2f06b Bump aioesphomeapi from 45.9.0 to 45.10.0 (#18294) 2026-08-11 20:02:53 +00:00
dependabot[bot]andGitHub 74c30c62ef Bump setuptools from 83.0.0 to 84.0.0 (#18290)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 14:36:59 -05:00
J. Nick KostonandGitHub 94fbfa05de [core] Show the out-of-flash tip instead of crashing the build (#18280) 2026-08-11 14:35:06 -05:00
55bd63732d [mitsubishi_cn105] Add vertical vane state trigger (#16727)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 13:56:12 -05:00
J. Nick KostonandGitHub 3ae651af7b [core] Stop dropping complete lines behind an unfinished one (#18279) 2026-08-11 12:38:55 -05:00
esphome[bot]andGitHub 55e8bc3b14 Bump aioesphomeapi from 45.8.0 to 45.9.0 (#18283) 2026-08-11 17:23:36 +00:00
J. Nick KostonandGitHub 9938a2487d [core] Show the last line when output stops without a newline (#18265) 2026-08-11 10:55:42 -05:00
J. Nick KostonandGitHub aee41d64c2 [rp2040_ble][bluetooth_connection] 3 connection slots on rp2 with esp32 parity (#18247) 2026-08-11 08:43:08 -05:00
J. Nick KostonandGitHub f3a5a9fbd5 [core] Retry transient git network failures with backoff (#18242) 2026-08-11 08:41:55 -05:00
8ee3c8d41d [modbus] Properly support client-mode broadcast sends (#17467)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 08:31:15 -05:00
J. Nick KostonandGitHub eefd2a00c7 [espidf] Flush the runner's output so dashboard builds stream (#18264) 2026-08-11 08:11:42 -05:00
J. Nick KostonandGitHub e8852c5950 [core] Flush stdout in safe_print so logs stream live (#18261) 2026-08-11 08:11:23 -05:00
069f40f653 [hoermann_hcp] Add connectivity binary sensor (#18189)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-11 08:09:33 -05:00
Jesse HillsandGitHub 04dd6b3a55 [core] Use MAC address size constants instead of literals (#18254) 2026-08-11 08:27:32 -04:00
Jesse HillsandGitHub 2ff55e3058 [rp2] Use SDK clock query directly in arch_get_cpu_freq_hz (#18269) 2026-08-11 08:23:08 -04:00
Jesse HillsandGitHub e5cdda9ee5 [adc] Fix RP2350B internal temperature reading wrong ADC channel (#18270) 2026-08-11 08:22:52 -04:00
Jesse HillsandGitHub ecec3a19c7 [debug] Remove Arduino core dependency from RP2 platform code (#18267) 2026-08-11 08:22:20 -04:00
J. Nick KostonandGitHub 9823ad6abc [tests] Fix flaky modbus server/controller integration tests (#18258) 2026-08-10 23:56:32 -05:00
J. Nick KostonandGitHub 51b0240fb8 [rp2] Fix %f formatting in logs (#18256) 2026-08-10 22:05:19 -05:00
Jesse HillsandGitHub f697cf2011 [api] Add DeviceCapabilities message for optional-feature flags (#17984) 2026-08-11 14:52:19 +12:00
Jesse HillsandGitHub 8728aaa616 [core] Use MAC address size constants in BLE components (#18252) 2026-08-11 14:21:03 +12:00
J. Nick KostonandGitHub eab9a47aa2 [bluetooth_proxy] Retry dropped services-done, disconnect and scanner-state notifications (#18225) 2026-08-11 14:20:39 +12:00
Jesse HillsandGitHub c2ba2fbfc4 [ld24xx] Use MAC address size constants instead of literals (#18253) 2026-08-11 14:18:09 +12:00
J. Nick KostonandGitHub 3de01d2a2e Bump aioesphomeapi to 45.8.0 (#18251) 2026-08-10 19:56:35 -05:00
dependabot[bot]andGitHub 791d6659c0 Bump platformdirs from 4.11.0 to 4.11.1 (#18250)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 00:43:35 +00:00
007c677da1 [mitsubishi_cn105] Refactor property encoding/decoding (#16709)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-10 18:58:34 -05:00
2a0f2d59f0 [modbus_server] Add coil/discrete-input support (#17464)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 18:13:12 -05:00
c8d2c3691a [mitsubishi_cn105] Add vertical vane direction select (#16723)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-10 17:58:37 -05:00
82a63658f9 [hoermann_hcp] Add Hörmann HCP garage door component (#17355)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-10 17:20:20 -05:00
Bonne EgglestonandGitHub 596827c51c [modbus_client] Add read/write multiple registers (FC 0x17) (#18215) 2026-08-10 15:18:48 -05:00
J. Nick KostonandGitHub 8a16ead8ce [web_server] Fix basic auth with long credentials (#18237) 2026-08-10 14:49:14 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
334afdefb5 Update argcomplete requirement from >=3.7.0 to >=3.7.2 (#18243)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 15:44:32 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e7a2980b9c Bump ruff from 0.16.1 to 0.16.2 (#18245)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 15:44:30 -04:00
dependabot[bot]andGitHub b5a78c6c46 Bump CodSpeedHQ/action from 5.0.2 to 5.0.3 (#18246)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 19:36:22 +00:00
J. Nick KostonandGitHub 671b6216a7 Merge branch 'dev' into neutral-ble-client 2026-08-10 14:30:42 -05:00
dependabot[bot]andGitHub 8650d175f4 Bump aiohappyeyeballs from 2.6.2 to 2.7.1 (#18244)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 14:26:41 -05:00
2999e7b925 [modbus] Add API for reading/writing coils and discrete inputs in server mode (#17264)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-10 14:23:49 -05:00
J. Nick KostonGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>bdraco
207ae2e4cb [bk72xx_ble] Support active scanning by packing the GAPM start command (#18169)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bdraco <663432+bdraco@users.noreply.github.com>
2026-08-10 14:02:37 -05:00
Boris KrivonogandGitHub 9e78a768a2 [mitsubishi_cn105] Extract top-level hub (#16987) 2026-08-10 14:02:16 -05:00
J. Nick KostonandGitHub e4d08a73b0 [ufm01] Prefix PassiveReadResult enumerators to avoid Realtek SDK macro collision (#18240) 2026-08-10 13:03:01 -05:00
J. Nick KostonandGitHub 4cf7ad9c63 [ble_device_base] Treat a partially bound merger as unbound (#18235) 2026-08-10 13:02:27 -05:00
J. Nick KostonandGitHub 293d0b90d9 [core] Document enum class value naming to avoid platform SDK macro collisions (#18241) 2026-08-10 13:02:02 -05:00
J. Nick KostonandGitHub 8f1e439792 [bluetooth_proxy] Finish the connection scan before reserving a slot (#18239) 2026-08-10 12:56:16 -05:00
J. Nick KostonandGitHub ad733272e5 [bluetooth_proxy] Pair the advertisement flush time with hub_ to close alignment holes (#18234) 2026-08-10 12:40:22 -05:00
J. Nick KostonandGitHub b7c6245388 [wifi] Save fast connect settings and reset roaming bookkeeping after driver initiated roams (#18167) 2026-08-10 12:39:32 -05:00
2184ec2928 [ethernet] Add CH390 SPI ethernet support (#18226)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-10 12:24:16 -05:00
J. Nick KostonandGitHub bae7f19323 [core] Extend '/' in names deprecation window to 2027.7.0 (#18236) 2026-08-10 11:32:02 -05:00
56ec21d950 [ufm01] Improve startup with reset retry and passive polling fallback (#17567)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-10 12:31:31 -04:00
J. Nick KostonandGitHub 2798ef4de2 [ota] Enforce the backend contract with a concept (#18192) 2026-08-10 11:20:38 -05:00
J. Nick KostonandGitHub e90b4abe9c [core] Enforce the preferences contracts with concepts (#18191) 2026-08-10 11:20:24 -05:00
J. Nick Koston 5764ca607d Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_device_base/ble_gatt_client.h
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection.h
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_hub.h
#	esphome/components/bluetooth_proxy/__init__.py
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/core/defines.h
#	tests/component_tests/bluetooth_proxy/test_platform_gates.py
#	tests/components/ble_device_base/__init__.py
#	tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml
2026-08-10 10:50:00 -05:00
J. Nick KostonandGitHub f3d1fc0d64 [bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198) 2026-08-10 10:47:56 -05:00
J. Nick KostonandGitHub c8c929d487 [ble_device_base] Merge adv and scan response before delivery on rp2 (#18217) 2026-08-10 10:06:06 -05:00
J. Nick KostonandGitHub d9567b2974 Normalize marker-wrapped callable keys in the schema dump (#18218) 2026-08-10 10:05:37 -05:00
J. Nick KostonandGitHub 02fa18b74f [core] Skip colorama init for terminal and dashboard runs (#18224) 2026-08-10 09:44:01 -05:00
Kevin AhrendtandGitHub 3656375516 [sendspin] Bump sendspin-cpp to v0.7.1 (#18232) 2026-08-10 09:00:29 -05:00
b0a9bfd381 [ld6002b] Add area and zone configuration (5/5) (#17823)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-10 09:59:32 -04:00
J. Nick KostonandGitHub 25cb440005 [core] Use Happy Eyeballs for remote file downloads (#18050) 2026-08-10 17:29:00 +12:00
J. Nick KostonandGitHub 0f59ef36a9 [core] Store the validated-config cache as JSON to drop YAML off the upload fast path (#18106) 2026-08-10 17:26:41 +12:00
esphome[bot]andGitHub 3de8c7f95c Bump bundled esphome-device-builder to 1.9.5 (#18223) 2026-08-10 01:49:54 +00:00
J. Nick Koston 273c80ecd3 Address review: state the table lifetime where it is freed
Every teardown path routes through release_services(), so the
materialized table cannot outlive its link - said at free_service_table_
so the next reviewer need not re-derive it.
2026-08-09 19:59:06 -05:00
J. Nick Koston 1933126ad7 Address review: de-vacuous the reject test, loud node overflow, walker logs
- The rejection test registers the hub and matches the extra-keys error,
  so the missing-tracker error can no longer satisfy it vacuously
- register_ble_node logs and drops loudly at capacity: a push_back past
  a StaticVector's bound is a silent no-op, and an undersized slot count
  must show at boot, not as an unresolvable node
- Every walker failure names the failing call and status; the descriptor
  cap gets its own message so a >64-descriptor characteristic is
  diagnosable instead of a permanent silent backoff loop
2026-08-09 19:37:32 -05:00
J. Nick Koston 64034b5b23 Use the set_core_config fixture instead of hand-poking CORE.data
The conftest fixture is the one spelling for platform setup; the
ledger tests already use it.
2026-08-09 19:16:18 -05:00
J. Nick Koston 830854d661 Address review: pin the neutral arm's rejection of esp32-only keys
The schema split's accept side was tested; the reject side was not - a
key leaking from the legacy arm into the neutral schema would have
passed silently.
2026-08-09 19:12:52 -05:00
J. Nick Koston 87b7076cd1 Address review: fail the empty table before CONNECTED, one slot spelling
- The empty-table bailout ran after state_ went CONNECTED, so its
  teardown's terminal report read as a completed connection and fired
  on_disconnect with no preceding on_connect - the exact trigger
  contract violation the sibling error branch avoids. The check now
  runs first and the teardown resolves through connect_failed; a
  node-less client still reaches CONNECTED through the single
  assignment after the guard
- The esp32 arm claims its slot through consume_gatt_slot like every
  other claimant (behavior-identical: it forwards to the same esp32
  validator), so the ledger's one-spelling contract holds
2026-08-09 18:52:49 -05:00
J. Nick Koston 5ec7047151 Address review: empty-table failure semantics, async abort guard, StaticVector nodes
- A zero-service table on a client with nodes is treated as a failed
  discovery (warn, release, charge the backoff, disconnect): a real GATT
  peer always exposes at least GAP/GATT, so an empty table means the
  materialization failed and the connection must retry instead of
  sitting inert behind a successful on_connect
- The fan-out abort guard also tests cancel_requested_, covering the
  normal async teardown a node starts from on_connected (the state-only
  check caught just the synchronous refusal)
- nodes_ becomes a StaticVector sized by ESPHOME_BLE_CLIENT_MAX_NODES:
  the client requests a baseline slot, each neutral write action (which
  registers itself in its constructor) requests one, and the define
  rides defines.h for analysis - no realloc machinery on the rp2 target
- The Bluedroid empty-table return logs its quiet cases; the
  service_table=False stub notes the misconfiguration it implies
2026-08-09 18:34:13 -05:00
J. Nick Koston 45d921f77a Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:34:37 -05:00
J. Nick Koston 321b00143d Address review: latch the retry only for the current subscriber
A gate-refused send to a peer that is not api_connection_ (the
subscribe-time path during a handover) must not arm a retry that
loop() would aim at a different connection.
2026-08-09 17:34:33 -05:00
Jesse HillsandGitHub 989dbd7550 [ci] Name release runs after the version or dev tag they build (#18110) 2026-08-10 10:10:38 +12:00
J. Nick Koston e6e9016ed4 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:07:45 -05:00
J. Nick Koston a292aa2654 Drop the disconnect-notification retry latch
Overkill for its window: the address latch, drain, reuse guard and
session hygiene bought coverage for a case the client's own timeouts
and the retried connections-free state already heal. The notification
goes out when it can; the connections-free retry (the hardware-verified
fix) stays.
2026-08-09 17:07:42 -05:00
J. Nick Koston 4353f5ac9c Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:05:05 -05:00
J. Nick Koston 4925f6648b Address review: retry-latch hygiene on subscriber change, parity note
- Both retry latches clear on subscribe/unsubscribe: a new subscriber
  must not receive a connected=false for an address from the previous
  session (it resyncs through its own subscribe requests)
- The app-register failure path states its BLEClientBase parity so the
  no-retry behavior reads as deliberate
2026-08-09 17:05:01 -05:00
J. Nick Koston 47bd8f5ffc Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 17:03:13 -05:00
J. Nick Koston 31771da92c Address review: gate the cache clean where the paths converge
The stack-down settle skipped release_services() locally, but its
report routes into the wrapper's reset which calls it anyway - the
warn survived. Gating the clean on an active stack inside
release_services() covers every path, and the stack-down branch goes
back to the plain call.
2026-08-09 17:03:09 -05:00
ab12e5490f [modbus] Rename send_pdu() to queue_pdu() (#18196)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-09 17:00:44 -05:00
J. Nick Koston 1978f203bc Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:32:41 -05:00
J. Nick Koston ae906ada94 Address review: quiet stack-down teardown, pin bitfield widths
- The stack-down settle resets the stream latches directly instead of
  calling release_services(): the dying stack invalidates its own cache,
  and the newly checked cache_clean would warn on every OTA or
  ble.disable with a live connection
- static_asserts pin the exactly-sized state bitfields so a future
  enumerator truncates loudly at compile time
2026-08-09 16:32:37 -05:00
J. Nick Koston 6a2465da63 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:24:06 -05:00
J. Nick Koston 9a5aab63d5 Address review: retry a dropped freed-slot notification too
The connections-free retry left its sibling asymmetric: on the same
full buffer the slot could report free while the device still read as
connected, with nothing resending the connected=false. A one-deep latch
(address + error) drains at the same 100 ms cadence and re-latches on a
repeat failure; a re-reservation of the address clears it, since the
client re-requesting the connect has already acted on the disconnect
and a late resend would shadow the new connection. A lost
connected=true stays client-timeout territory.
2026-08-09 16:24:03 -05:00
J. Nick Koston deeec17d0d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:07:38 -05:00
J. Nick Koston 735f810f08 Cover the unknown-platform raise in frameworks_for_platforms 2026-08-09 16:07:35 -05:00
J. Nick Koston cd48023a56 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 16:03:30 -05:00
J. Nick Koston 9330204908 Address review: unknown platforms raise, the filter pin asserts equality
An unknown or misspelled platform name yielded an empty set - the file
would be filtered out of every build (link failure at the end of a long
compile) and the subset guard passed vacuously.
2026-08-09 16:03:25 -05:00
J. Nick Koston 45501056d8 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:58:59 -05:00
J. Nick Koston 2e6ad94bd1 Tighten the listener doc to repo style 2026-08-09 15:58:56 -05:00
J. Nick Koston 5994105d34 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:57:24 -05:00
J. Nick Koston 0ac9a0f8bd Check the cache-clean status in release_services()
The one IDF call in the backend that skipped check_and_log_error_. A
failed clean leaves a stale database the next connection could serve as
authoritative, with nothing in the log to point at it. (The call fails
only at dispatch - stack down - not for an empty cache, so this cannot
warn-spam routine teardowns.)
2026-08-09 15:57:20 -05:00
J. Nick Koston 77b8b384bd Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:55:00 -05:00
J. Nick Koston 3728669fa6 Restore the reconnect-during-teardown cancel fast path
The collapse of the CONNECTING branch dropped a real race fix: HA
re-requesting a connect while a scheduled teardown was still pending
used to cancel the teardown and let the in-flight open complete;
without it the request was ignored and HA paid a teardown plus a fresh
connect during exactly the reconnect churn this path sees most.

cancel_gatt_disconnect() joins the contract: true only for a scheduled
teardown that has not started closing (Bluedroid clears the latched
want_disconnect_); rp2 and the stubs return false since their teardowns
start inside gatt_disconnect(). The wrapper's cancel_teardown() returns
its state to CONNECTING and the proxy handler carries the dev branch
verbatim.
2026-08-09 15:54:56 -05:00
J. Nick Koston 578e4774a9 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:44:01 -05:00
J. Nick Koston 8696255ccc Give connection_index_ an in-class initializer like every sibling 2026-08-09 15:43:57 -05:00
J. Nick Koston 594931265b Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 15:15:37 -05:00
J. Nick Koston b0f753a7a2 Address review: check prefer-conn-params, pace the retry drain
- Both esp_ble_gap_set_prefer_conn_params calls route through
  check_and_log_error_ like the class this PR replaces did; a rejected
  preference leaves the link on the controller default interval, which
  is exactly the WiFi-coex failure the shared constants exist to avoid,
  so it must be visible in logs
- The connections-free retry moves below the 100 ms gate so a full TCP
  buffer is retried at the loop cadence instead of every iteration
2026-08-09 15:15:33 -05:00
J. Nick Koston fc69d8543e Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:54:05 -05:00
J. Nick Koston d586a6c8d4 Address review: check the reclaim close, log the mid-stream park
- The late-OPEN_EVT reclaim was the one unchecked IDF call in the
  backend; a failed close there leaks a live link nothing tracks
- The mid-stream services_released_ park now leaves a trace like its
  API-lost sibling, so a stuck GetServices is explicable from logs
2026-08-09 14:54:01 -05:00
J. Nick Koston 9301391792 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:46:35 -05:00
J. Nick Koston 1b801427c4 Dry-pass round 2: comment accuracy polish
Round 2 found no code issues - the round-1 mechanisms verify clean on
disk (every IDLE transition through the one door, the teardown-guard
trio minimal-complete per event, the error latch airtight, the reset
lists disjoint by construction). What remained was comment drift:
disconnect_pending() spelling, the OPEN_EVT and cached-MTU comments,
the contract header re-wrap, the two claim-a-slot docstrings
disambiguated, and ragged wraps from earlier text excisions.
2026-08-09 14:46:30 -05:00
J. Nick Koston be391feaa9 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
2026-08-09 14:38:33 -05:00
J. Nick Koston 7e369f332c Dry-pass round 1: one door to IDLE, complete teardown guards, residue
- set_idle_() is the single door back to IDLE (bootstrap, open-fail and
  the DISCOVERED park went through bare set_state), so the per-attempt
  reset list holds only per-attempt latches
- CFG_MTU joins OPEN_EVT and SEARCH_CMPL in suppressing reports while a
  teardown owns the link - one spelling of the guard across all three
  events, and the wrapper's race arm becomes defense instead of the only
  cover
- The connections-free retry drain compiles on every proxy build (the
  advertisement-only arm sends the message too; the latch already did)
- latch_pending_error_ makes first-cause-wins the mechanism at all three
  latch sites; the dead freed-slot refused branch and its retired
  vocabulary go; the initiate_connection/start_connect_ pair collapses
- UNSET_CONN_ID hoisted next to the field it initializes; count-status
  shadow renamed; stale busy-error rationale replaced with the real one
  (a repeat call would re-arm the teardown timer)
- Fixture states the batch-grouping caveat like its rp2 sibling; the
  get_service_table stub carries a greppable direct-consumer warning
2026-08-09 14:37:57 -05:00
J. Nick Koston a40ecfdf9c Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:22:40 -05:00
J. Nick Koston 9a6765eb8f Log the deferred connections-free resend at verbose
Log lines ride the same API connection; a debug line at the exact
moment the TCP buffer is full adds traffic when it can least afford it
(the api layer's own buffer-full log is verbose for the same reason).
2026-08-09 14:22:37 -05:00
J. Nick Koston 1d0a87daa8 Address review: an empty service table with nodes logs a warning
A service-less device or a failed materialization both leave the nodes
unable to resolve; the client no longer sits inert without a trace.
2026-08-09 14:20:31 -05:00
J. Nick Koston c98dd74e11 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:13:53 -05:00
J. Nick Koston aceff27948 Retry a dropped connections-free update
Field bug on a Pico W: the slot freed device-side but the API client
kept free=0 with the address still allocated. send_message drops the
response when the TCP buffer is full (a boot storm makes that likely
right when HA reconnects), and nothing ever resent it - the client's
slot state is verbatim the last response received, so it stayed stale
until reboot. The cached response is current by construction, so a
pending bit retried from loop() is an idempotent resync; the
subscribe-time send heals through the same bit.
2026-08-09 14:13:50 -05:00
J. Nick Koston 211337cd6d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 14:03:07 -05:00
J. Nick Koston 4276815734 Address review: scope the rp2 defines pin, contract-back the teardown semantics
- The esp32 arm's ESPHOME_BLE_GATT_CLIENT_COUNT shadowed the unanchored
  regex; the pin now searches the USE_RP2 platform block
- The contract states what the wrapper relies on: nonzero from
  gatt_disconnect means nothing to tear down, an accepted teardown
  always reaches a terminal report
2026-08-09 14:03:04 -05:00
J. Nick Koston 3c57064bf2 Address review: skip table materialization when no node will read it
A client with only connect/disconnect automations paid the build/free
cycle on every reconnect for nothing - post-setup heap churn on the
Bluedroid direct-consumer path. release_services() stays unconditional.
2026-08-09 13:45:33 -05:00
J. Nick Koston f9be82d1a6 Address review: on_notify_state joins the node interface
notify_characteristic() had no completion path to the node that asked;
the hook lands now while the surface has no external users, with the
client fanning out after its breadcrumb.
2026-08-09 13:30:08 -05:00
J. Nick Koston 10a0bf0ff8 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 13:29:27 -05:00
J. Nick Koston 484674b85d Derive the bluedroid source-filter entry from the shared helper
frameworks_for_platforms() gains its production caller in the same file
as the platform registry; the derived set is identical to the hand list.
2026-08-09 13:29:00 -05:00
J. Nick Koston 49c1547fda Compile the gated lookup helpers into the ble_device_base test build
The contract test's own #define only covers its translation unit;
ble_gatt_client.cpp needs the define from the build to link the
find_characteristic/find_cccd definitions the lookup tests call.
2026-08-09 13:25:52 -05:00
J. Nick Koston 7b4353b417 Import the service-table materializer from #18198
The Bluedroid on-demand materializer, the neutral lookup helpers' host
tests, and the USE_BLE_GATT_SERVICE_TABLE define move here, next to
their first consumer (the neutral engine's table resolution and the
service_table codegen flag). One behavior fix rides along: hitting
MAX_DESCRIPTORS_PER_CHARACTERISTIC now fails the walk like every other
inconsistency instead of truncating the table silently.
2026-08-09 13:23:44 -05:00
J. Nick Koston 5f20346d54 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/ble_device_base/ble_gatt_client.h
2026-08-09 13:23:17 -05:00
J. Nick Koston 356c1d9134 Guard every GATT op on an unset conn id
discover_services() already returned the contract's not-connected value;
the read/write/notify/pair ops fell through to an arbitrary stack error
instead. Dead for the proxy (the wrapper gates on connected()), live for
a direct consumer racing an op against a teardown.
2026-08-09 13:22:28 -05:00
J. Nick Koston 9534a1df55 Move the dark service-table materializer and lookup helpers to #18205
Nothing in this PR emits USE_BLE_GATT_SERVICE_TABLE, so the Bluedroid
materializer, the neutral find_service/find_characteristic/find_cccd
helpers, their host tests, and the defines.h entry were scaffolding with
no caller here. They move to the PR that introduces their first
consumer. The contract op stays: get_service_table() keeps its {} stub
(the proxy streams in place).
2026-08-09 13:21:36 -05:00
J. Nick Koston fb64b18047 Address review: notify/pairing breadcrumbs, aborted fan-out trace
- on_notify_state and on_pairing_result are handled on the neutral
  client: a failed registration or pairing logs instead of vanishing
  into the default no-op on a frozen surface
- A node tearing the link down during the connect fan-out leaves a
  warning, since on_disconnect then fires with no preceding on_connect
2026-08-09 12:59:24 -05:00
J. Nick Koston 5572bf77dd Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 12:57:42 -05:00
J. Nick Koston d8ec76e02b Fail on premature enumeration terminators; explicit terminal states
- An INVALID_OFFSET/NOT_FOUND before the count from the same cache is a
  contradiction, not an end-of-range: the walker fails the build and the
  streamer aborts with the real cause instead of silently truncating
  (the walker's descriptor loop keeps its terminator - the 64 cap is its
  only bound)
- unconditional_disconnect_'s unset-conn-id path drives the slot to a
  terminal reported state instead of leaning on the scheduled-teardown
  timer
- find_characteristic/find_cccd log a corrupt range before returning the
  not-found sentinel (moved out of line - no ESP_LOG in headers)
2026-08-09 12:57:36 -05:00
J. Nick Koston cf5faa454c Address review: rejection logs, honest service_table docstring, terse sweep
- connect()/disconnect() log a rejected user action like the legacy
  engine instead of silently ignoring it
- The service_table docstring states the flag is Bluedroid-only forward
  scaffolding and that rp2's proxy hub must keep its materializer
  regardless (it streams through get_service_table())
- Review-round comments tightened to repo style
2026-08-09 12:47:17 -05:00
J. Nick Koston 55d14c6fbf Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
2026-08-09 12:43:57 -05:00
J. Nick Koston d86bdb36cf Address review: honor SEARCH_CMPL status, close a late OPEN, backstops
- handle_search_cmpl_ takes the event status: a failed discovery no
  longer reads as a clean zero-service success (the empty cached
  database satisfies the count calls), and the table invalidation plus
  param step-down still run on the failure path
- A late successful OPEN_EVT on a slot the teardown net already gave up
  is closed instead of leaking a live controller link with no conn id
- The counting pass logs its failure like the two paths below it
- static_assert pins the wrapper's compile-time streamer detection; a
  signature drift would silently fall back to a table proxy builds
  compile without
- find_characteristic/find_cccd range math widened to 32-bit (correct by
  type; the wrapped-end case was already loop-safe)
2026-08-09 12:42:17 -05:00
J. Nick Koston c64684dd00 Tighten review-round comments to repo style 2026-08-09 12:36:55 -05:00
J. Nick Koston ed002feee9 Address review: mid-fan-out release, self-enforcing cap, scaffolding note
- The fan-out early return releases the borrowed table itself instead of
  relying on the backend's own teardown release
- A registered non-esp32 backend platform without a slot cap now fails
  validation loudly instead of failing open (the test pin remains)
- new_gatt_backend's docstring says the service_table flag is forward
  scaffolding for the first esp32 direct consumer, not load-bearing on
  any current build
2026-08-09 12:19:43 -05:00
J. Nick Koston 38f31d85b6 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 12:19:14 -05:00
J. Nick Koston d04ef2378e Contract doc: writes without response also report on_write_result
Both backends report the no-response completion (Bluedroid at
WRITE_CHAR_EVT, rp2 synthesized or via the can-send path); the neutral
write action already relies on it.
2026-08-09 12:18:35 -05:00
J. Nick Koston 5e26633231 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 12:07:13 -05:00
J. Nick Koston 6c78b480c4 Return the wrapper to 48 bytes - exact per-slot RAM parity with dev
The measured layout showed 56+48 = 104 B/slot vs dev's 96. Two changes
that only pay together (the wrapper is 8-aligned, so nothing under a
full 8 helps):

- The tail packs into 2 bytes of bitfields; remote_addr_type_ becomes a
  start_connect_ parameter (written and read on adjacent lines only)
- The wrapper's duplicate 10 s teardown timer is gone: the backend owns
  the whole safety window. Its timer now also arms for a teardown
  scheduled during CONNECTING (the one case the wrapper covered alone),
  a late OPEN_EVT on a given-up slot closes the link instead of
  resurrecting it, and the wrapper's transient-refusal branch collapses
  because both backends return nonzero only when already idle

48 + 48 = 96 B/slot, the split at dev parity.
2026-08-09 12:07:10 -05:00
J. Nick Koston 3d33a16b71 Return the wrapper to 48 bytes - exact per-slot RAM parity with dev
The measured layout showed 56+48 = 104 B/slot vs dev's 96. Two changes
that only pay together (the wrapper is 8-aligned, so nothing under a
full 8 helps):

- The tail packs into 2 bytes of bitfields; remote_addr_type_ becomes a
  start_connect_ parameter (written and read on adjacent lines only)
- The wrapper's duplicate 10 s teardown timer is gone: the backend owns
  the whole safety window. Its timer now also arms for a teardown
  scheduled during CONNECTING (the one case the wrapper covered alone),
  a late OPEN_EVT on a given-up slot closes the link instead of
  resurrecting it, and the wrapper's transient-refusal branch collapses
  because both backends return nonzero only when already idle

48 + 48 = 96 B/slot, the split at dev parity.
2026-08-09 12:06:44 -05:00
J. Nick Koston e3aa8773e6 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 11:50:26 -05:00
J. Nick Koston be5e00aefd Address review: discovery status, loop lifetime, event conn id
- SEARCH_CMPL honors the event's own status: a failed discovery leaves
  an empty cached database that the count calls read as a clean zero,
  which would have become an authoritative empty service list
- The loop stays enabled while a link exists and settles only at IDLE -
  the stack-down recovery needs a tick to run in ESTABLISHED, which the
  settle-on-established optimization was silently blocking
- The pre-started search uses the OPEN_EVT conn id (replaced-class
  parity)
2026-08-09 11:50:22 -05:00
J. Nick Koston 9c5ebdffc9 Address review: complete the node-facing surface, wiring test, comment placement
- read_descriptor and notify_characteristic passthroughs join the frozen
  surface so the first migrated node can subscribe without extending the
  client; the comment now says which ops have callers today
- The choke-point test also validates through the public
  BLE_CLIENT_SCHEMA, so removing the cv.All wiring fails a test
- DOMAIN no longer sits between the BTstack comment and the constant it
  explains
2026-08-09 11:26:47 -05:00
J. Nick Koston 9554e1a212 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 11:19:58 -05:00
J. Nick Koston 28491f83b4 Close the silent-failure paths from review
- A refused MTU request no longer wedges the connection: OPEN_EVT
  reports with the default MTU so the consumer proceeds (previously no
  CFG_MTU_EVT meant no connected report and a slot that never freed)
- Disabling the BLE stack settles a live link with a connected=false
  report before forgetting it, so the consumer frees its slot instead of
  holding a phantom connection
- A refused security response answers the pairing request with the
  failure instead of hanging it
- The service-table walk mismatch logs its reason before discarding
- The shared streamer's two bounds-check aborts use abort_service_stream
  with an ATT Unlikely Error cause (new shared GATT_ERR_UNLIKELY)
2026-08-09 11:19:55 -05:00
J. Nick Koston 2f71ec16d7 Close the silent-failure paths from review
- A refused MTU request no longer wedges the connection: OPEN_EVT
  reports with the default MTU so the consumer proceeds (previously no
  CFG_MTU_EVT meant no connected report and a slot that never freed)
- Disabling the BLE stack settles a live link with a connected=false
  report before forgetting it, so the consumer frees its slot instead of
  holding a phantom connection
- A refused security response answers the pairing request with the
  failure instead of hanging it
- The service-table walk mismatch logs its reason before discarding
- The shared streamer's two bounds-check aborts use abort_service_stream
  with an ATT Unlikely Error cause (new shared GATT_ERR_UNLIKELY)
2026-08-09 11:19:25 -05:00
J. Nick Koston 3daa1d2450 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 10:56:31 -05:00
J. Nick Koston a513cef4e2 Encode the pre-started search lifecycle as a state machine
The three coordinated flags become one SearchState (NONE / PRESTARTED /
PRESTART_DONE / CLAIMED / REPORT_PENDING): illegal combinations are
unrepresentable, delivery and the two reset sites collapse to single
assignments, and the requested-meets-done subtlety becomes a named
state. A second discover_services() while one is in flight now returns 0
instead of issuing a duplicate search (one completion is already owed).
Same RAM: the enum sits in a 4-bit bitfield in the same flags byte.
2026-08-09 10:56:27 -05:00
J. Nick Koston fa17215053 Address review: fan-out guard, refusal warning, sighting-less connect hint
- The connected fan-out stops if a node tears the link down mid-loop
  (latent until a backend settles a teardown synchronously)
- A synchronously refused disconnect logs its own warning - a backend/
  client state divergence is no longer the most quietly logged outcome
- An action-initiated connect before any sighting warns that the public
  address type is assumed (an opaque repeated failure becomes
  self-diagnosing); the connect() doc no longer mentions a config key
  that does not exist
- The explicit-connections ledger test asserts the exact charged count;
  the host-test override comment names the current gate macro
2026-08-09 10:43:21 -05:00
J. Nick Koston 073d9a72a0 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 10:22:39 -05:00
J. Nick Koston 3a24d0b03b Reset the search latches per connection attempt
The latches were cleared only in set_idle_(), but the stack-down branch
in loop() reaches IDLE through set_state() without it, so after a
ble.disable/enable cycle the next connection's discover_services() would
complete immediately from the previous connection's latched result and
HA would cache an authoritative empty service list. tracker_connect_()
now resets the latch alongside the two per-attempt flags it already
clears, and delivery consumes the whole latch so a re-discovery on a
live link issues a real search instead of re-reporting the first
result.
2026-08-09 10:22:35 -05:00
J. Nick Koston b229bd4d8a Address review: explicit connections claim slots, re-enable clears backoff
- Explicit `connections:` entries now charge the slot ledger like the
  generated ones (dev let them evade the controller budget); pinned by a
  test
- set_enabled(true) clears the connect backoff - a re-enable is an
  explicit try-again
- The neutral engine's source filter uses the shared
  frameworks_for_platforms helper; ledger tests use the conftest fixture
2026-08-09 10:09:20 -05:00
J. Nick Koston c8e240198d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 10:07:42 -05:00
J. Nick Koston 0512339c58 Deduplicate the stream-abort sequence and small cleanups
- abort_service_stream() on the wrapper (which owns the latch discipline)
  replaces five identical field-poke sequences in the streamer
- The pre-start reuses check_and_log_error_ like the serialized path
- discover_services() sets the requested flag where it sticks instead of
  set-then-rollback; deliver_pending_search_() returns void (no caller
  reads it); loop()'s IDLE arm folds into the settle arm
- SEARCH_CMPL during a teardown skips the param/count work whose result
  is never delivered
- frameworks_for_platforms() in config_helpers derives framework sets
  from platform lists; the hub.cpp filter entry is now a named map pinned
  by a test against the proxy's platform list
2026-08-09 10:05:42 -05:00
J. Nick Koston e95bebb7d0 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection.h
2026-08-09 09:54:08 -05:00
J. Nick Koston 084ff9d6e3 Restore the discovery/MTU overlap and latch streamer abort causes
The split serialized service discovery behind the MTU exchange: the
backend reported connected only at CFG_MTU_EVT and the wrapper started
discovery on that report, costing one ATT round trip per uncached
connection. The backend now pre-starts the search at OPEN_EVT (it knows
the connection type) and completes the consumer's discover_services()
from it: SEARCH_CMPL latches silently until requested, the flush after
the connected report delivers in the same event drain, and a refused
pre-start falls back to the serialized path. Net RAM cost is zero (the
flags pack into one bitfield byte plus a status byte, replacing the two
existing bools).

Also from review: SEARCH_CMPL no longer clobbers a teardown in progress
with ESTABLISHED, the five streamer abort sites latch pending_error_ so
HA sees the real Bluedroid status instead of a generic HCI reason, and a
completed re-discovery frees a materialized service table before the
counts its offsets derive from move.
2026-08-09 09:52:58 -05:00
J. Nick Koston 4a9e5f5da9 Require the proxy in the wrapper's compile gate
The hub wrapper serves the proxy's API surface, but its gate keyed on the
backend define alone while the backend guarded its own proxy pieces on
USE_BLUETOOTH_PROXY. Narrow BLUETOOTH_CONNECTION_HAS_GATT to require both
so a future backend-only consumer build agrees with the backend's guards.
No change to any build this PR can produce (only the proxy emits
USE_BLE_GATT_CLIENT here).
2026-08-09 09:44:23 -05:00
J. Nick Koston f6158597ac Address review: slot-cap testing mode, derived source filter, breadcrumbs
- Skip the hub-platform GATT slot cap in testing mode (mirrors
  esp32_ble.validate_connection_slots) so grouped builds validate
- Derive the neutral engine's source filter from the backend registry so
  a new platform cannot validate and then fail at link
- Log the real code on refused service discovery and cancelled connects;
  read completions get the same breadcrumb writes have
- Comment accuracy: the dumper arm is the legacy shape, not a superset
- Ledger tests: real-validator end-to-end path and the testing-mode skip
2026-08-09 09:42:50 -05:00
J. Nick Koston 8924640593 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_proxy/__init__.py
2026-08-09 09:37:59 -05:00
J. Nick Koston 26c000c417 Collapse shim-era indirection left by the merge of the two halves
- Fold the one-line event-handler forwarders, the state accessor renames,
  and the report helper into direct calls; drop the dead forward
  declaration and the tracker_client() accessor (the backend is the
  tracker client)
- Hoist DEFAULT_ATT_MTU next to the other shared GATT constants and use
  it in all three engines
- Emit BLUETOOTH_PROXY_MAX_CONNECTIONS once, in _connections_to_code
- Drop the unused esp_gatt_common_api.h include and DOMAIN constant
- Comment accuracy: stale shim/esp32-parity wording, registry roles,
  request_gatt_client() caller
2026-08-09 09:36:17 -05:00
J. Nick Koston 7cb32f9546 Clean up iteration residue from the review rounds
- Drop unused includes (ble_client_state.h, <utility>) and the unused
  notify/indicate property constants (no CCCD path yet)
- Note the frozen node-facing surface on the uncalled backend forwarders
- Keep the legacy-only 'name' key out of the neutral schema
- Move the slot-ledger test to tests/component_tests/bluetooth_connection
- Comment accuracy fixes (choke-point wording, wrapped lines, backoff note)
2026-08-09 09:31:21 -05:00
J. Nick Koston 13de0275c8 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:19:35 -05:00
J. Nick Koston 22af86a65b Tighten new comments to repo style 2026-08-09 09:19:32 -05:00
J. Nick Koston 83b099d708 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:17:42 -05:00
J. Nick Koston 6957e2ef15 Type gattc_if_ at its real width instead of shrinking the timer 2026-08-09 09:17:38 -05:00
J. Nick Koston 743a10fa3e Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:04:35 -05:00
J. Nick Koston f7b1e9ae65 Shrink the disconnect timestamp to a tick so the backend packs to 48 bytes 2026-08-09 09:04:31 -05:00
J. Nick Koston 53a5c7b051 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 09:00:25 -05:00
J. Nick Koston 54c50c07c5 Merge remote-tracking branch 'origin/dev' into esp32-gatt-backend
# Conflicts:
#	esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
#	esphome/components/bluetooth_proxy/bluetooth_proxy.h
#	esphome/core/defines.h
2026-08-09 08:59:12 -05:00
J. Nick KostonandGitHub e9f428983e [ble_device_base] Bind BLEHub to the build's tracker at compile time (#18181) 2026-08-09 08:57:31 -05:00
J. Nick Koston 64df0c1da1 The fixture comment follows the renamed predicate here 2026-08-09 08:56:18 -05:00
J. Nick Koston 9c31e08e95 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 08:55:53 -05:00
J. Nick Koston 93124f1f1f Align two comments with the code they describe 2026-08-09 08:55:49 -05:00
J. Nick Koston 8abb5f102d Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 04:20:42 -05:00
J. Nick Koston 12fb1b0697 Name the MTU default and bound the descriptor walk sensibly 2026-08-09 04:20:39 -05:00
J. Nick Koston 81216da8e7 Apply review: honest slot docstring, cap-coverage pin, write breadcrumb 2026-08-09 04:02:45 -05:00
J. Nick Koston ed2cdad9b4 Merge branch 'esp32-gatt-backend' into neutral-ble-client 2026-08-09 03:44:14 -05:00
J. Nick Koston be818a5bf2 Apply review polish: bounded walk, real statuses, guarded settles 2026-08-09 03:44:10 -05:00
J. Nick Koston 444a217ee7 Apply review: honest teardown status and unresolved-write cause 2026-08-09 03:29:20 -05:00
J. Nick Koston 93f9d4a1ad Apply review: state before fan-out, refusal breadcrumbs, write status log 2026-08-09 03:13:06 -05:00
J. Nick Koston ac7d39a8c1 Merge branch 'esp32-gatt-backend' into neutral-ble-client
# Conflicts:
#	esphome/components/bluetooth_connection/__init__.py
#	esphome/components/bluetooth_connection/bluetooth_connection_rp2.h
#	tests/component_tests/bluetooth_proxy/test_platform_gates.py
2026-08-09 03:09:32 -05:00
J. Nick Koston 612033062f Apply combined review: wire MTU parity, abort teardown parity, closure ownership 2026-08-09 03:08:47 -05:00
J. Nick Koston f9dbd24a2a Apply review: settle refused teardowns, per-platform schema cache, ledger tests 2026-08-09 02:50:01 -05:00
J. Nick Koston 833a835e0f Combine the flip into the backend PR and put the table define in the esp32 arm 2026-08-09 02:27:53 -05:00
J. Nick Koston 399dc26236 Follow the contract op rename 2026-08-09 02:17:36 -05:00
J. Nick Koston 0d9aea9594 Merge branch 'esp32-proxy-flip' into neutral-ble-client 2026-08-09 02:17:08 -05:00
J. Nick Koston f2261d6632 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 02:16:17 -05:00
J. Nick Koston d224a82e38 Fold the tracker shim into the backend and pass the MTU through 2026-08-09 02:16:14 -05:00
J. Nick Koston ce436ff8b4 Apply review: refusal resolves waiters, cancel skips backoff, wrap-safe hold-off 2026-08-09 01:54:16 -05:00
J. Nick Koston cbda1e71be Merge branch 'esp32-proxy-flip' into neutral-ble-client 2026-08-09 01:53:48 -05:00
J. Nick Koston ceabfeb6f4 Include the header that declares the bond removal 2026-08-09 01:52:39 -05:00
J. Nick Koston 5e11a886f1 Merge branch 'esp32-gatt-backend' into esp32-proxy-flip 2026-08-09 01:51:59 -05:00
J. Nick Koston e771b2522a Terminate a released stream without services-done and keep the net armed 2026-08-09 01:51:42 -05:00
J. Nick Koston ee96293c4c Platform-split ble_client codegen and gate the raw-gattc node family 2026-08-09 01:45:34 -05:00
J. Nick Koston fa18f5a0ad Neutral ble_client engine on the GATT contract 2026-08-09 01:45:11 -05:00
J. Nick Koston 269d52ac2e Revive backend-only builds, the slot ledger, and the table gate 2026-08-09 01:33:45 -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 409ff027cc Collapse the identical ignore branches 2026-08-09 00:59:16 -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 cc81069d02 Keep the esp32 maintenance functions outside the gated surface 2026-08-09 00:28:54 -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
969 changed files with 44153 additions and 7467 deletions
+3 -3
View File
@@ -32,7 +32,7 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -49,7 +49,7 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
@@ -58,5 +58,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
+2 -1
View File
@@ -70,6 +70,7 @@ async function isStackedPr(github, context) {
async function detectMergeBranch(github, context) {
const labels = new Set();
const baseRef = context.payload.pull_request.base.ref;
const defaultBranch = context.payload.repository.default_branch;
if (baseRef === 'release') {
labels.add('merging-to-release');
@@ -78,7 +79,7 @@ async function detectMergeBranch(github, context) {
} else if (await isStackedPr(github, context)) {
// GitHub manages the merge order for a stack, so these are not blocked.
labels.add('stacked-pr');
} else if (baseRef !== 'dev') {
} else if (baseRef !== defaultBranch) {
// A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr');
}
@@ -43,14 +43,14 @@ const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
// Builds a fresh context for detectMergeBranch tests instead of mutating the
// shared CONTEXT fixture above (which other describe blocks rely on).
function makeMergeContext(baseRef, { stack } = {}) {
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
const pull_request = { number: 1, base: { ref: baseRef } };
if (stack !== undefined) {
pull_request.stack = stack;
}
return {
repo: { owner: 'esphome', repo: 'esphome' },
payload: { pull_request }
payload: { pull_request, repository: { default_branch: defaultBranch } }
};
}
@@ -136,6 +136,21 @@ describe('detectMergeBranch', () => {
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
assert.equal(state.calls, 1);
});
it('base ref matches default branch adds no labels', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('other', { defaultBranch: 'other' });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), []);
});
it('base ref dev when the default branch is main adds chained-pr', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('dev', { defaultBranch: 'main' });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
});
});
// ---------------------------------------------------------------------------
+26 -4
View File
@@ -29,7 +29,7 @@ jobs:
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
@@ -41,10 +41,32 @@ jobs:
version: "0.11.15"
- name: Install apt dependencies
# PR-only workflow, so nothing on dev could seed a shared apt cache
# entry; the cached apt action would save one copy per PR. Plain apt
# with every call bounded: the apt.conf.d timeouts make a dead
# mirror fail over in seconds, and timeout runs under sudo so it can
# kill apt-get itself. Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes slow.
timeout-minutes: 15
run: |
sudo apt update
sudo apt-cache show protobuf-compiler
sudo apt install -y protobuf-compiler
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y protobuf-compiler; then
protoc --version
exit 0
fi
# Rescue path: refresh the lists once with a generous bound; the
# apt config already fails a stalled mirror over quickly.
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
dpkg --configure -a || true
sudo timeout -k 15 300 apt-get update
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
apt-get install -y protobuf-compiler
protoc --version
- name: Install python dependencies
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
+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
+108 -47
View File
@@ -49,7 +49,7 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -68,6 +68,22 @@ jobs:
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
seed-apt-cache:
name: Seed apt package cache
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
determine-jobs:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -179,6 +195,7 @@ jobs:
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_alias_registry.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
@@ -322,7 +339,8 @@ jobs:
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
# Must match seed-apt-cache's image: the apt cache key has no OS in it.
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
@@ -334,24 +352,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ccache
# Speeds up the host compiles: tests in a bucket compile overlapping
# component sets, so later tests reuse earlier tests' objects.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ccache
- name: Restore ccache (restore-only)
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
# back to any seed when the bucket layout differs from dev.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly;
# libsdl2-dev is unused here and carried only for cache-key parity.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
packages: libsdl2-dev ccache
version: 1.1
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -366,7 +376,7 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -400,14 +410,6 @@ jobs:
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save ccache
# Pull request saves land in per-PR scopes nothing else can reuse;
# dev pushes seed the shared copy instead.
if: github.event_name != 'pull_request'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
import-time:
name: Check import esphome.__main__ time
@@ -440,12 +442,17 @@ jobs:
benchmarks:
name: Run CodSpeed benchmarks
runs-on: ubuntu-24.04
timeout-minutes: 30
needs:
- common
- determine-jobs
if: >-
(github.event_name == 'push' && github.ref_name == 'dev') ||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
github.repository == 'esphome/esphome' && (
(github.event_name == 'push' && github.ref_name == 'dev') ||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
)
# CodSpeed benchmarks require a CodSpeed account linked to the repository to run
# (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -459,14 +466,60 @@ jobs:
- name: Build benchmarks
id: build
run: |
# pipefail: without it a failed build is masked by the grep/cut
# pipeline below, leaving BINARY empty and silently dropping every
# C++ benchmark from the run while the job still reports success.
set -o pipefail
. venv/bin/activate
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
# --build-only prints BUILD_BINARY=<path> to stdout
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
export BENCHMARK_LIB_CONFIG
# --build-only prints BUILD_BINARY=<path> to stdout; the grep is
# non-fatal so a missing marker reaches the check below instead of
# tripping errexit at this assignment
BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
if [ -z "$BINARY" ]; then
echo "::error::Benchmark build did not report a binary path"
exit 1
fi
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Bound apt fetches and pre-install libc6-dbg
# The CodSpeed runner installs valgrind + libc6-dbg via its own
# unbounded apt-get update; per-invocation apt options cannot reach
# it. The apt.conf.d timeouts below bound every later apt call in
# this job, the runner's included. Pre-installing libc6-dbg lets the
# runner skip apt once its valgrind cache is restored (it checks
# ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
# would not count). Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes
# slow. Best effort; the job timeout is the last backstop.
timeout-minutes: 15
continue-on-error: true
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
if dpkg -s libc6-dbg >/dev/null 2>&1; then
echo "libc6-dbg already installed"
exit 0
fi
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y libc6-dbg; then
exit 0
fi
# Rescue path: refresh the lists once with a generous bound; the
# apt config already fails a stalled mirror over quickly.
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
dpkg --configure -a || true
sudo timeout -k 15 300 apt-get update
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
with:
run: |
. venv/bin/activate
@@ -516,7 +569,6 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- id: clang-tidy
@@ -550,24 +602,29 @@ jobs:
fetch-depth: 2
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Key on the exact Python version as well: LibreTiny creates a venv under
# ~/.platformio/penv whose interpreter is a symlink into the runner's
# hosted toolcache, so a cache saved on an older runner image breaks once
# a new image ships a newer patch release and drops the old interpreter.
- name: Cache platformio
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache ESP-IDF install
if: matrix.cache_idf
@@ -707,7 +764,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
@@ -787,7 +843,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
@@ -874,7 +929,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:
@@ -886,12 +941,17 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1093,7 +1153,7 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -1426,6 +1486,7 @@ jobs:
# this check.
needs:
- common
- seed-apt-cache
- determine-jobs
- ci-custom
- pylint
+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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
+38
View File
@@ -0,0 +1,38 @@
---
name: Nightly Dev Release
# Works out the dated dev tag and starts the release workflow with it, so that
# the release run is named after the tag it builds. A workflow run name is
# fixed when the run starts and cannot read a file or the current date.
on:
schedule:
- cron: "0 2 * * *"
permissions:
contents: read # actions/checkout to read the version from esphome/const.py
jobs:
trigger:
name: Start release build
if: github.repository == 'esphome/esphome'
runs-on: ubuntu-latest
permissions:
contents: read # actions/checkout to read the version from esphome/const.py
actions: write # gh workflow run starts release.yml
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Start the release workflow
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
if [[ -z "$VERSION" ]]; then
echo "::error::Could not read __version__ from esphome/const.py"
exit 1
fi
TAG="${VERSION}$(date --utc '+%Y%m%d')"
echo "Starting release build for ${TAG}"
gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
+30 -6
View File
@@ -1,12 +1,23 @@
---
name: Publish Release
# Releases (production and beta) are named after the version they publish.
# Dev builds are named after the dated dev tag, which is passed in by the
# nightly workflow because a run name cannot compute it itself.
run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
on:
workflow_dispatch:
inputs:
tag:
description: >-
Tag to build. Only supported on dev, where the nightly workflow
uses it. Leave empty to build the version from esphome/const.py
with today's date appended.
required: false
default: ""
release:
types: [published]
schedule:
- cron: "0 2 * * *"
permissions:
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
@@ -23,6 +34,8 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get tag
id: tag
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
# yamllint disable rule:line-length
run: |
if [[ "${{ github.event_name }}" = "release" ]]; then
@@ -34,12 +47,23 @@ jobs:
ENVIRONMENT="production"
fi
else
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
today="$(date --utc '+%Y%m%d')"
TAG="${TAG}${today}"
BRANCH=${GITHUB_REF#refs/heads/}
# The nightly workflow passes the finished tag so that the run name
# matches what is built. Without it, work it out here.
TAG="${INPUT_TAG}"
if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
exit 1
fi
if [[ -z "$TAG" ]]; then
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
today="$(date --utc '+%Y%m%d')"
TAG="${TAG}${today}"
if [[ "$BRANCH" != "dev" ]]; then
TAG="${TAG}-${BRANCH}"
fi
fi
if [[ "$BRANCH" != "dev" ]]; then
TAG="${TAG}-${BRANCH}"
BRANCH_BUILD="true"
ENVIRONMENT=""
else
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``prek`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.16.3
hooks:
# Run the linter.
- id: ruff
+16
View File
@@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
these common names (for example the Realtek SDKs used by LibreTiny define
`#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
before the compiler sees it, breaking the build and clang-tidy on those platforms.
* **Python Idioms:**
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
@@ -757,3 +763,13 @@ The project uses English for non-code content. When drafting documentation, code
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
audience, including non-native English speakers.
## 10. Code Comments
Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
Function and method comment blocks may include more detail as required to make
calling contracts clear and document parameter usage, but should still be kept concise.
Avoid redundancy and repetition; comments should never simply restate what the code already says.
+5
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
@@ -237,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/switch/* @Links2004
@@ -288,6 +290,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 +354,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
@@ -462,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.8.0-dev
PROJECT_NUMBER = 2026.9.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+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.12.1
RUN \
platformio settings set enable_telemetry No \
+219 -82
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import re
import sys
import time
from typing import Protocol
from typing import TYPE_CHECKING, Protocol
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
@@ -21,7 +21,6 @@ from esphome.const import (
ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API,
CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
@@ -29,6 +28,7 @@ from esphome.const import (
CONF_DISCOVER_IP,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -42,7 +42,7 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
@@ -71,6 +71,9 @@ from esphome.util import (
safe_print,
)
if TYPE_CHECKING:
import threading
# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
# module's top level. Every `esphome` invocation — including fast paths
# like `esphome version` — pays the cost of what's imported here before
@@ -273,8 +276,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
if purpose == Purpose.LOGGING and not has_api():
return (
"Cannot view logs over the network: no 'api:' component is "
"configured. Network log streaming requires the native API; add "
"an 'api:' component, enable MQTT logging, or view logs over USB."
"configured. Add an 'api:' component, enable MQTT logging, add a "
"'web_server:' component, or view logs over USB."
)
if purpose == Purpose.UPLOADING and not has_ota():
return (
@@ -314,9 +317,12 @@ def choose_upload_log_host(
]
resolved.append(choose_prompt(options, purpose=purpose))
elif device == "OTA":
# Logs can stream over a network transport via the native API
# or the web_server HTTP SSE feed.
network_logging = has_api() or has_web_server_logging()
# ensure IP adresses are used first
if is_ip_address(CORE.address) and (
(purpose == Purpose.LOGGING and has_api())
(purpose == Purpose.LOGGING and network_logging)
or (purpose == Purpose.UPLOADING and has_ota())
):
resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -328,7 +334,11 @@ def choose_upload_log_host(
if has_mqtt_logging():
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
if (
network_logging
and has_non_ip_address()
and has_resolvable_address()
):
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
@@ -390,7 +400,7 @@ def choose_upload_log_host(
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
if has_api() or has_web_server_logging():
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
@@ -483,6 +493,21 @@ def has_web_server_ota() -> bool:
)
def has_web_server_logging() -> bool:
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
The ``web_server`` component exposes a ``/events`` Server-Sent Events
stream that carries ``event: log`` frames. This requires version 2+ (the
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
"""
web_conf = CORE.config.get(CONF_WEB_SERVER)
if web_conf is None:
return False
if web_conf.get(CONF_VERSION, 2) == 1:
return False
return web_conf.get(CONF_LOG, True)
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
if CONF_MQTT not in CORE.config:
@@ -545,11 +570,48 @@ def has_name_add_mac_suffix() -> bool:
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
config: ConfigType,
username: str,
password: str,
client_id: str,
stop_event: "threading.Event | None" = None,
) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
return mqtt.get_esphome_device_ip(
config, username, password, client_id, stop_event=stop_event
)
def _add_network_device(device: str, network_devices: list[str]) -> None:
"""Append a device to the list, expanding it through ``CORE.address_cache``.
If the hostname is already in the address cache (e.g. populated by mDNS
discovery), substitute the cached IPs so aioesphomeapi doesn't open its
own Zeroconf to re-resolve it. Duplicates are dropped.
"""
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(addr for addr in cached if addr not in network_devices)
elif device not in network_devices:
network_devices.append(device)
def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]:
"""Split the device list into direct addresses and an MQTT-lookup flag.
Direct addresses are expanded through ``CORE.address_cache`` and deduped
the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings
are not resolved, only reported via the returned bool so the caller can
defer the broker lookup.
"""
network_devices: list[str] = []
has_mqtt_lookup = False
for device in devices:
if get_port_type(device) in _MQTT_PORT_TYPES:
has_mqtt_lookup = True
else:
_add_network_device(device, network_devices)
return network_devices, has_mqtt_lookup
def _resolve_network_devices(
@@ -582,40 +644,44 @@ def _resolve_network_devices(
if port_type in _MQTT_PORT_TYPES:
# Only resolve MQTT once, even if multiple MQTT entries
if not mqtt_resolved:
try:
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
# pylint can't infer mqtt_get_ip's return through its
# lazy ``from esphome import mqtt`` import, so it flags
# the genexpr below.
network_devices.extend(
addr
for addr in mqtt_ips # pylint: disable=not-an-iterable
if addr not in network_devices
)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_ips = _mqtt_get_ip_or_warn(
config, args.username, args.password, args.client_id
)
network_devices.extend(
addr for addr in mqtt_ips if addr not in network_devices
)
mqtt_resolved = True
continue
# If the hostname is already in the address cache (e.g. populated by
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
# open its own Zeroconf to re-resolve it.
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
network_devices.extend(
addr for addr in cached if addr not in network_devices
)
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
_add_network_device(device, network_devices)
return network_devices
def _mqtt_get_ip_or_warn(
config: ConfigType,
username: str,
password: str,
client_id: str,
stop_event: "threading.Event | None" = None,
) -> list[str]:
"""Look up the device IP via MQTT, returning [] with a warning on failure.
This owns the failure policy for MQTT IP discovery on paths that have
other addresses to fall back on: a broker problem must not abort the
operation. Also used as the deferred resolver handed to ``run_logs``,
where it runs in a worker thread.
"""
try:
return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event)
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
return []
def run_miniterm(config: ConfigType, port: str, args) -> int:
from datetime import datetime
@@ -1291,25 +1357,23 @@ def _upload_via_native_api(
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
web_conf = config.get(CONF_WEB_SERVER)
if not web_conf:
raise EsphomeError(
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
f"is not configured."
)
remote_port = int(web_conf[CONF_PORT])
auth = web_conf.get(CONF_AUTH) or {}
username = auth.get(CONF_USERNAME)
password = auth.get(CONF_PASSWORD)
from esphome import web_server_ota
from esphome.web_server_helpers import get_web_server_connection
remote_port, username, password = get_web_server_connection(config)
return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary
)
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
from esphome import web_server_logs
from esphome.web_server_helpers import get_web_server_connection
port, username, password = get_web_server_connection(config)
return web_server_logs.run_logs(network_devices, port, username, password)
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
@@ -1418,17 +1482,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
return run_miniterm(config, port, args)
# Check if we should use API for logging
# Resolve MQTT magic strings to actual IP addresses
if has_api() and (
network_devices := _resolve_network_devices(devices, config, args)
):
from esphome.api_client import run_logs
if has_api():
network_devices, has_mqtt_lookup = _split_network_devices(devices)
mqtt_resolver = None
if has_mqtt_lookup:
if network_devices:
# Addresses are already known, so don't block startup on the
# MQTT broker lookup; hand it to run_logs as a deferred
# resolver that runs in the background and feeds discovered
# addresses into the running log client, keeping MQTT as a
# fallback for when the known addresses are stale (e.g. DHCP
# reassigned the IP).
mqtt_resolver = functools.partial(
_mqtt_get_ip_or_warn,
config,
args.username,
args.password,
args.client_id,
)
else:
# The MQTT lookup is the only way to find the device; resolve
# it up front since the client needs an address to start with.
network_devices = _resolve_network_devices(devices, config, args)
if network_devices:
from esphome.api_client import run_logs
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
)
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
mqtt_resolver=mqtt_resolver,
)
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome import mqtt
@@ -1437,6 +1521,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
config, args.topic, args.username, args.password, args.client_id
)
# Fall back to the web_server HTTP SSE log stream for devices that have
# web_server: but no api: (the logging counterpart to web_server OTA).
if has_web_server_logging() and (
network_devices := _resolve_network_devices(devices, config, args)
):
return _show_logs_via_web_server(config, network_devices)
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
@@ -1941,7 +2032,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
print(
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
@@ -1954,7 +2045,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
print(
safe_print(
color(
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
)
@@ -2001,7 +2092,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
> 1
):
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
safe_print(
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
)
return 1
new_raw = re.sub(
@@ -2019,7 +2112,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
print(
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
@@ -2029,7 +2122,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
print(
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
@@ -2037,7 +2130,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
return 1
if new_path.exists():
print(
safe_print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
@@ -2045,7 +2138,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
)
return 1
print(
safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
@@ -2054,7 +2147,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
if rc != 0:
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
new_path.unlink()
return 1
@@ -2080,7 +2173,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
if CORE.config_path != new_path:
CORE.config_path.unlink()
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
@@ -2507,6 +2600,49 @@ def parse_args(argv):
return parser.parse_args(arguments)
def _warn_if_source_tree_mismatch() -> None:
"""Warn when the checkout the user is standing in is not the one being run.
An editable install records one absolute path, so a venv shared between git
worktrees (or reused after a checkout is copied or renamed) keeps importing
the tree it was installed from. Every command then silently runs, and
compiles, sources the user is not looking at. Only fires inside a checkout,
so ordinary installs never see it.
"""
try:
cwd = Path.cwd()
except OSError:
return # working directory is gone; a diagnostic must not break startup
for candidate in (cwd, *cwd.parents):
if (candidate / "esphome" / "__main__.py").is_file():
standing_in = candidate.resolve()
break
else:
return # not inside a checkout; nothing to compare against
running = Path(__file__).resolve().parent.parent
# Both sides are resolved, so on a case-sensitive filesystem this matches
# plain equality. samefile() compares device and inode, which additionally
# covers a case-insensitive filesystem (macOS) reaching one directory by
# differently cased paths. Falls back to equality if either path is gone.
try:
same = standing_in.samefile(running)
except OSError:
same = standing_in == running
if same:
return
_LOGGER.warning(
"Running ESPHome from a different checkout than the one you are in:\n"
" running from: %s\n"
" you are in: %s\n"
"The installed esphome resolves to the first, so its sources are used.\n"
"Run 'python -m esphome' from the second to use that one instead.",
running,
standing_in,
)
def run_esphome(argv):
from esphome.address_cache import AddressCache
@@ -2525,6 +2661,7 @@ def run_esphome(argv):
args.log_level = "CRITICAL"
setup_log(log_level=args.log_level)
_warn_if_source_tree_mismatch()
if args.command in PRE_CONFIG_ACTIONS:
try:
@@ -2595,7 +2732,8 @@ def run_esphome(argv):
conf_path.name,
)
if config is None:
cache_missed = config is None
if cache_missed:
from esphome.config import read_config
config = read_config(
@@ -2604,26 +2742,25 @@ def run_esphome(argv):
# 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
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
if config is None:
return 2
CORE.config = config
# Fallback for platforms whose validators didn't set the toolchain
# (only the esp32 component reads esp32.framework.toolchain). All
# other platforms only support PlatformIO today.
# other platforms only support PlatformIO today. Must run before the
# cache refresh below so its sidecar records the same toolchain a
# compile would.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config.
if cache_eligible and cache_missed:
from esphome.compiled_config import save_compiled_config_and_sidecar
save_compiled_config_and_sidecar(config)
if args.command not in POST_CONFIG_ACTIONS:
safe_print(f"Unknown command {args.command}")
return 1
+84 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
import logging
import threading
from typing import TYPE_CHECKING, Any
import warnings
@@ -20,6 +21,8 @@ from esphome.stacktrace import LogLineProcessor
from esphome.util import safe_print
if TYPE_CHECKING:
from collections.abc import Callable
from aioesphomeapi.api_pb2 import (
SubscribeLogsResponse, # pylint: disable=no-name-in-module
)
@@ -32,8 +35,18 @@ async def async_run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
) -> None:
"""Run the logs command in the event loop."""
"""Run the logs command in the event loop.
If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt
has no asyncio support on Windows) concurrently with the connection
attempts to ``addresses``, and any addresses it discovers are fed into
the running client. It owns its own failure handling (returning [] when
discovery fails) and must honor the ``threading.Event`` it is passed so
teardown is not delayed by the lookup's wait window; the initial broker
connect itself is only bounded by the socket timeout.
"""
from datetime import datetime
conf = config["api"]
@@ -60,6 +73,41 @@ async def async_run_logs(
# Decoder resolution policy lives in LogLineProcessor.
processor = LogLineProcessor(config, CORE.target_platform)
mqtt_task: asyncio.Task[None] | None = None
mqtt_stop_event = threading.Event()
def _cancel_mqtt_discovery() -> None:
"""Stop the broker lookup once a connection has been established.
Its answer is only useful while still disconnected: after that it
either duplicates the connected address or arrives too late to
matter, so don't keep an idle broker session open for it.
"""
mqtt_stop_event.set()
if mqtt_task is not None and not mqtt_task.done():
mqtt_task.cancel()
async def _resolve_mqtt_addresses() -> None:
"""Discover the device address via the MQTT broker in the background."""
try:
mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event)
if not mqtt_ips:
_LOGGER.debug(
"MQTT discovery %s",
"aborted" if mqtt_stop_event.is_set() else "found no addresses",
)
return
if cli.add_addresses(mqtt_ips):
_LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips))
else:
_LOGGER.debug(
"MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips)
)
except Exception: # pylint: disable=broad-except
# A background task failure would otherwise stay invisible for
# the whole session and only re-raise at teardown
_LOGGER.exception("MQTT address discovery failed")
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
time_ = datetime.now().astimezone()
@@ -98,20 +146,53 @@ async def async_run_logs(
# A top-level ``deep_sleep:`` block means the device is only awake
# briefly; cap the reconnect backoff so a wake window is not missed.
deep_sleep="deep_sleep" in config,
on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None,
)
try:
# Don't start (or keep) the broker lookup if a connection already
# succeeded; the stop event doubles as the not-needed-anymore latch
# and get_esphome_device_ip returns immediately when it is set.
if mqtt_resolver is not None and not mqtt_stop_event.is_set():
mqtt_task = asyncio.create_task(_resolve_mqtt_addresses())
await asyncio.Event().wait()
finally:
await stop()
try:
if mqtt_task is not None:
# Unblock the worker thread first so it can't hold up
# loop.shutdown_default_executor() for the full lookup timeout.
mqtt_stop_event.set()
# Give the worker a moment to exit through its own error
# handling; cancelling first would race out a late failure.
done, _ = await asyncio.wait([mqtt_task], timeout=1.0)
if not done:
mqtt_task.cancel()
# return_exceptions keeps a CancelledError from the cancel()
# above from re-raising here and jumping over the stop() below.
# The task handles Exception itself, so only a BaseException
# escape (e.g. SystemExit from the worker) can land here.
(result,) = await asyncio.gather(mqtt_task, return_exceptions=True)
if isinstance(result, BaseException) and not isinstance(
result, asyncio.CancelledError
):
_LOGGER.error("MQTT address discovery failed", exc_info=result)
finally:
# Must run even if a second cancellation lands mid-cleanup above
await stop()
def run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
) -> None:
"""Run the logs command."""
with suppress(KeyboardInterrupt):
asyncio.run(
async_run_logs(config, addresses, subscribe_states=subscribe_states)
async_run_logs(
config,
addresses,
subscribe_states=subscribe_states,
mqtt_resolver=mqtt_resolver,
)
)
+171 -50
View File
@@ -1,26 +1,178 @@
"""Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
the next upload/logs for that YAML reuses it instead of running the full
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
!lambda/!include/IDs/paths intact; mtime gates staleness.
read_config pipeline. The cache is deliberately lossy: only ``!lambda``
bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
paths, UUIDs and enums store the same string form the YAML dumper
produced for them. JSON additionally coerces non-str dict keys to
strings; validated configs only use string keys (every schema key
validator is ``cv.string``). mtime gates staleness.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from esphome.core import CORE
from esphome.const import __version__ as ESPHOME_VERSION
from esphome.core import CORE, EsphomeError, Lambda
from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# Bump when the on-disk shape changes; a mismatched version falls back
# to read_config. The envelope also stamps the writing esphome version:
# after an upgrade the cache holds the previous release's validation, so
# it falls back once and the re-save self-heals.
_CACHE_VERSION = 1
_LAMBDA_KEY = "__esphome_lambda__"
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
Mode 0600 because config validation resolved !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
"""
try:
# The legacy YAML cache holds inline-resolved secrets and nothing
# reads it anymore; drop it even when the write below fails. A
# failed removal leaves resolved secrets on disk, so it warns.
try:
_legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
except OSError as err:
_LOGGER.warning(
"Could not remove the legacy validated-config cache: %s", err
)
rendered = json.dumps(
{"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
separators=(",", ":"),
default=_json_default,
)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except TypeError as err:
# Structural, not transient: this config can never cache (e.g. a
# non-basic dict key), so every upload/logs pays the slow path.
_LOGGER.warning("Cannot cache the validated config: %s", err)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# Likely persistent (permissions, full disk): every upload/logs
# pays the slow path until it clears, so surface it.
_LOGGER.warning("Skipping compiled config cache write: %s", err)
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
The cache is only written when a complete sidecar is on disk:
load_compiled_config can't use it otherwise, and it holds resolved
secrets.
"""
if _refresh_sidecar():
save_compiled_config(config)
def _refresh_sidecar() -> bool:
"""Ensure a complete sidecar is on disk; True when one is.
Writes one (without claiming a build) when missing or wizard-only.
Failures are non-fatal; the next upload/logs pays the slow path again.
"""
try:
path = storage_path()
try:
old = StorageJSON.load_strict(path)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# Present but unreadable: it may hold a real build's metadata,
# and a fresh rewrite would also stop the next compile from
# cleaning a possibly incoherent build tree.
_LOGGER.warning(
"Not caching: storage sidecar %s is unreadable (%s)", path, err
)
return False
if old is not None and old.can_apply_to_core():
# Compile-written; nothing to refresh.
return True
if CORE.build_path is not None and CORE.build_path.exists():
# An unvalidated build tree: its absent or mismatched sidecar
# is what makes the next compile wipe it, so don't vouch for
# a build this run never saw.
_LOGGER.warning(
"Not caching: build tree %s has no matching sidecar; "
"'esphome compile' will settle it",
CORE.build_path,
)
return False
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
if not new.can_apply_to_core():
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
return False
new.save(path)
return True
except (OSError, EsphomeError) as err:
# write_file wraps OSError into EsphomeError. Persistent
# (unwritable storage dir), so surface that every upload/logs
# pays the slow path.
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
# A structural bug; keep the traceback so it isn't mistaken
# for the I/O failure above.
_LOGGER.warning(
"Unexpected error refreshing the storage sidecar", exc_info=True
)
return False
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, a different cache
version, or the sidecar is incomplete. The loaded config carries no
source ranges; callers must not feed it into read_config/write_cpp.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
try:
envelope = json.loads(
cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
)
except (OSError, ValueError) as err:
_LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
return None
if (
not isinstance(envelope, dict)
or envelope.get("v") != _CACHE_VERSION
or envelope.get("esphome") != ESPHOME_VERSION
or not isinstance(config := envelope.get("config"), dict)
):
_LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None or not storage.can_apply_to_core():
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
return None
storage.apply_to_core()
return config
# Remove before 2027.8: by then every maintained install has saved the
# JSON cache at least once and dropped its legacy YAML file.
def _legacy_compiled_config_path(config_filename: str) -> Path:
"""Path of the pre-JSON YAML cache; only ever removed."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
@@ -32,52 +184,21 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
return False
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
def _json_default(value: Any) -> Any:
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
Mode 0600 because show_secrets=True resolves !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
IncludeFile/Extend/Remove have no JSON mirror and would stringify
wrong, but none survive validation (config.py's packages merge and
the substitution pass consume them) so no guard is spent on them.
"""
from esphome import yaml_util
try:
rendered = yaml_util.dump(config, show_secrets=True)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
if isinstance(value, Lambda):
return {_LAMBDA_KEY: value.value}
return str(value)
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, or the sidecar
is incomplete.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
from esphome import yaml_util
try:
# Fast path never validates or generates code - no source ranges
# needed (see load_yaml). Callers must not feed this config into
# read_config/write_cpp: the esp_range consumers in config.py and
# cpp_generator.py are isinstance-guarded and would degrade
# silently (wrong error/lambda locations) instead of raising.
config = yaml_util.load_yaml(
cache_path, clear_secrets=False, track_document_range=False
)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
return None
storage.apply_to_core()
return config
def _decode_object(obj: dict[str, Any]) -> Any:
"""Revive the Lambda sentinel; every other mapping passes through."""
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
return Lambda(value)
return obj
+10
View File
@@ -0,0 +1,10 @@
"""Component alias registry.
Generated by script/build_alias_registry.py - do not edit manually.
See the component-alias section of esphome/loader.py.
"""
# alias -> (canonical component, removal version or None)
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
"rp2040": ("rp2", "2027.7.0"),
}
+1
View File
@@ -231,6 +231,7 @@ def validate_adc_pin(value):
return pins.internal_gpio_input_pin_schema(29)
return cv.only_on([PLATFORM_ESP8266])("VCC")
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
if str(value).upper() == "TEMPERATURE":
return cv.only_on_rp2("TEMPERATURE")
+1 -1
View File
@@ -3,7 +3,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.common";
static const char *const TAG = "adc";
const LogString *sampling_mode_to_str(SamplingMode mode) {
switch (mode) {
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.esp32";
static const char *const TAG = "adc";
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
namespace esphome::adc {
static const char *const TAG = "adc.esp8266";
static const char *const TAG = "adc";
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
@@ -5,7 +5,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.libretiny";
static const char *const TAG = "adc";
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
+21 -2
View File
@@ -17,7 +17,26 @@
namespace esphome::adc {
static const char *const TAG = "adc.rp2";
static const char *const TAG = "adc";
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
// than four.
//
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
// is compiled, on both arduino-pico and pico-sdk builds.
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
#endif
#if defined(PICO_RP2350) && !PICO_RP2350A
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
#else
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
#endif
void ADCSensor::setup() {
static bool initialized = false;
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
if (this->is_temperature_) {
adc_set_temp_sensor_enabled(true);
delay(1);
adc_select_input(4);
adc_select_input(TEMPERATURE_ADC_INPUT);
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
raw = adc_read();
+1 -1
View File
@@ -7,7 +7,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.zephyr";
static const char *const TAG = "adc";
void ADCSensor::setup() {
if (!adc_is_ready_dt(this->channel_)) {
+8
View File
@@ -67,6 +67,13 @@ def validate_config(config):
# Alter value here so `config` command prints the recommended change
config[CONF_ATTENUATION] = _attenuation("12db")
# Remove before 2027.2.0
if config[CONF_PIN] == "TEMPERATURE":
_LOGGER.warning(
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
"sensor platform instead. Will be removed in 2027.2.0"
)
return config
@@ -133,6 +140,7 @@ async def to_code(config):
if config[CONF_PIN] == "VCC":
cg.add_define("USE_ADC_SENSOR_VCC")
elif config[CONF_PIN] == "TEMPERATURE":
# Remove before 2027.2.0
cg.add(var.set_is_temperature())
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
+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
+5
View File
@@ -13,8 +13,13 @@
import esphome.components.image as espImage
import esphome.config_validation as cv
from . import image as animation_image
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
# The deprecated top-level `animation:` shim gets the same batched
# downloads as the `image:` platform form.
PREFETCH_FILES = animation_image.PREFETCH_FILES
AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
+5
View File
@@ -1,6 +1,7 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.file import image as file_image
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
import esphome.config_validation as cv
@@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT
from esphome.types import ConfigType
CODEOWNERS = ["@syndlex"]
# The animation platform shares the file platform's remote file handling,
# including its batch-download hook.
PREFETCH_FILES = file_image.PREFETCH_FILES
AUTO_LOAD = ["file"]
DEPENDENCIES = ["display"]
+1 -1
View File
@@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.11")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+94 -22
View File
@@ -19,6 +19,7 @@ service APIConnection {
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false;
}
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -243,6 +244,12 @@ message SerialProxyInfo {
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
//
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
// have moved to that message as of API 1.15, but are still sent here so that
// older clients keep working. Do NOT mark them (deprecated) until the removal
// release: in this repo (deprecated) makes the generator drop the field
// entirely, so the device would stop sending it.
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
@@ -280,6 +287,8 @@ message DeviceInfoResponse {
// Deprecated in API version 1.9
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
@@ -288,11 +297,14 @@ message DeviceInfoResponse {
// Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key
@@ -305,10 +317,13 @@ message DeviceInfoResponse {
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Serial proxy instance metadata
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
@@ -317,6 +332,63 @@ message DeviceInfoResponse {
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
// ==================== DEVICE CAPABILITIES ====================
// Asks the device which optional features it supports.
//
// This message exists so that DeviceInfoResponse does not have to keep growing
// a flat list of feature flags. DeviceInfoResponse is served before
// authentication, so it is limited to identity information. Capabilities are
// only served on an authenticated connection (encrypted as well, when
// encryption is configured).
//
// Clients that see api_version >= 1.15 should read these values from
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
// Older clients keep reading DeviceInfoResponse, which still carries the same
// values, so this is not a breaking change.
message DeviceCapabilitiesRequest {
option (id) = 149;
option (source) = SOURCE_CLIENT;
// Empty
}
// Each feature gets its own sub-message so that it can gain fields over time
// without crowding the top-level field numbering.
//
// Note: a sub-message whose fields are all at their default value is not sent
// at all, so the presence of a sub-message is not a reliable test for "this
// feature is compiled in". Clients should test a value inside it, for example
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
message BluetoothProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
string mac_address = 2 [(max_data_length) = 17, (force) = true];
}
message VoiceAssistantCapabilities {
// Bitmask of the features this voice assistant supports
uint32 feature_flags = 1;
}
message ZWaveProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
uint32 home_id = 2;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
}
message ListEntitiesRequest {
option (id) = 11;
option (source) = SOURCE_CLIENT;
@@ -1688,7 +1760,7 @@ enum BluetoothDeviceRequestType {
message BluetoothDeviceRequest {
option (id) = 68;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
BluetoothDeviceRequestType request_type = 2;
@@ -1699,7 +1771,7 @@ message BluetoothDeviceRequest {
message BluetoothDeviceConnectionResponse {
option (id) = 69;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool connected = 2;
@@ -1710,7 +1782,7 @@ message BluetoothDeviceConnectionResponse {
message BluetoothGATTGetServicesRequest {
option (id) = 70;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
}
@@ -1754,7 +1826,7 @@ message BluetoothGATTService {
message BluetoothGATTGetServicesResponse {
option (id) = 71;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
repeated BluetoothGATTService services = 2;
@@ -1763,7 +1835,7 @@ message BluetoothGATTGetServicesResponse {
message BluetoothGATTGetServicesDoneResponse {
option (id) = 72;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
}
@@ -1771,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse {
message BluetoothGATTReadRequest {
option (id) = 73;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1780,7 +1852,7 @@ message BluetoothGATTReadRequest {
message BluetoothGATTReadResponse {
option (id) = 74;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1792,7 +1864,7 @@ message BluetoothGATTReadResponse {
message BluetoothGATTWriteRequest {
option (id) = 75;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1804,7 +1876,7 @@ message BluetoothGATTWriteRequest {
message BluetoothGATTReadDescriptorRequest {
option (id) = 76;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1813,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest {
message BluetoothGATTWriteDescriptorRequest {
option (id) = 77;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1824,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest {
message BluetoothGATTNotifyRequest {
option (id) = 78;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1834,7 +1906,7 @@ message BluetoothGATTNotifyRequest {
message BluetoothGATTNotifyDataResponse {
option (id) = 79;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1845,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse {
message SubscribeBluetoothConnectionsFreeRequest {
option (id) = 80;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
}
message BluetoothConnectionsFreeResponse {
option (id) = 81;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint32 free = 1;
uint32 limit = 2;
@@ -1864,7 +1936,7 @@ message BluetoothConnectionsFreeResponse {
message BluetoothGATTErrorResponse {
option (id) = 82;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1874,7 +1946,7 @@ message BluetoothGATTErrorResponse {
message BluetoothGATTWriteResponse {
option (id) = 83;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1883,7 +1955,7 @@ message BluetoothGATTWriteResponse {
message BluetoothGATTNotifyResponse {
option (id) = 84;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1892,7 +1964,7 @@ message BluetoothGATTNotifyResponse {
message BluetoothDevicePairingResponse {
option (id) = 85;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool paired = 2;
@@ -1902,7 +1974,7 @@ message BluetoothDevicePairingResponse {
message BluetoothDeviceUnpairingResponse {
option (id) = 86;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool success = 2;
@@ -1918,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
message BluetoothDeviceClearCacheResponse {
option (id) = 88;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool success = 2;
@@ -2735,7 +2807,7 @@ message SerialProxyRequestResponse {
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 min_interval = 2; // units of 1.25ms
@@ -2747,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest {
message BluetoothSetConnectionParamsResponse {
option (id) = 146;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
int32 error = 2;
+118 -32
View File
@@ -23,6 +23,7 @@
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
@@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
static const char *const TAG = "api.connection";
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
void log_dropped_message(const char *tag, int line, const LogString *what) {
esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
LOG_STR_ARG(what));
}
#endif
#ifdef USE_CAMERA
static const int CAMERA_STOP_STREAM = 5000;
#endif
@@ -152,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
#else
#error "No frame helper defined"
#endif
#ifdef USE_CAMERA
if (camera::Camera::instance() != nullptr) {
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
#endif
}
void APIConnection::start() {
@@ -440,7 +443,7 @@ void APIConnection::on_disconnect_response() {
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
msg.key = entity->get_entity_key();
msg.key = entity->get_object_id_hash();
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
@@ -451,7 +454,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
// Set common fields that are shared by all entity types
msg.key = entity->get_entity_key();
msg.key = entity->get_object_id_hash();
if (entity->has_own_name()) {
msg.name = entity->get_name();
@@ -1132,6 +1135,7 @@ void APIConnection::try_send_camera_image_() {
if (!this->image_reader_)
return;
const auto *cam = camera::Camera::instance();
// Send as many chunks as possible without blocking
while (this->image_reader_->available()) {
if (!this->helper_->can_write_without_blocking())
@@ -1141,11 +1145,11 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_entity_key();
msg.key = cam->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
msg.device_id = camera::Camera::instance()->get_device_id();
msg.device_id = cam->get_device_id();
#endif
if (!this->send_message(msg)) {
@@ -1161,15 +1165,19 @@ void APIConnection::try_send_camera_image_() {
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
if (!this->flags_.state_subscription)
return;
if (!this->image_reader_)
if (this->image_reader_ && this->image_reader_->available())
return;
if (this->image_reader_->available())
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
return;
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
if (!this->image_reader_) {
// Created on the first image this connection will send, so connections
// that never receive one never pay for a reader. Only a registered
// camera's listener can reach this, so instance() is non-null here.
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
}
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *camera = static_cast<camera::Camera *>(entity);
@@ -1235,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
}
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
}
@@ -1268,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
}
}
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
}
#endif
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
}
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
}
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -1532,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
if (!this->send_message(msg)) {
// V: fires per decoded frame with no subscription gate, so a warning
// would flood the congested link it reports on.
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
}
}
#endif
#ifdef USE_SERIAL_PROXY
@@ -1574,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance;
resp.line_states = proxies[msg.instance]->get_modem_pins();
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
}
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
@@ -1606,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break;
}
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
break;
}
default:
@@ -1615,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
}
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
if (!this->send_message(msg)) {
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
}
}
#endif
#ifdef USE_INFRARED
@@ -1735,7 +1760,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
resp.api_version_minor = 15;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1746,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Hello response");
}
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req);
@@ -1771,9 +1798,8 @@ bool APIConnection::send_device_info_response_() {
#ifdef USE_AREAS
resp.suggested_area = StringRef(App.get_area());
#endif
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char mac_address[18];
uint8_t mac[6];
char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
uint8_t mac[MAC_ADDRESS_SIZE];
get_mac_address_raw(mac);
format_mac_addr_upper(mac, mac_address);
resp.mac_address = StringRef(mac_address);
@@ -1849,8 +1875,7 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
char bluetooth_mac[18];
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
#endif
@@ -1904,6 +1929,35 @@ bool APIConnection::send_device_info_response_() {
return this->send_message(resp);
}
bool APIConnection::send_device_capabilities_response_() {
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
// below in sync with send_device_info_response_() until those copies are removed.
DeviceCapabilitiesResponse resp;
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
#endif
#ifdef USE_VOICE_ASSISTANT
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
#endif
#ifdef USE_ZWAVE_PROXY
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
#endif
#ifdef USE_SERIAL_PROXY
size_t serial_proxy_index = 0;
for (auto const &proxy : App.get_serial_proxies()) {
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
break;
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
}
#endif
return this->send_message(resp);
}
void APIConnection::on_hello_request(const HelloRequest &msg) {
if (!this->send_hello_response_(msg)) {
this->on_fatal_error();
@@ -1925,6 +1979,11 @@ void APIConnection::on_device_info_request() {
this->on_fatal_error();
}
}
void APIConnection::on_device_capabilities_request() {
if (!this->send_device_capabilities_response_()) {
this->on_fatal_error();
}
}
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
@@ -2003,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.call_id = call_id;
resp.success = success;
resp.error_message = error_message;
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Action response");
}
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
@@ -2014,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.error_message = error_message;
resp.response_data = response_data;
resp.response_data_len = response_data_len;
this->send_message(resp);
if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Action response");
}
}
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif
#ifdef USE_API_HOMEASSISTANT_SERVICES
bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return false;
if (!this->send_message(call)) {
API_LOG_MSG_DROPPED(TAG, "Action request");
}
return true;
}
#endif // USE_API_HOMEASSISTANT_SERVICES
#ifdef USE_HOMEASSISTANT_TIME
void APIConnection::send_time_request() {
GetTimeRequest req;
if (!this->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Time request");
}
}
#endif // USE_HOMEASSISTANT_TIME
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
@@ -2094,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
if (this->helper_->can_write_without_blocking())
return true;
if (log_out_of_space) {
ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
// VV: refusals are either reported by the sending call site (naming what
// was lost) or retried without loss (the deferred batch), so this generic
// line only duplicates them.
ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
}
return false;
}
+22 -13
View File
@@ -25,6 +25,7 @@
#include "esphome/components/esp8266/crash_handler.h"
#endif
#include "esphome/core/entity_base.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include <functional>
@@ -40,6 +41,16 @@ namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// One shared flash string for every refused-frame warning: send_message()
// fails as soon as the TCP buffer is full, and each caller only pays for its
// short name. The guard drops the helper and its arguments below WARN.
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
void log_dropped_message(const char *tag, int line, const LogString *what);
#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what))
#else
#define API_LOG_MSG_DROPPED(tag, what)
#endif
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase {
// Returns whether this client has subscribed to Home Assistant actions; the message
// is only handed to the send path when subscribed. A true return does not guarantee
// delivery - it lets the caller warn when no connected client has the subscription.
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return false;
this->send_message(call);
return true;
}
bool send_homeassistant_action(const HomeassistantActionRequest &call);
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
void on_unsubscribe_bluetooth_le_advertisements_request();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
@@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
void on_subscribe_bluetooth_connections_free_request();
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
#endif
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
#endif
#ifdef USE_HOMEASSISTANT_TIME
void send_time_request() {
GetTimeRequest req;
this->send_message(req);
}
void send_time_request();
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -266,6 +271,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_device_capabilities_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
this->flags_.state_subscription = true;
@@ -334,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *);
template<typename T> bool send_message(const T &msg) {
/// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
/// have no silent failures: every caller must handle (or log) a refusal.
template<typename T> [[nodiscard]] bool send_message(const T &msg) {
if constexpr (T::ESTIMATED_SIZE == 0) {
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
} else {
@@ -385,10 +393,11 @@ class APIConnection final : public APIServerConnectionBase {
bool send_disconnect_response_();
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool send_subscribe_bluetooth_connections_free_response_();
#endif
#ifdef USE_VOICE_ASSISTANT
+2 -2
View File
@@ -149,7 +149,7 @@ class APIFrameHelper {
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
//
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
@@ -312,7 +312,7 @@ class APIFrameHelper {
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
#ifdef USE_ESP8266
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
#else
@@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err;
memset(&nid_, 0, sizeof(nid_));
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
nid_.pattern_id = NOISE_PATTERN_NN;
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
nid_.dh_id = NOISE_DH_CURVE25519;
nid_.prefix_id = NOISE_PREFIX_STANDARD;
nid_.hybrid_id = NOISE_DH_NONE;
nid_.hash_id = NOISE_HASH_SHA256;
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
@@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
// NoiseProtocolId (size depends on implementation)
NoiseProtocolId nid_;
// Group small types together
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
+81 -1
View File
@@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
return size;
}
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
return pos;
}
uint32_t BluetoothProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += 2 + this->mac_address.size();
return size;
}
#endif
#ifdef USE_VOICE_ASSISTANT
uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
return pos;
}
uint32_t VoiceAssistantCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
return size;
}
#endif
#ifdef USE_ZWAVE_PROXY
uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
return pos;
}
uint32_t ZWaveProxyCapabilities::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->feature_flags);
size += ProtoSize::calc_uint32(1, this->home_id);
return size;
}
#endif
uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
#ifdef USE_BLUETOOTH_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
#endif
#ifdef USE_VOICE_ASSISTANT
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
#endif
#ifdef USE_ZWAVE_PROXY
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
}
#endif
return pos;
}
uint32_t DeviceCapabilitiesResponse::calculate_size() const {
uint32_t size = 0;
#ifdef USE_BLUETOOTH_PROXY
size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
#endif
#ifdef USE_VOICE_ASSISTANT
size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
#endif
#ifdef USE_ZWAVE_PROXY
size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(1, it.calculate_size());
}
#endif
return size;
}
#ifdef USE_BINARY_SENSOR
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
@@ -2406,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
}
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
@@ -2782,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
size += ProtoSize::calc_int32(1, this->error);
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state));
@@ -4145,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
return size;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
+76 -2
View File
@@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
@@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
};
#endif
#ifdef USE_BLUETOOTH_PROXY
enum BluetoothScannerState : uint32_t {
BLUETOOTH_SCANNER_STATE_IDLE = 0,
BLUETOOTH_SCANNER_STATE_STARTING = 1,
@@ -600,6 +602,74 @@ class DeviceInfoResponse final : public ProtoMessage {
protected:
};
#ifdef USE_BLUETOOTH_PROXY
class BluetoothProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
StringRef mac_address{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_VOICE_ASSISTANT
class VoiceAssistantCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_ZWAVE_PROXY
class ZWaveProxyCapabilities final : public ProtoMessage {
public:
uint32_t feature_flags{0};
uint32_t home_id{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
class DeviceCapabilitiesResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 102;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
#endif
#ifdef USE_BLUETOOTH_PROXY
BluetoothProxyCapabilities bluetooth_proxy{};
#endif
#ifdef USE_VOICE_ASSISTANT
VoiceAssistantCapabilities voice_assistant{};
#endif
#ifdef USE_ZWAVE_PROXY
ZWaveProxyCapabilities zwave_proxy{};
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
class ListEntitiesDoneResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 19;
@@ -1931,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothDeviceRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 68;
@@ -2316,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY
class BluetoothScannerStateResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 126;
@@ -3290,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 145;
+1 -1
View File
@@ -3,7 +3,7 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
#ifndef USE_API_VARINT64
#define USE_API_VARINT64
#endif
+57 -2
View File
@@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string<enums::MediaPlayerFormatPurpose>(enu
}
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
template<>
const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) {
switch (value) {
@@ -606,6 +606,8 @@ const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::Bluet
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_BLUETOOTH_PROXY
template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) {
switch (value) {
case enums::BLUETOOTH_SCANNER_STATE_IDLE:
@@ -988,6 +990,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
return out.c_str();
}
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
return out.c_str();
}
#endif
#ifdef USE_VOICE_ASSISTANT
const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
#ifdef USE_ZWAVE_PROXY
const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
this->bluetooth_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_VOICE_ASSISTANT
out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
this->voice_assistant.dump_to(out);
out.append("\n");
#endif
#ifdef USE_ZWAVE_PROXY
out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
this->zwave_proxy.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
it.dump_to(out);
out.append("\n");
}
#endif
return out.c_str();
}
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
return out.c_str();
@@ -1953,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
}
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address);
@@ -2124,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error"), this->error);
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state));
@@ -2715,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address);
+16 -9
View File
@@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothDeviceRequest::MESSAGE_TYPE: {
BluetoothDeviceRequest msg;
msg.decode(msg_data, msg_size);
@@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: {
BluetoothGATTGetServicesRequest msg;
msg.decode(msg_data, msg_size);
@@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTReadRequest::MESSAGE_TYPE: {
BluetoothGATTReadRequest msg;
msg.decode(msg_data, msg_size);
@@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTWriteRequest::MESSAGE_TYPE: {
BluetoothGATTWriteRequest msg;
msg.decode(msg_data, msg_size);
@@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: {
BluetoothGATTReadDescriptorRequest msg;
msg.decode(msg_data, msg_size);
@@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: {
BluetoothGATTWriteDescriptorRequest msg;
msg.decode(msg_data, msg_size);
@@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTNotifyRequest::MESSAGE_TYPE: {
BluetoothGATTNotifyRequest msg;
msg.decode(msg_data, msg_size);
@@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"));
@@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: {
BluetoothSetConnectionParamsRequest msg;
msg.decode(msg_data, msg_size);
@@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
case 149 /* DeviceCapabilitiesRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
#endif
this->on_device_capabilities_request();
break;
}
default:
break;
}
+11 -9
View File
@@ -27,6 +27,8 @@ class APIServerConnectionBase {
void on_ping_response(){};
void on_device_info_request(){};
void on_device_capabilities_request(){};
void on_list_entities_request(){};
void on_subscribe_states_request(){};
@@ -113,32 +115,32 @@ class APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_subscribe_bluetooth_connections_free_request(){};
#endif
@@ -233,7 +235,7 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
};
+11 -4
View File
@@ -123,7 +123,9 @@ void APIServer::setup() {
// Best-effort: if the send buffer is full the reason is dropped, but the
// client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close.
c->send_message(req);
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
}
});
}
@@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) {
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
// We could add code to manage a second subscription type, but, since this message type is
// very infrequent and small, we simply send it to all clients
for (auto &c : this->active_clients())
c->send_message(msg);
for (auto &c : this->active_clients()) {
if (!c->send_message(msg)) {
API_LOG_MSG_DROPPED(TAG, "Home ID notification");
}
}
}
#endif
@@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
for (auto &c : this->active_clients()) {
DisconnectRequest req;
c->send_message(req);
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
}
});
}
@@ -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))
+3 -1
View File
@@ -371,7 +371,7 @@ async def to_code(config):
data.wav_support = True
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
add_idf_component(name="esphome/micro-decoder", ref="0.4.0")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
@@ -380,6 +380,8 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
# Vorbis is unsupported in ESPHome, so always disable it
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False)
if not data.wav_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
+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))
@@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
uint8_t white = this->is_wrgb_ ? 0 : 3;
return {this->buf_ + (index * multiplier) + r + this->is_wrgb_,
this->buf_ + (index * multiplier) + g + this->is_wrgb_,
this->buf_ + (index * multiplier) + b + this->is_wrgb_,
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
@@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() {
"Beken SPI LED Strip:\n"
" Pin: %u",
this->pin_);
const char *rgb_order;
switch (this->rgb_order_) {
case ORDER_RGB:
rgb_order = "RGB";
break;
case ORDER_RBG:
rgb_order = "RBG";
break;
case ORDER_GRB:
rgb_order = "GRB";
break;
case ORDER_GBR:
rgb_order = "GBR";
break;
case ORDER_BGR:
rgb_order = "BGR";
break;
case ORDER_BRG:
rgb_order = "BRG";
break;
default:
rgb_order = "UNKNOWN";
break;
}
char channel_colors[5];
ESP_LOGCONFIG(TAG,
" RGB Order: %s\n"
" Channel colors: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -3,6 +3,7 @@
#ifdef USE_BK72XX
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
@@ -10,15 +11,6 @@
namespace esphome::beken_spi_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
class BekenSPILEDStripLightOutput final : public light::AddressableLight {
public:
void setup() override;
@@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->is_rgbw_ || this->is_wrgb_) {
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
@@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++)
this->effect_data_[i] = 0;
@@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
uint8_t *buf_{nullptr};
uint8_t *effect_data_{nullptr};
@@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
uint8_t pin_;
uint16_t num_leds_;
bool is_rgbw_;
bool is_wrgb_;
uint32_t spi_frequency_{6666666};
uint8_t bit0_{0xE0};
uint8_t bit1_{0xFC};
RGBOrder rgb_order_;
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
+17 -24
View File
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import libretiny, light
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_PIN,
CONF_RGB_ORDER,
)
from esphome.types import ConfigType
CODEOWNERS = ["@Mat931"]
DEPENDENCIES = ["libretiny"]
@@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_(
"BekenSPILEDStripLightOutput", light.AddressableLight
)
RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder")
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
@dataclass
class LEDStripTimings:
@@ -57,8 +48,6 @@ CHIPSETS = {
}
CONF_IS_WRGB = "is_wrgb"
SUPPORTED_PINS = {
libretiny.const.FAMILY_BK7231N: [16],
libretiny.const.FAMILY_BK7231T: [16],
@@ -79,10 +68,9 @@ def _validate_pin(value):
return value
def _validate_num_leds(value):
max_num_leds = 165 # 170
if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]:
max_num_leds = 123 # 127
def _validate_num_leds(value: ConfigType) -> ConfigType:
# A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer.
max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170
if value[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
@@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All(
pins.internal_gpio_output_pin_number, _validate_pin
),
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Optional(CONF_IS_WRGB): cv.boolean,
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
}
),
light.migrate_channel_colors(
removed_in="2027.3.0", component="beken_spi_led_strip"
),
_validate_num_leds,
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_component(var, config)
@@ -130,6 +123,6 @@ async def to_code(config):
)
)
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
+37 -6
View File
@@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
to_code; unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
@@ -21,9 +21,16 @@ import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import EsphomeError
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
@@ -50,7 +57,31 @@ CONFIG_SCHEMA = cv.Schema(
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
def _unsupported_family_message(family: str) -> str | None:
if family in (FAMILY_BK7231T, FAMILY_BK7251):
return (
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
return None
def _final_validate(config: ConfigType) -> None:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
raise EsphomeError(msg)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
+119
View File
@@ -0,0 +1,119 @@
// Every SDK call the scan reconciler makes. The BDK's own start hardcodes
// passive (the active bit is commented out in both stacks), so
// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field
// the SDK's app_ble_start_scaning() except that prop takes the mode, armed
// through the SDK's own operation bookkeeping. The component pins
// beken-bdk 3.0.78; the static asserts catch a layout change on a bump.
#include "bdk_scan.h"
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h")
extern "C" {
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
// app_ble_actv_state_get, app_ble_env_state_get,
// app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX,
// bk_ble_* (via ble_api_5_x.h)
#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send
#if __has_include("gapm_msg.h")
#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_*
#else
#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name
#endif
}
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
// Pin the SDK surface this file depends on: a beken-bdk bump that moves these
// must fail the build, not corrupt the kernel message.
static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) &&
sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4,
"beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() "
"against the SDK's app_ble_start_scaning()");
static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX,
"beken-bdk activity sentinel changed; revalidate the scan reconciler");
static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 &&
GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5),
"beken-bdk GAPM report info changed; revalidate the tracker's demux constants");
bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; }
BdkActivityState bdk_scan_state(uint8_t activity_idx) {
if (activity_idx == INVALID_ACTIVITY_IDX)
return BdkActivityState::IDLE;
switch (app_ble_actv_state_get(activity_idx)) {
case ACTV_IDLE:
return BdkActivityState::IDLE;
case ACTV_SCAN_CREATED:
return BdkActivityState::CREATED;
case ACTV_SCAN_STARTED:
return BdkActivityState::STARTED;
default:
return BdkActivityState::OTHER;
}
}
uint8_t bdk_scan_acquire_activity() {
uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (idx == INVALID_ACTIVITY_IDX)
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
return idx;
}
BdkOpResult bdk_scan_create(uint8_t activity_idx) {
ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr);
if (ret == ERR_SUCCESS)
return BdkOpResult::OK;
if (ret == ERR_BLE_STATUS)
return BdkOpResult::BUSY;
ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast<int>(ret));
return BdkOpResult::FAILED;
}
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) {
app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr);
struct gapm_activity_start_cmd *cmd =
KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd);
if (cmd == nullptr) {
app_ble_reset(); // the SDK's own failure path for an unsent operation
ESP_LOGE(TAG, "Scan start failed: kernel message allocation");
return BdkOpResult::FAILED;
}
cmd->operation = GAPM_START_ACTIVITY;
cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx;
cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER;
cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0);
cmd->u_param.scan_param.scan_param_1m.scan_intv = interval;
cmd->u_param.scan_param.scan_param_1m.scan_wd = window;
cmd->u_param.scan_param.scan_param_coded.scan_intv = 0;
cmd->u_param.scan_param.scan_param_coded.scan_wd = 0;
cmd->u_param.scan_param.dup_filt_pol = 0;
cmd->u_param.scan_param.rsvd = 0;
cmd->u_param.scan_param.duration = 0; // scan until stopped
cmd->u_param.scan_param.period = 10; // matches the SDK's passive start
kernel_msg_send(cmd);
return BdkOpResult::OK;
}
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr);
*err_out = static_cast<int>(ret);
if (ret == ERR_SUCCESS)
return BdkOpResult::OK;
// DEBUG on purpose: the reconciler WARNs once per streak and the stuck
// ERROR carries this code — a per-retry ERROR would be unbounded.
ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast<int>(ret));
return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED;
}
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h && app_ble.h
#endif // USE_BK72XX_BLE
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BK72XX_BLE
#include <cstdint>
namespace esphome::bk72xx_ble {
/// Activity index value marking "no scan activity", the BDK's own convention
/// (asserted against its symbol in bdk_scan.cpp).
inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF;
/// Scan-relevant controller activity states, read live from the SDK.
enum class BdkActivityState : uint8_t {
IDLE, ///< No activity (or one whose create failed).
CREATED, ///< Created but not started.
STARTED, ///< Scanning.
OTHER, ///< A non-scan or transitional state; settles on a later read.
};
/// Outcome of a BDK scan operation request.
enum class BdkOpResult : uint8_t {
OK, ///< Accepted; completion is asynchronous.
BUSY, ///< Another controller operation is in flight; retry later.
FAILED, ///< Rejected.
};
/// True when no controller operation is in flight (APP_BLE_READY).
bool bdk_scan_ready();
/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE.
BdkActivityState bdk_scan_state(uint8_t activity_idx);
/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free.
uint8_t bdk_scan_acquire_activity();
/// Create the scan activity (asynchronous); started once CREATED is observed.
BdkOpResult bdk_scan_create(uint8_t activity_idx);
/// Start a created activity: the packed GAPM start, taking the scan mode the
/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the
/// kernel message could not be allocated (the armed SDK operation is rolled
/// back).
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active);
/// Release the activity: delete when never started (a stop would be
/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED
/// on any other error; err_out receives the SDK code (0 on success).
/// Teardown is asynchronous — observe IDLE to confirm.
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out);
} // namespace esphome::bk72xx_ble
#endif // USE_BK72XX_BLE
+270 -45
View File
@@ -5,7 +5,8 @@
// talks to the Beken BDK BLE stack:
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
// - the controller BLE address,
// - the raw controller scan primitives (bk_ble_scan_start/stop),
// - the scan reconciler (request, pacing, bring-up budget) over the
// bdk_scan surface,
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains,
// dispatches on the main task and returns reports to the pool — the same
@@ -20,32 +21,39 @@
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release)
#ifdef USE_BK72XX_BLE
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" // get_mac_address_raw()
#include "esphome/core/log.h"
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
// the probe: it ships for every SoC (driver/include) and merely switches on
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
// any BLE-5.x chip — present or future — without a hard-coded list, and a
// non-5.x build fails here with a clear message instead of a cryptic
// "app_ble.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
// one and bury this message.
#define BK72XX_BLE_NO_SDK
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
@@ -57,9 +65,8 @@
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
// ---------------------------------------------------------------------------
extern "C" {
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb,
// app_ble_get_idle_actv_idx_handle, struct scan_param,
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t,
// BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp)
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
#include "common_bt_defines.h" // struct bd_addr
// The controller's public BLE address, populated by the BDK during ble_entry().
@@ -76,6 +83,12 @@ namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops
static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release
static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED
static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence
static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED)
// The BDK notice callback is a plain C function pointer with no user argument,
// so it reaches the (single) component instance through a file-static pointer.
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -95,21 +108,22 @@ static void ble_notice_callback(ble_notice_t notice, void *param) {
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for
// a signed dBm value packed in a uint8_t).
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data,
info->data_len);
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type,
static_cast<uint8_t>(info->evt_type), info->data, info->data_len);
}
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type,
const uint8_t *data, uint16_t data_len) {
BLEScanReport *report = this->report_pool_.allocate();
if (report == nullptr) {
// Pool exhausted — the queue is full; count and drop.
this->report_queue_.increment_dropped_count();
return;
}
memcpy(report->mac, mac, 6);
memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
report->rssi = rssi;
report->addr_type = addr_type;
report->evt_type = evt_type;
report->data_len =
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
memcpy(report->data, data, report->data_len);
@@ -123,6 +137,9 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add
void BK72xxBLE::setup() {
s_ble = this;
// The report pool grows lazily on purpose: the BDK notice callback runs in
// task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic
// stays far below the pool cap, so not warming contains RAM.
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
// the stack is up (it is re-read once ble_entry() has run).
this->resolve_mac_();
@@ -173,6 +190,30 @@ void BK72xxBLE::enable() {
}
void BK72xxBLE::loop() {
// Keep reconciling toward the requested scan state (e.g. complete a stop
// that arrived while a controller operation was in flight), and re-check a
// settled scan at low frequency: a controller-side drop re-enters the
// bring-up, and the budget's FAILED feeds the tracker's recovery.
// Keep driving until settled: any PENDING, plus a terminal stop whose slot
// must still be freed. A FAILED scan request is the one combination not
// re-driven here — that belongs to the tracker's backoff.
const uint32_t pump_now = App.get_loop_component_start_time();
if (this->last_result_ == ScanOpResult::PENDING ||
(!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) {
const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED)
? RECONCILE_REJECTED_RETRY_MS
: RECONCILE_RETRY_MS;
if (pump_now - this->last_advance_ms_ >= gate)
this->advance_();
} else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED &&
pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) {
// Re-check a settled scan; scan_start() refills the bring-up budget.
// WARN: the only report of a drop that recovers inside its budget.
if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) !=
ScanOpResult::SETTLED)
ESP_LOGW(TAG, "Controller dropped the scan; restarting");
}
// Drain the lock-free ring filled by the BLE task; all per-report work runs
// here on the main task, then the report returns to the pool.
BLEScanReport *report = this->report_queue_.pop();
@@ -193,7 +234,7 @@ void BK72xxBLE::loop() {
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i];
}
@@ -226,7 +267,7 @@ void BK72xxBLE::resolve_mac_() {
}
}
if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
return;
}
#endif
@@ -238,54 +279,238 @@ void BK72xxBLE::resolve_mac_() {
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
// would carry differently.
uint8_t wifi_mac[6];
uint8_t wifi_mac[MAC_ADDRESS_SIZE];
get_mac_address_raw(wifi_mac); // MSB-first
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
// Store LSB-first to match recv_adv_t adv_addr ordering.
for (int i = 0; i < 6; i++)
this->ble_mac_[i] = ble[5 - i];
}
// ---------------------------------------------------------------------------
// Controller scan primitives
// Scan reconciler
// ---------------------------------------------------------------------------
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
// Episode boundary: fresh teardown deadline and error bookkeeping.
void BK72xxBLE::reset_teardown_episode_() {
this->teardown_since_ms_ = 0;
this->restarting_ = false;
this->last_release_err_ = 0;
}
ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) {
if (!this->is_active())
this->enable();
if (this->scan_actv_idx_ != 0xFF) {
// Already scanning — stop first so this call cleanly restarts with the new
// parameters (the BDK cannot start a second scan on a busy activity).
this->scan_stop();
const ScanParams params{active, interval, window};
// A new episode refills the budget and gets a fresh teardown deadline; a
// re-call observing an in-flight bring-up (last result PENDING) must not.
if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) {
this->pending_since_ms_ = App.get_loop_component_start_time();
this->reset_teardown_episode_();
}
this->scan_wanted_ = true;
this->requested_ = params;
return this->advance_();
}
struct scan_param sp;
memset(&sp, 0, sizeof(sp));
sp.channel_map = 7; // advertising channels 37/38/39
sp.interval = interval;
sp.window = window;
void BK72xxBLE::scan_stop() {
if (this->scan_wanted_) {
// A stamp inherited from a stuck restart would fail the stop on its
// first advance.
this->reset_teardown_episode_();
}
this->scan_wanted_ = false;
this->advance_();
}
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (this->scan_actv_idx_ == 0xFF) {
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) {
// millis() on both sides: the loop clock is frozen while this blocks.
const uint32_t start = millis();
while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) {
if (millis() - start >= timeout_ms)
return false;
delay(RECONCILE_RETRY_MS);
this->advance_();
}
return this->last_result_ == ScanOpResult::SETTLED;
}
// Teardown is asynchronous: the handle is kept until an IDLE observation
// confirms the radio is idle. A rejection WARNs once per failure streak and
// widens the pump gate; the epilogue owns the stuck-teardown deadline.
void BK72xxBLE::release_activity_(BdkActivityState state) {
const BdkOpResult result =
bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_);
if (result == BdkOpResult::OK) {
this->release_warned_ = false;
return;
}
if (!this->release_warned_) {
// A hard error carries its code immediately; the 30 s stuck ERROR follows
// if it persists.
if (result == BdkOpResult::FAILED) {
ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_);
} else {
ESP_LOGW(TAG, "Scan activity release rejected; retrying");
}
this->release_warned_ = true;
}
}
// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged
// each interval) and report stuck.
bool BK72xxBLE::teardown_stuck_(uint32_t now) {
if (this->teardown_since_ms_ == 0) {
this->teardown_since_ms_ = now;
this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline
return false;
}
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr);
if (ret != ERR_SUCCESS) {
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
this->scan_actv_idx_ = 0xFF;
if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS)
return false;
if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) {
if (this->last_release_err_ != 0) {
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_);
} else {
// No rejected release this episode: stuck waiting on the controller.
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)");
}
this->teardown_stuck_log_ms_ = now;
}
return true;
}
void BK72xxBLE::scan_stop() {
if (this->scan_actv_idx_ != 0xFF) {
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
this->scan_actv_idx_ = 0xFF;
// One SDK operation per call toward the latched request; controller state is
// read live each time (it changes on the BLE task, so nothing is mirrored).
// The epilogue owns all deadlines and episode bookkeeping.
ScanOpResult BK72xxBLE::advance_() {
if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
// Nothing to do; also keeps SDK reads off the pre-enable() path.
this->last_result_ = ScanOpResult::SETTLED;
return ScanOpResult::SETTLED;
}
const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_);
const bool ready = bdk_scan_ready();
ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready);
const uint32_t now = App.get_loop_component_start_time();
this->last_advance_ms_ = now;
if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) {
// Any teardown episode is over (IDLE observed with the controller
// settled, or e.g. a mode flip that settled back without ever reaching
// IDLE). An IDLE read while an operation is in flight proves nothing —
// a stop deferred there must keep its episode running.
this->reset_teardown_episode_();
this->release_warned_ = false;
}
if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) {
// The mode-change release is observed complete; the rest is a normal
// bring-up on a fresh budget.
this->restarting_ = false;
this->pending_since_ms_ = now;
}
// Not chained to the clear above: a bring-up waiting at IDLE (create still
// in flight) must keep spending its budget.
if (result == ScanOpResult::PENDING) {
if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) {
// A downed radio spends the bring-up budget; exhausting it hands
// recovery to the tracker's backoff.
if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) {
ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start");
result = ScanOpResult::FAILED;
}
} else {
// A teardown is pending: a stop, or a mode-change release still in
// flight (restarting_); either way the bring-up budget waits.
if (this->scan_wanted_)
this->pending_since_ms_ = now;
if (this->teardown_stuck_(now)) {
// Terminal for stop AND restart: the tracker's backoff owns recovery
// (a stop's release keeps re-driving from loop(); a restart is
// re-requested through scan_start() with a fresh deadline).
result = ScanOpResult::FAILED;
}
}
}
this->last_result_ = result;
return result;
}
ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) {
if (state == BdkActivityState::IDLE && ready) {
// Fully torn down (or never created): the radio is idle. IDLE is trusted
// only when the controller is settled — mid-create the slot still reads
// IDLE, and dropping the handle then would leak the activity once the
// create lands.
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
return ScanOpResult::SETTLED;
}
if (!ready) {
// Acting mid-operation could delete an activity whose start lands
// afterwards, leaking the slot with the radio on; wait.
if (this->last_result_ == ScanOpResult::SETTLED)
ESP_LOGD(TAG, "Scan stop deferred (controller busy)");
return ScanOpResult::PENDING;
}
// Settled, so CREATED unambiguously means "never started".
this->release_activity_(state);
return ScanOpResult::PENDING; // confirmed once IDLE is observed
}
ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) {
if (state == BdkActivityState::STARTED) {
if (this->applied_ == this->requested_)
return ScanOpResult::SETTLED;
// Running with different mode or parameters: tear down (the SDK stop
// chain also deletes the activity) and recreate on a later advance.
if (ready) {
this->release_activity_(state);
// Invalidate so a flip back to the old params cannot SETTLE against the
// activity being deleted (interval 0 never matches a real request).
this->applied_.interval = 0;
this->restarting_ = true;
}
return ScanOpResult::PENDING;
}
if (!ready) {
if (this->last_result_ == ScanOpResult::SETTLED)
ESP_LOGD(TAG, "Scan start deferred (controller busy)");
return ScanOpResult::PENDING;
}
if (state == BdkActivityState::CREATED) {
// Fire-and-forget: SETTLED only once a later advance observes the scan
// running, so a rejected start is retried rather than silently dead. On
// failure the created activity is intact; keep the handle.
if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window,
this->requested_.active) != BdkOpResult::OK)
return ScanOpResult::FAILED;
this->applied_ = this->requested_;
return ScanOpResult::PENDING;
}
if (state == BdkActivityState::OTHER)
return ScanOpResult::PENDING; // transitional; settles on a later read
// IDLE and ready: acquire a slot and create. A kept index is deliberately
// reused: SDK delete returns the slot to idle and create requires an idle
// slot, so it equals a fresh acquire — while clearing here would orphan a
// create still in flight (the BUSY race below).
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
this->scan_activity_idx_ = bdk_scan_acquire_activity();
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX)
return ScanOpResult::FAILED;
}
switch (bdk_scan_create(this->scan_activity_idx_)) {
case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot
case BdkOpResult::OK:
return ScanOpResult::PENDING;
case BdkOpResult::FAILED:
break;
}
// Safe to clear (unlike BUSY): acquire is a pure search, so a rejected
// create leaves the slot IDLE for re-acquire.
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
return ScanOpResult::FAILED;
}
} // namespace esphome::bk72xx_ble
+63 -11
View File
@@ -11,6 +11,8 @@
#include <cstdint>
#include "bdk_scan.h"
namespace esphome::bk72xx_ble {
enum class BLEComponentState : uint8_t {
@@ -19,11 +21,32 @@ enum class BLEComponentState : uint8_t {
ACTIVE,
};
/// Outcome of one reconciliation step.
enum class ScanOpResult : uint8_t {
SETTLED, ///< The request is reached: scan observed running, or stopped
///< with the activity fully released.
PENDING, ///< A step is in flight; loop() keeps advancing — call
///< scan_start() again to learn the outcome.
FAILED, ///< The controller rejected a step; retry later.
};
/// One scan request: mode plus timing, in BLE units (0.625 ms).
struct ScanParams {
bool active;
uint16_t interval;
uint16_t window;
bool operator==(const ScanParams &) const = default;
};
/// One advertisement report from the controller.
struct BLEScanReport {
uint8_t mac[6]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t addr_type;
// GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type
// (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the
// tracker's merger tell the two frames apart.
uint8_t evt_type;
uint8_t data_len; // bytes valid in data[]
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
@@ -60,7 +83,7 @@ class BK72xxBLE final : public Component {
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address, least-significant octet first (BLE convention).
void get_mac_lsb_first(uint8_t out[6]) const;
void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
/// Register a consumer for scan reports (delivered on the main task via loop()).
@@ -69,18 +92,33 @@ class BK72xxBLE final : public Component {
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
#endif
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
/// Enables the stack first if needed. Returns false on controller failure.
bool scan_start(uint16_t interval, uint16_t window);
/// Stop the controller scan (no-op when not scanning).
/// Request a scan (interval/window in 0.625 ms BLE units); enables the
/// stack first if needed. PENDING until the scan is observed running —
/// loop() keeps advancing, call again to learn the outcome.
ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active);
/// Request the scanner stopped and the activity released; steps that
/// cannot run yet are completed from loop().
void scan_stop();
/// Drive a requested stop until the radio is observed idle, bounded by
/// timeout_ms (for OTA). Returns false if it still has not settled.
bool flush_pending_stop(uint32_t timeout_ms);
/// Last reconciliation outcome; on FAILED the consumer's retry policy owns
/// recovery.
ScanOpResult last_scan_result() const { return this->last_result_; }
/// Internal: buffer one controller report (BDK notice callback, BLE task
/// context — bounded copy under the scheduler lock, nothing else).
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data,
uint16_t data_len);
protected:
void resolve_mac_();
ScanOpResult advance_();
ScanOpResult advance_stop_(BdkActivityState state, bool ready);
ScanOpResult advance_start_(BdkActivityState state, bool ready);
bool teardown_stuck_(uint32_t now);
void reset_teardown_episode_();
void release_activity_(BdkActivityState state);
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
// Codegen-sized: no heap allocation, no std::vector template instantiation —
@@ -95,10 +133,24 @@ class BK72xxBLE final : public Component {
// allocate() returns nullptr before push() can fail. This prevents leaking a
// pool slot on a failed push and keeps release() off the producer path.
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
uint8_t scan_actv_idx_{0xFF};
BLEComponentState state_{BLEComponentState::STATE_OFF};
// Largest-to-smallest: padding only at the tail, absorbed by future byte fields.
uint32_t last_advance_ms_{0};
uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change
uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none
uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS
int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none
ScanParams requested_{}; // latched by scan_start()
ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention)
uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX};
bool scan_wanted_{false}; // the latched request is to scan (vs stopped)
bool release_warned_{false}; // gates the release WARN; widens the pump gate
bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released
bool enable_on_boot_{false};
// PENDING means advance_() has more to do; loop() drives it, paced and
// (for a bring-up) bounded.
ScanOpResult last_result_{ScanOpResult::SETTLED};
BLEComponentState state_{BLEComponentState::STATE_OFF};
};
} // namespace esphome::bk72xx_ble
@@ -25,6 +25,7 @@ from esphome.components.ble_device_base import automation as ble_automation
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTIVE,
CONF_CONTINUOUS,
CONF_DURATION,
CONF_ID,
@@ -44,6 +45,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 +145,12 @@ 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")
# Compiles the shared adv + scan-response merge (the BDK delivers the pair
# as separate reports).
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -159,6 +168,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
await ble_automation.advertise_trigger_to_code(conf, var)
@@ -9,10 +9,9 @@
#include "bk72xx_ble_tracker.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble_tracker {
@@ -27,6 +26,15 @@ static const char *const TAG = "bk72xx_ble_tracker";
// a single WARN is emitted when the retry interval first saturates.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
// Stable-run time before the failure streak clears; reset-on-start would keep
// a flapping controller at the 1 s gate.
static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000;
// Radio-idle deadline for the bounded stop drain at OTA start.
static constexpr uint32_t OTA_STOP_FLUSH_MS = 100;
// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part.
constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; }
// ---------------------------------------------------------------------------
// Component lifecycle
@@ -36,11 +44,20 @@ void BK72xxBLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the
// BLE task and delivers here on the main task.
this->parent_->register_scan_listener(this);
// Merged (and unmerged) frames go to the shared dispatcher; unclaimed
// devices are logged only on one-shot scans (continuous would spam).
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
// scan_requested_ check: an on_boot start_scan latched before this setup()
// must keep the retry loop running (rp2/ln882h parity).
if (!this->scan_continuous_ && !this->scan_requested_) {
// Nothing to time until an explicit start_scan(); it re-enables the loop.
this->disable_loop();
}
}
#ifdef USE_OTA_STATE_LISTENER
@@ -50,30 +67,54 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress,
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_requested_before_ota_ = this->scan_requested_;
this->stop_scan();
// The transfer starves the loop; a deferred stop would leave the radio
// scanning for the whole update, so drain it here, bounded.
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS))
ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
// On success the device reboots, so restore only on a failed/aborted update;
// loop() restarts the scan on its next iteration (continuous idle branch).
if (this->scan_continuous_before_ota_) {
this->scan_continuous_before_ota_ = false;
this->scan_continuous_ = true;
this->enable_loop(); // stop_scan() parked it
}
// A one-shot request that was still pending (latched, retrying) when the
// OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
if (this->scan_requested_before_ota_) {
this->scan_requested_before_ota_ = false;
this->scan_requested_ = true;
this->enable_loop();
}
}
}
#endif // USE_OTA_STATE_LISTENER
void BK72xxBLETracker::loop() {
const uint32_t now = millis();
const uint32_t now = App.get_loop_component_start_time();
// Deliver held scannable advertisements whose scan response never arrived —
// unmerged after the merger's timeout.
if (!this->merger_.empty())
this->merger_.sweep(now);
// Before the drop branch: a drop after a stable run starts a fresh streak.
if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS)
this->failed_start_count_ = 0;
// A terminal failure while we report running recovers via the normal retry
// path; the drop charges the backoff so a flapping controller escalates.
if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) {
ESP_LOGW(TAG, "Controller scan lost; retrying");
this->scan_requested_ = true;
this->count_failed_start_();
this->mark_scan_ended_(now);
}
if (this->scan_continuous_) {
if (!this->scan_running_) {
// A start that succeeded re-anchored the period timer from a later millis(),
// so the stale `now` below would underflow the comparison and fire
// on_scan_end() for a scan that just began. Resume next iteration.
// One-iteration deferral; all stamps share this iteration's cached
// timestamp, so the period check below cannot underflow.
if (this->try_start_with_backoff_(now))
return;
}
@@ -81,11 +122,7 @@ void BK72xxBLETracker::loop() {
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
// that never came up (start kept failing) does not fire spurious on_scan_end events.
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
this->fire_scan_end_();
this->scan_period_start_ = now;
}
return;
@@ -99,13 +136,14 @@ void BK72xxBLETracker::loop() {
// would be silent: the scan never runs, stop_scan_() is never reached and
// on_scan_end() never fires, leaving period-keyed consumers waiting forever.
if (this->scan_requested_ && !this->scan_running_) {
// Same stale-`now` hazard as the continuous branch: start_scan_() stamps
// scan_start_time_ from a later millis(), so the duration check below would
// underflow and stop the scan in the iteration that started it.
// Same one-iteration deferral as the continuous branch.
if (this->try_start_with_backoff_(now))
return;
}
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
// A full-duration run proves the controller healthy even when duration is
// shorter than SCAN_STABLE_RESET_MS.
this->failed_start_count_ = 0;
this->stop_scan_();
}
}
@@ -122,32 +160,54 @@ bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) {
// even user-initiated attempts respect the backoff, so a start_scan() action
// on a short cadence cannot hammer a failing controller; the attempt stays
// inside the failure accounting below either way.
const uint8_t doublings = std::min<uint8_t>(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS);
if ((!force || this->failed_start_count_ != 0) &&
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << doublings))
// Mid bring-up, observe instead of re-issuing (the hub self-advances). A
// SETTLED outcome completes immediately; only fresh attempts after FAILED
// are rate-limited.
const auto hub = this->parent_->last_scan_result();
if (hub == bk72xx_ble::ScanOpResult::PENDING)
return false;
this->last_scan_start_attempt_ = now;
if (hub == bk72xx_ble::ScanOpResult::FAILED) {
if (this->start_attempt_open_) {
// Our bring-up gave up asynchronously; charge it to the backoff.
this->start_attempt_open_ = false;
this->count_failed_start_();
}
if ((!force || this->failed_start_count_ != 0) &&
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_))
return false;
}
this->start_scan_();
if (!this->scan_running_ && this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
if (!this->scan_running_) {
if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) {
this->start_attempt_open_ = true;
return false; // the controller is still bringing the scan up; not a failure
}
this->count_failed_start_();
}
return this->scan_running_;
}
void BK72xxBLETracker::count_failed_start_() {
if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
++this->failed_start_count_;
if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
(SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
}
}
return this->scan_running_;
}
void BK72xxBLETracker::dump_config() {
ESP_LOGCONFIG(TAG,
"BK72xx BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n"
" Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
" Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
" Scan Type: %s (configured %s)\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_,
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_,
ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
}
// ---------------------------------------------------------------------------
@@ -156,31 +216,33 @@ void BK72xxBLETracker::dump_config() {
// listener dispatch run in main-loop context with no cross-task handling here.
// ---------------------------------------------------------------------------
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,
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
// 5.2 fill it from gapm_ext_adv_report_ind.info).
static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
ble_device_base::ESPBTDevice device;
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
bool found = false;
for (auto *listener : this->listeners_) {
if (listener->parse_device(device)) {
found = true;
}
// Demux advertisements vs scan responses into the shared merger: the BDK
// delivers the pair as separate reports; a scannable advertisement is held
// until its scan response arrives and delivered as one merged frame.
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
return;
}
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
// it and the scan is one-shot (continuous scans would spam).
if (!found && !this->scan_continuous_)
this->discovered_log_.log_device(TAG, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Stash only while an active scan runs: a passive scan never gets a
// response, and after a stop nothing would sweep the merger, so a late
// report would surface minutes later as a fresh advertisement.
if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) {
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
App.get_loop_component_start_time());
return;
}
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
}
// ---------------------------------------------------------------------------
@@ -207,7 +269,8 @@ void BK72xxBLETracker::start_scan() {
// against a failing controller, repeated start_scan() calls are rate-limited
// like any other attempt.
this->scan_requested_ = true;
this->try_start_with_backoff_(millis(), /* force= */ true);
this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_()
this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true);
}
void BK72xxBLETracker::restart_scan_duration() {
@@ -218,7 +281,7 @@ void BK72xxBLETracker::restart_scan_duration() {
// start_scan action fired more often than scan_duration_ would otherwise
// suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
// publish) rides on that period.
this->scan_start_time_ = millis();
this->scan_start_time_ = App.get_loop_component_start_time();
}
void BK72xxBLETracker::stop_scan() {
@@ -231,24 +294,31 @@ void BK72xxBLETracker::stop_scan() {
// Internal scan start / stop
// ---------------------------------------------------------------------------
bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() {
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
}
void BK72xxBLETracker::start_scan_() {
if (this->scan_running_)
return;
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_)))
if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED)
return;
const uint32_t now = millis();
const uint32_t now = App.get_loop_component_start_time();
this->scan_running_ = true;
this->scan_requested_ = false; // the latched one-shot request is satisfied
this->failed_start_count_ = 0; // reset here so direct starts clear the backoff too
this->start_attempt_open_ = false;
// failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
this->scan_start_time_ = now;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f,
this->scan_interval_ * 0.625f);
ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_),
ble_units_to_ms(this->scan_interval_));
// Re-anchor the on_scan_end period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
@@ -258,18 +328,49 @@ void BK72xxBLETracker::start_scan_() {
this->scan_started_once_ = true;
}
// Deliberate logical/physical split: on_scan_end() reports the tracker's
// intent while the hub winds the radio down asynchronously; OTA is the one
// path that must wait, and it flushes explicitly.
void BK72xxBLETracker::stop_scan_() {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
this->start_attempt_open_ = false; // an abandoned bring-up is not charged
this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
if (this->scan_running_) {
ESP_LOGD(TAG, "Scan stopped");
this->mark_scan_ended_(App.get_loop_component_start_time());
}
// Park when idle (the hub drives its own teardown); re-check because an
// on_scan_end automation may have restarted the scan.
if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_)
this->disable_loop();
}
// The period re-anchor keeps on_scan_end from double-firing in one iteration.
void BK72xxBLETracker::mark_scan_ended_(uint32_t now) {
this->scan_running_ = false;
ESP_LOGD(TAG, "Scan stopped");
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire
this->fire_scan_end_();
this->scan_period_start_ = now;
}
void BK72xxBLETracker::fire_scan_end_() {
// Deliver held advertisements whose scan response never came (unmerged)
// BEFORE on_scan_end fires.
this->merger_.flush();
this->dispatcher_.on_scan_end();
}
// true = request latched, not applied: the reconciler applies it
// asynchronously and loop() recovers a failed re-arm (ln882h parity).
bool BK72xxBLETracker::request_scan_mode(bool active) {
if (this->scan_active_ == active)
return true;
this->scan_active_ = active;
// V: the proxy's "Setting scanner mode" line already narrates this at D.
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
// The controller reconciler restarts a running scan itself; the scan stays
// logically running. An idle scanner picks the mode up on its next start.
if (this->scan_running_)
this->controller_scan_start_();
return true;
}
} // namespace esphome::bk72xx_ble_tracker
@@ -21,6 +21,7 @@
// window: 30ms
// duration: 5min
// continuous: true
// active: true
#pragma once
@@ -29,6 +30,7 @@
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/components/ble_device_base/scan_response_merger.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -45,7 +47,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
@@ -70,6 +71,12 @@ class BK72xxBLETracker : public Component,
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
/// Set from YAML (scan_parameters.active); runtime mode requests change
/// only the resolved mode.
void set_scan_active(bool scan_active) {
this->scan_active_ = scan_active;
this->scan_active_configured_ = scan_active;
}
/// Set from YAML (scan_parameters.continuous); also the value
/// configured_continuous() reports and a bare start_scan action restores.
void set_configured_continuous(bool scan_continuous) {
@@ -93,38 +100,30 @@ class BK72xxBLETracker : public Component,
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
this->dispatcher_.register_listener(listener);
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->dispatcher_.set_raw_advertisement_callback(callback);
}
ble_device_base::HubCapabilities get_capabilities() const override {
// 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
// receiver merges per address (Home Assistant does). No GATT client either.
// scan_mode_switch stays false for the same reason: with no active-scan
// 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 {
// Passive-only controller: a passive request is already honored, an active
// one cannot be.
return !active;
static constexpr ble_device_base::HubCapabilities get_capabilities() {
// Active scanning is driven through bk72xx_ble's reconciler because the BDK
// API itself is passive-only. The controller delivers scan responses as
// separate reports; this tracker merges the pair before delivery (shared
// ScanResponseMerger, Bluedroid semantics). No GATT client.
return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true};
}
bool request_scan_mode(bool 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 {
uint8_t mac[6];
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
uint8_t mac[MAC_ADDRESS_SIZE];
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 this->scan_active_; }
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
@@ -134,15 +133,20 @@ class BK72xxBLETracker : public Component,
protected:
void start_scan_();
void stop_scan_();
/// Attempt a rate-limited (re)start; returns true when the scan is running,
/// which means the caller must not compare its cached millis() against the
/// timestamps start_scan_() just refreshed. force bypasses the rate gate for
/// an explicit user start only while the failure streak is clean; a failing
/// controller rate-limits forced attempts too. Failure accounting always runs.
void fire_scan_end_();
void mark_scan_ended_(uint32_t now);
/// Stamp-and-start for every controller scan attempt, so the retry rate
/// limit covers all callers.
bk72xx_ble::ScanOpResult controller_scan_start_();
/// Rate-limited (re)start; true when the scan is running (the caller must
/// not reuse a `now` older than the stamps this refreshed). Force and
/// backoff rules are documented at the definition.
bool try_start_with_backoff_(uint32_t now, bool force = false);
void count_failed_start_();
bool scan_running_{false};
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once
// Defaults: the BK reference — 30 % duty cycle
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
@@ -150,30 +154,27 @@ class BK72xxBLETracker : public Component,
uint32_t scan_duration_{300000};
bool scan_continuous_{true};
bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it
bool scan_active_{true}; // resolved mode; see scan_parameters.active
bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure
#endif
uint32_t scan_start_time_{0};
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success)
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries
uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop())
uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end()
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{};
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Parsed-advertisement consumers registered through ble_device_base.
// Codegen-sized: no heap allocation, no std::vector template instantiations.
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
// in ble_device_base, identical output on every tracker backend. Guarded like
// its only writer so a no-listener build does not carry an unused vector.
ble_device_base::DiscoveredDeviceLog discovered_log_{};
#endif
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
// All calls run on the main task (the controller queue already crossed
// tasks). Merger clock: stash_adv() reads the PARENT's cached loop time
// (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this
// component's — same App.loop() pass, so the delta stays non-negative and
// the 300 ms timeout holds.
ble_device_base::ScanResponseMerger merger_;
ble_device_base::AdvDispatcher dispatcher_;
};
} // namespace esphome::bk72xx_ble_tracker
+321 -103
View File
@@ -1,8 +1,23 @@
from collections.abc import Callable
import functools
from typing import Any
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.esp32_ble import BTLoggers
from esphome.components import ble_device_base, bluetooth_connection
from esphome.components.ble_device_base import (
BT_UUID16_FORMAT as bt_uuid16_format,
BT_UUID32_FORMAT as bt_uuid32_format,
BT_UUID128_FORMAT as bt_uuid128_format,
as_hex,
as_reversed_hex_array,
bt_uuid,
)
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_CHARACTERISTIC_UUID,
@@ -15,13 +30,53 @@ from esphome.const import (
CONF_SERVICE_UUID,
CONF_TRIGGER_ID,
CONF_VALUE,
PLATFORM_ESP32,
PlatformFramework,
)
from esphome.core import ID
from esphome.core import CORE, ID
from esphome.enum import StrEnum
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
AUTO_LOAD = ["esp32_ble_client"]
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily inside
# the esp32 schema/codegen arms: importing those modules registers esp32-only
# automations as a side effect, which must not leak into the neutral
# platforms' registries (the bluetooth_proxy pattern).
def _legacy_engine() -> bool:
"""True when the build uses the legacy raw-gattc engine - one line to
flip when esp32 moves to the neutral engine (with
USE_BLE_CLIENT_LEGACY_ENGINE in _to_code_esp32)."""
return CORE.is_esp32
def AUTO_LOAD() -> list[str]:
"""The engine's closure per platform: the legacy esp32 engine builds on
esp32_ble_client plus bluetooth_connection (the shared service-table
materializer; its sources compile empty in builds without a neutral
node), the neutral engine on the bluetooth_connection backend. The
platform-less arm is the union for manifest-resolving tooling."""
if _legacy_engine() or CORE.target_platform is None:
return ["bluetooth_connection", "esp32_ble_client"]
return ["bluetooth_connection"]
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
DEPENDENCIES = ["esp32_ble_tracker"]
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"ble_client.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
# Every framework of every non-esp32 registry platform: a platform
# that validates the neutral arm must also compile the neutral engine.
"ble_client_gatt.cpp": frameworks_for_platforms(
set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32}
),
}
)
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
@@ -58,7 +113,9 @@ def notify_from_on_notify(config: ConfigType) -> ConfigType:
ble_client_ns = cg.esphome_ns.namespace("ble_client")
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
# One codegen class for both engines: the exclusively-gated headers resolve
# the name to exactly one C++ definition per build.
BLEClient = ble_client_ns.class_("BLEClient", cg.Component)
BLEClientNode = ble_client_ns.class_("BLEClientNode")
BLEClientNodeConstRef = BLEClientNode.operator("ref").operator("const")
# Triggers
@@ -105,62 +162,179 @@ CONF_AUTO_CONNECT = "auto_connect"
MULTI_CONF = True
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEClient),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientConnectTrigger
),
}
),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientDisconnectTrigger
),
}
),
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyRequestTrigger
),
}
),
cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyNotificationTrigger
),
}
),
cv.Optional(
CONF_ON_NUMERIC_COMPARISON_REQUEST
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientNumericComparisonRequestTrigger
),
}
),
}
# Keys shared by both engines' schemas.
_COMMON_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLEClient),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_AUTO_CONNECT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BLEClientConnectTrigger),
}
),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientDisconnectTrigger
),
}
),
}
).extend(cv.COMPONENT_SCHEMA)
@functools.cache
def _esp32_config_schema() -> cv.All:
"""The legacy engine's schema, byte-compatible with what esp32 always had
(including the Bluedroid security triggers)."""
from esphome.components import esp32_ble_tracker
return cv.All(
_COMMON_SCHEMA.extend(
{
# Accepted-but-unused legacy key; not propagated to the
# neutral schema.
cv.Optional(CONF_NAME): cv.string,
cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyRequestTrigger
),
}
),
cv.Optional(
CONF_ON_PASSKEY_NOTIFICATION
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientPasskeyNotificationTrigger
),
}
),
cv.Optional(
CONF_ON_NUMERIC_COMPARISON_REQUEST
): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
BLEClientNumericComparisonRequestTrigger
),
}
),
}
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
bluetooth_connection.consume_gatt_slot("ble_client"),
)
.extend(cv.COMPONENT_SCHEMA)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
esp32_ble.consume_connection_slots(1, "ble_client"),
)
@functools.cache
def _gatt_config_schema(platform: str) -> cv.All:
"""The neutral engine's schema: the shared keys plus the hub reference
(parsed-advertisement sightings) and the GATT backend declaration.
Keyed by platform - the backend fragment differs per platform."""
return cv.All(
_COMMON_SCHEMA.extend(ble_device_base.BLE_DEVICE_SCHEMA).extend(
bluetooth_connection.gatt_client_schema(platform)
),
bluetooth_connection.consume_gatt_slot("ble_client"),
)
@schema_extractor("schema")
def _validate_platform(config: ConfigType) -> ConfigType:
if config is SCHEMA_EXTRACT:
# Deliberate gap (the bluetooth_proxy pattern): the dumper gets only
# this shape, so the neutral arm's ble_hub_id is absent from editor
# schemas and the esp32-only keys are advertised on every platform.
# The language-schema dumper runs without a platform; expose the
# esp32 (legacy-engine) shape.
return _esp32_config_schema()
if _legacy_engine():
return _esp32_config_schema()(config)
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
return _gatt_config_schema(CORE.target_platform)(config)
raise cv.Invalid(f"ble_client is not supported on {CORE.target_platform}")
CONFIG_SCHEMA = _validate_platform
CONF_BLE_CLIENT_ID = "ble_client_id"
class BLEClientFeatures(StrEnum):
"""Per-platform engine capabilities consumers declare against."""
# The platform-neutral node interface (on_connected/table + completion
# callbacks) - every platform with a ble_client engine.
GATT_NODE = "gatt_node"
# The raw esp32 GATT client event stream (gattc/gap handlers,
# node_state) - the legacy engine only.
RAW_GATTC = "raw_gattc"
# Pairing dialog replies and bond management (Bluedroid GAP/SMP).
SECURITY = "security"
def _engine_features() -> set[BLEClientFeatures]:
"""Features the validated platform's engine provides."""
if _legacy_engine():
return {
BLEClientFeatures.GATT_NODE,
BLEClientFeatures.RAW_GATTC,
BLEClientFeatures.SECURITY,
}
if CORE.target_platform in bluetooth_connection.GATT_CLIENT_PLATFORMS:
return {BLEClientFeatures.GATT_NODE}
return set()
def requires_feature(
feature: BLEClientFeatures, description: str
) -> Callable[[Any], Any]:
"""Validator gating a consumer to platforms whose engine provides
`feature`, naming the missing capability in the error."""
def validator(value: Any) -> Any:
features = _engine_features()
if feature not in features:
available = (
f"; this platform's engine provides: {', '.join(sorted(features))}"
if features
else ""
)
raise cv.Invalid(
f"{description} requires the ble_client '{feature}' feature, "
f"which {CORE.target_platform} does not provide{available}"
)
return value
return validator
# The one choke point for every node component still on the raw esp32 event
# stream; migrating to the neutral interface (NODE_BLE_CLIENT_SCHEMA +
# register_gatt_node) lifts it.
_legacy_engine_only = requires_feature(
BLEClientFeatures.RAW_GATTC,
"This component drives the raw ESP32 GATT client events and has not "
"been migrated to the platform-neutral node interface yet; it",
)
BLE_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.use_id(BLEClient),
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
cv.use_id(BLEClient), _legacy_engine_only
),
}
)
# For node components on the neutral interface: valid wherever ble_client
# itself is.
NODE_BLE_CLIENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BLE_CLIENT_ID): cv.All(
cv.use_id(BLEClient),
requires_feature(BLEClientFeatures.GATT_NODE, "This component"),
),
}
)
@@ -170,11 +344,31 @@ async def register_ble_node(var, config):
cg.add(parent.register_ble_node(var))
def _request_gatt_node_build() -> None:
"""Node storage and the one define meaning "the neutral node surface is
compiled in", plus the esp32 bridge/materializer defines."""
_request_node_slot()
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
if _legacy_engine():
# Deliberately not ble_device_base.request_gatt_client(): that would
# claim a phantom backend slot on combined proxy builds.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_BACKEND_BLUEDROID")
cg.add_define("USE_BLUEDROID_GATT_SERVICE_TABLE")
async def register_gatt_node(var, config):
"""Register a node on the platform-neutral interface (both engines)."""
parent = await cg.get_variable(config[CONF_BLE_CLIENT_ID])
_request_gatt_node_build()
cg.add(parent.register_gatt_node(var))
BLE_WRITE_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid,
cv.Required(CONF_CHARACTERISTIC_UUID): esp32_ble_tracker.bt_uuid,
cv.Required(CONF_SERVICE_UUID): bt_uuid,
cv.Required(CONF_CHARACTERISTIC_UUID): bt_uuid,
cv.Required(CONF_VALUE): cv.templatable(cv.ensure_list(cv.hex_uint8_t)),
}
)
@@ -185,25 +379,34 @@ BLE_CONNECT_ACTION_SCHEMA = maybe_simple_id(
}
)
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
}
BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean),
}
),
)
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
}
BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)),
}
),
)
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
}
BLE_REMOVE_BOND_ACTION_SCHEMA = cv.All(
requires_feature(BLEClientFeatures.SECURITY, "This action"),
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(BLEClient),
}
),
)
@@ -237,6 +440,8 @@ async def ble_connect_to_code(config, action_id, template_arg, args):
)
async def ble_write_to_code(config, action_id, template_arg, args):
parent = await cg.get_variable(config[CONF_ID])
# The action registers itself as a neutral node in its constructor.
_request_gatt_node_build()
var = cg.new_Pvariable(action_id, template_arg, parent)
value = config[CONF_VALUE]
@@ -251,38 +456,20 @@ async def ble_write_to_code(config, action_id, template_arg, args):
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*value))
cg.add(var.set_value_simple(arr, len(value)))
if len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(
var.set_service_uuid16(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
)
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid32_format):
cg.add(
var.set_service_uuid32(esp32_ble_tracker.as_hex(config[CONF_SERVICE_UUID]))
)
elif len(config[CONF_SERVICE_UUID]) == len(esp32_ble_tracker.bt_uuid128_format):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(config[CONF_SERVICE_UUID])
if len(config[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
cg.add(var.set_service_uuid16(as_hex(config[CONF_SERVICE_UUID])))
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid32_format):
cg.add(var.set_service_uuid32(as_hex(config[CONF_SERVICE_UUID])))
elif len(config[CONF_SERVICE_UUID]) == len(bt_uuid128_format):
uuid128 = as_reversed_hex_array(config[CONF_SERVICE_UUID])
cg.add(var.set_service_uuid128(uuid128))
if len(config[CONF_CHARACTERISTIC_UUID]) == len(esp32_ble_tracker.bt_uuid16_format):
cg.add(
var.set_char_uuid16(
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
)
)
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
esp32_ble_tracker.bt_uuid32_format
):
cg.add(
var.set_char_uuid32(
esp32_ble_tracker.as_hex(config[CONF_CHARACTERISTIC_UUID])
)
)
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(
esp32_ble_tracker.bt_uuid128_format
):
uuid128 = esp32_ble_tracker.as_reversed_hex_array(
config[CONF_CHARACTERISTIC_UUID]
)
if len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid16_format):
cg.add(var.set_char_uuid16(as_hex(config[CONF_CHARACTERISTIC_UUID])))
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid32_format):
cg.add(var.set_char_uuid32(as_hex(config[CONF_CHARACTERISTIC_UUID])))
elif len(config[CONF_CHARACTERISTIC_UUID]) == len(bt_uuid128_format):
uuid128 = as_reversed_hex_array(config[CONF_CHARACTERISTIC_UUID])
cg.add(var.set_char_uuid128(uuid128))
return var
@@ -339,14 +526,45 @@ async def remove_bond_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, parent)
async def to_code(config):
async def _to_code_esp32(config: ConfigType) -> cg.MockObj:
from esphome.components import esp32_ble, esp32_ble_tracker
from esphome.components.esp32_ble import BTLoggers
# Register the loggers this component needs
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP)
cg.add_define("USE_ESP32_BLE_UUID")
cg.add_define("USE_BLE_CLIENT_LEGACY_ENGINE")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await esp32_ble_tracker.register_client(var, config)
return var
# Sizes the neutral client's node storage; the client itself requests a
# baseline slot so the define exists on every build that compiles the engine.
_request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
# The engine always carries the node surface (the client itself owns the
# baseline slot).
_request_gatt_node_build()
backend = await bluetooth_connection.new_gatt_backend(config)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_backend(backend))
# Sighting-gated connects: the client listens for the peer's parsed
# advertisements through the hub.
await ble_device_base.register_ble_device(var, config)
return var
async def to_code(config: ConfigType) -> None:
if _legacy_engine():
var = await _to_code_esp32(config)
else:
var = await _to_code_gatt(config)
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
cg.add(var.set_auto_connect(config[CONF_AUTO_CONNECT]))
for conf in config.get(CONF_ON_CONNECT, []):
@@ -1,11 +0,0 @@
#ifdef USE_ESP32
#include "automation.h"
namespace esphome::ble_client {
const char *const Automation::TAG = "ble_client.automation";
} // namespace esphome::ble_client
#endif
+2 -153
View File
@@ -1,27 +1,14 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#include <utility>
#include <vector>
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "esphome/core/automation.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
namespace esphome::ble_client {
// placeholder class for static TAG .
class Automation {
public:
// could be made inline with C++17
static const char *const TAG;
};
// implement on_connect automation.
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
public:
@@ -93,144 +80,6 @@ class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>,
}
};
// implement the ble_client.ble_write action.
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_ble_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = espbt::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
// Static mode: write directly from flash pointer
result = this->write(this->value_.data, this->len_);
} else {
// Template mode: call function and write the vector
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value);
}
// on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
/**
* Note about logging: the esph_log_X macros are used here because the CI checks complain about use of the ESP LOG
* macros in header files (Can't even write it in a comment!)
* Not sure why, because they seem to work just fine.
* The problem is that the implementation of a templated class can't be placed in a .cpp file when using C++ less than
* 17, so the methods have to be here. The esph_log_X macros are equivalent in function, but don't trigger the CI
* errors.
*/
// initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event.
bool write(const uint8_t *data, size_t len) {
if (this->node_state != espbt::ClientState::ESTABLISHED) {
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
esp_err_t err =
esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len,
const_cast<uint8_t *>(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE);
if (err != ESP_OK) {
esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err));
return false;
}
return true;
}
bool write(const std::vector<uint8_t> &value) { return this->write(value.data(), value.size()); }
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override {
switch (event) {
case ESP_GATTC_WRITE_CHAR_EVT:
// upstream code checked the MAC address, verify the characteristic.
if (param->write.handle == this->char_handle_)
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
break;
case ESP_GATTC_DISCONNECT_EVT:
if (this->num_running_ != 0)
this->stop_complex();
break;
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *chr = this->parent()->get_characteristic(this->service_uuid_, this->char_uuid_);
if (chr == nullptr) {
char char_buf[esp32_ble::UUID_STR_LEN];
char service_buf[esp32_ble::UUID_STR_LEN];
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s",
this->char_uuid_.to_str(char_buf), this->service_uuid_.to_str(service_buf));
break;
}
this->char_handle_ = chr->handle;
this->char_props_ = chr->properties;
if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE) {
this->write_type_ = ESP_GATT_WRITE_TYPE_RSP;
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_RSP");
} else if (this->char_props_ & ESP_GATT_CHAR_PROP_BIT_WRITE_NR) {
this->write_type_ = ESP_GATT_WRITE_TYPE_NO_RSP;
esph_log_d(Automation::TAG, "Write type: ESP_GATT_WRITE_TYPE_NO_RSP");
} else {
char char_buf[esp32_ble::UUID_STR_LEN];
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
break;
}
this->node_state = espbt::ClientState::ESTABLISHED;
char char_buf[esp32_ble::UUID_STR_LEN];
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
ble_client_->address_str());
break;
}
default:
break;
}
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
espbt::ESPBTUUID service_uuid_;
espbt::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
esp_gatt_char_prop_t char_props_{};
esp_gatt_write_type_t write_type_{};
};
template<typename... Ts> class BLEClientPasskeyReplyAction final : public Action<Ts...> {
public:
BLEClientPasskeyReplyAction(BLEClient *ble_client) { parent_ = ble_client; }
@@ -0,0 +1,118 @@
// Neutral twins of the shared ble_client automations. Class names, namespace,
// and codegen-visible signatures are IDENTICAL to automation.h so generated
// main.cpp compiles against whichever engine the build gates in; only the
// internals differ (client callbacks and the neutral node interface instead
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include <tuple>
#include "ble_client_gatt.h"
#include "esphome/core/automation.h"
namespace esphome::ble_client {
class BLEClientConnectTrigger final : public Trigger<> {
public:
explicit BLEClientConnectTrigger(BLEClient *parent) {
parent->add_on_connect_callback([this]() { this->trigger(); });
}
};
class BLEClientDisconnectTrigger final : public Trigger<> {
public:
explicit BLEClientDisconnectTrigger(BLEClient *parent) {
// Fires only after a completed connection (never for failed attempts),
// matching the legacy CLOSE_EVT semantics.
parent->add_on_disconnect_callback([this]() { this->trigger(); });
}
};
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
public:
BLEClientConnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
ble_client->add_on_connect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
// A connect attempt that dies (or a later disconnect) terminates the
// chain, mirroring the legacy DISCONNECT_EVT handling.
ble_client->add_on_connect_failed_callback([this]() {
if (this->num_running_ != 0)
this->stop_complex();
});
ble_client->add_on_disconnect_callback([this]() {
if (this->num_running_ != 0)
this->stop_complex();
});
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
// it makes no sense to have multiple instances of this running at the
// same time; cancel a re-trigger while still running.
if (this->num_running_ != 0) {
this->stop_complex();
return;
}
this->num_running_++;
if (this->ble_client_->connected()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
// No-op while already connecting; the callback resolves the wait.
this->ble_client_->connect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
public:
BLEClientDisconnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
// Both terminal outcomes resolve the wait: a completed teardown and a
// connect attempt that died on the way down.
ble_client->add_on_disconnect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
ble_client->add_on_connect_failed_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
if (this->ble_client_->idle()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
this->ble_client_->disconnect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
+270 -3
View File
@@ -2,10 +2,16 @@
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
#endif
namespace esphome::ble_client {
@@ -30,6 +36,10 @@ void BLEClient::dump_config() {
bool BLEClient::parse_device(const espbt::ESPBTDevice &device) {
if (!this->enabled)
return false;
#ifdef USE_BLE_CLIENT_GATT_NODES
if (device.address_uint64() == this->address_ && this->gatt_backoff_.holding_off())
return false;
#endif
return BLEClientBase::parse_device(device);
}
@@ -40,24 +50,60 @@ void BLEClient::set_enabled(bool enabled) {
if (!enabled) {
ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str());
this->disconnect();
return;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
// A re-enable clears the backoff (neutral-engine parity).
this->gatt_backoff_.reset();
#endif
}
bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
esp_ble_gattc_cb_param_t *param) {
#ifdef USE_BLE_CLIENT_GATT_NODES
// Bridge-initiated registrations bypass the base's REG_FOR_NOTIFY handling:
// its automatic CCCD write would double the node's own.
// Handle-keyed: mixed legacy/neutral subscriptions to one characteristic
// are unsupported during the migration window.
if (event == ESP_GATTC_REG_FOR_NOTIFY_EVT && esp_gattc_if == this->gattc_if_ &&
this->take_pending_gatt_reg_(param->reg_for_notify.handle)) {
if (this->pending_notify_regs_ > 0)
this->pending_notify_regs_--;
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
this->notify_state_to_gatt_nodes_(param->reg_for_notify.handle, true, err);
// A retiring last registration must still release the cache.
this->maybe_release_services_();
return true;
}
#endif
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
return false;
#ifdef USE_BLE_CLIENT_GATT_NODES
// Before the legacy fan-out so gatt nodes resolve before any trigger fires.
if (!this->gatt_nodes_.empty()) {
if (event == ESP_GATTC_SEARCH_CMPL_EVT) {
// A failed discovery tears the link down; the on_connect trigger must
// not fire into the teardown.
if (!this->handle_gatt_search_cmpl_(param->search_cmpl.status))
return true;
} else {
this->dispatch_gatt_event_(event, param);
}
}
#endif
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
this->maybe_release_services_();
return true;
}
void BLEClient::maybe_release_services_() {
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
this->release_services();
ESP_LOGD(TAG, "All clients established, services released");
}
return true;
}
void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
@@ -65,10 +111,19 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
for (auto *node : this->nodes_)
node->gap_event_handler(event, param);
#ifdef USE_BLE_CLIENT_GATT_NODES
if (event == ESP_GAP_BLE_AUTH_CMPL_EVT && this->check_addr(param->ble_security.auth_cmpl.bd_addr)) {
int status = param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason;
for (auto *node : this->gatt_nodes_)
node->on_pairing_result(status);
}
#endif
}
void BLEClient::set_state(espbt::ClientState state) {
BLEClientBase::set_state(state);
// ESTABLISHED never flows through here; gatt nodes are promoted after the
// on_connected fan-out.
for (auto &node : nodes_)
node->node_state = state;
}
@@ -83,6 +138,218 @@ bool BLEClient::all_nodes_established_() {
return true;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
void BLEClient::register_gatt_node(BLEClientNode *node) {
// Parent before the capacity check so a dropped node still has a usable
// parent() (neutral-engine parity).
node->set_ble_client_parent(this);
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str());
this->status_set_error(LOG_STR("node capacity exceeded"));
return;
}
this->gatt_nodes_.push_back(node);
// nodes_ covers the shared state bookkeeping; gatt_nodes_ is the neutral
// fan-out subset.
this->register_ble_node(node);
}
int BLEClient::find_pending_gatt_reg_(uint16_t handle) const {
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
if (this->pending_gatt_regs_[i] == handle)
return i;
}
return -1;
}
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
int i = this->find_pending_gatt_reg_(handle);
if (i < 0)
return false;
// No duplicates (notify_characteristic refuses a re-push); swap-with-last.
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
return true;
}
void BLEClient::notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str(),
enabled ? "enable" : "disable", handle, error);
}
for (auto *node : this->gatt_nodes_)
node->on_notify_state(handle, enabled, error);
}
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_READ_CHAR_EVT:
case ESP_GATTC_READ_DESCR_EVT: {
bool ok = param->read.status == ESP_GATT_OK;
if (!ok) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str(), param->read.handle,
param->read.status);
}
for (auto *node : this->gatt_nodes_) {
node->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 (param->write.status != ESP_GATT_OK) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str(), param->write.handle,
param->write.status);
}
for (auto *node : this->gatt_nodes_) {
node->on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
}
break;
case ESP_GATTC_NOTIFY_EVT:
for (auto *node : this->gatt_nodes_) {
node->on_notify(param->notify.handle, param->notify.value, param->notify.value_len);
}
break;
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT:
// The base does no CCCD work for unregister; no interception needed.
this->notify_state_to_gatt_nodes_(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
default:
break;
}
}
bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
// The base ignores the search status; the neutral contract must not.
uint16_t service_total = 0;
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
this->gattc_if_, this->conn_id_, &service_total);
if (!counted || service_total == 0) {
// A failed search poisons the whole discovery, legacy nodes included.
ESP_LOGW(TAG, "[%s] Discovery failed (status=%d, services=%u)", this->address_str(), status, service_total);
this->gatt_backoff_.register_failure(this->address_str());
this->disconnect();
return false;
}
// Stack-owned; nodes copy their handles during on_connected().
bluetooth_connection::BluedroidServiceTable table;
if (!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
if (!this->has_legacy_nodes_()) {
ESP_LOGW(TAG, "[%s] Service table build failed; treating as failed discovery", this->address_str());
this->gatt_backoff_.register_failure(this->address_str());
this->disconnect();
return false;
}
// Only the table build failed; legacy nodes read the base's services_
// and keep the link. Gatt nodes catch the next connection.
ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str());
this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed"));
} else {
this->gatt_connected_ = true;
auto view = table.view();
for (auto *node : this->gatt_nodes_) {
node->on_connected(view);
if (this->state() != espbt::ClientState::ESTABLISHED) {
// The node tore the link down; remaining nodes get on_disconnected
// with no preceding on_connected, so leave a trace of why.
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str());
return false;
}
}
this->gatt_backoff_.reset();
this->status_clear_warning();
}
// Promote so the legacy release condition can fire.
for (auto *node : this->gatt_nodes_)
node->node_state = espbt::ClientState::ESTABLISHED;
return true;
}
void BLEClient::on_disconnect_complete(esp_err_t reason) {
this->pending_gatt_reg_count_ = 0;
if (!this->gatt_connected_)
return; // Never-established links report nothing (neutral parity).
this->gatt_connected_ = false;
for (auto *node : this->gatt_nodes_)
node->on_disconnected();
}
int BLEClient::check_and_log_error_(const char *operation, esp_err_t err) {
if (err != ESP_OK)
this->log_gattc_warning_(operation, err);
return err;
}
int BLEClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
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 BLEClient::read_characteristic(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
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 BLEClient::read_descriptor(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
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 BLEClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
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 BLEClient::notify_characteristic(uint16_t handle, bool enable) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
if (enable) {
if (this->find_pending_gatt_reg_(handle) >= 0) {
// ESP_OK: the in-flight registration's completion fans out to all nodes.
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
return ESP_OK;
}
if (this->pending_gatt_reg_count_ == MAX_PENDING_NOTIFY_REGS) {
// An untracked registration would let the base's auto-CCCD through.
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
return ble_device_base::GATT_ERR_NO_MEMORY;
}
// The base helper's pending count holds the service-release until the
// (intercepted) completion.
esp_err_t err = this->register_for_notify(handle);
if (err == ESP_OK)
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err);
}
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 BLEClient::unpair() { return bluetooth_connection::unpair_device(this->get_address()); }
#endif // USE_BLE_CLIENT_GATT_NODES
} // namespace esphome::ble_client
#endif
+53 -33
View File
@@ -1,18 +1,20 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "ble_client_node.h"
#include "connect_backoff.h"
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32
#include <esp_bt_defs.h>
#include <esp_gap_ble_api.h>
#include <esp_gatt_common_api.h>
#include <esp_gattc_api.h>
#include <array>
#include <string>
#include <vector>
namespace esphome::ble_client {
@@ -21,34 +23,6 @@ namespace espbt = esphome::esp32_ble_tracker;
using namespace esp32_ble_client;
class BLEClient;
class BLEClientNode {
public:
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param){};
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
virtual void loop() {}
void set_address(uint64_t address) { address_ = address; }
espbt::ESPBTClient *client;
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
espbt::ClientState node_state;
BLEClient *parent() { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_;
uint64_t address_;
};
class BLEClient final : public BLEClientBase {
public:
void setup() override;
@@ -64,7 +38,6 @@ class BLEClient final : public BLEClientBase {
void set_enabled(bool enabled);
void register_ble_node(BLEClientNode *node) {
node->client = this;
node->set_ble_client_parent(this);
this->nodes_.push_back(node);
}
@@ -73,10 +46,57 @@ class BLEClient final : public BLEClientBase {
void set_state(espbt::ClientState state) override;
#ifdef USE_BLE_CLIENT_GATT_NODES
// ---- the neutral node surface (signatures shared with the non-esp32
// engine, so nodes on the neutral interface compile against either) ----
void register_gatt_node(BLEClientNode *node);
bool idle() const { return this->state() == espbt::ClientState::IDLE; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_characteristic(uint16_t handle);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
/// Local registration only; per the neutral contract the CCCD write is the
/// node's job (the legacy auto-CCCD is suppressed for these handles).
int notify_characteristic(uint16_t handle, bool enable);
// pair() comes from BLEClientBase, matching the neutral engine's.
int unpair();
#endif
protected:
bool all_nodes_established_();
void maybe_release_services_();
#ifdef USE_BLE_CLIENT_GATT_NODES
int check_and_log_error_(const char *operation, esp_err_t err);
int find_pending_gatt_reg_(uint16_t handle) const;
void notify_state_to_gatt_nodes_(uint16_t handle, bool enabled, int error);
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
// False = failed discovery: the link comes down and the caller suppresses
// the legacy fan-out.
bool handle_gatt_search_cmpl_(esp_gatt_status_t status);
bool take_pending_gatt_reg_(uint16_t handle);
void on_disconnect_complete(esp_err_t reason) override;
#endif
std::vector<BLEClientNode *> nodes_;
#ifdef USE_BLE_CLIENT_GATT_NODES
// Raise if a migrated node needs more concurrent registrations.
static constexpr uint8_t MAX_PENDING_NOTIFY_REGS = 4;
// Nodes on the neutral surface; fed the translated callbacks and
// auto-established after the on_connected fan-out. Every gatt node is
// also in nodes_ (registration pushes into both).
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
bool has_legacy_nodes_() const { return this->nodes_.size() > this->gatt_nodes_.size(); }
// Reconnect backoff after materializer failures.
ConnectBackoff gatt_backoff_;
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
uint16_t pending_gatt_regs_[MAX_PENDING_NOTIFY_REGS];
uint8_t pending_gatt_reg_count_{0};
// on_connected fan-out started; on_disconnected is owed at teardown.
bool gatt_connected_{false};
#endif
};
} // namespace esphome::ble_client
@@ -0,0 +1,249 @@
#include "ble_client_gatt.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
static const char *const TAG = "ble_client";
void BLEClient::register_ble_node(BLEClientNode *node) {
node->set_ble_client_parent(this);
if (this->nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
ESP_LOGE(TAG, "[%s] Node capacity exceeded; node dropped", this->address_str_);
this->status_set_error(LOG_STR("node capacity exceeded"));
return;
}
this->nodes_.push_back(node);
}
void BLEClient::set_address(uint64_t address) {
this->address_ = address;
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BLEClient::set_enabled(bool enabled) {
if (enabled == this->enabled)
return;
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
this->enabled = enabled;
if (!enabled) {
this->disconnect();
return;
}
// A re-enable clears the backoff; the next sighting connects (legacy
// parity: enabling does not itself connect).
this->backoff_.reset();
}
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
// The sighting is the source of truth for the address type.
this->address_type_ = device.get_address_type();
this->address_type_known_ = true;
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
return true;
if (this->backoff_.holding_off())
return true;
this->attempt_connect_();
return true;
}
void BLEClient::connect() {
if (this->state_ != State::IDLE) {
ESP_LOGD(TAG, "[%s] Connect requested while busy, ignoring", this->address_str_);
return;
}
// An absent peer can inhibit scanning for the backend's full connect
// timeout, so this is worth a breadcrumb - but it is a supported action.
ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_);
if (!this->address_type_known_) {
// Legacy parity: without a sighting the address type defaults to
// public, which never matches a random-static peer.
ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_);
}
this->attempt_connect_();
}
void BLEClient::attempt_connect_() {
int err = this->backend_->connect(this->address_, this->address_type_);
if (err != 0) {
// A refused connect never produces a callback: stay idle, charge the
// backoff, and resolve any waiting connect action through the failure
// path so its chain terminates.
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
this->backoff_.register_failure(this->address_str_);
this->defer([this]() { this->connect_failed_callbacks_.call(); });
return;
}
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
this->state_ = State::CONNECTING;
}
void BLEClient::disconnect() {
if (this->state_ == State::IDLE) {
ESP_LOGD(TAG, "[%s] Disconnect requested while idle, ignoring", this->address_str_);
return;
}
// A deliberate teardown's failure report must not feed the backoff.
this->cancel_requested_ = true;
int err = this->backend_->gatt_disconnect();
if (err != 0) {
// Refused synchronously: backend and client disagree about the link
// state. Warn, then settle through the deliberate-cancel path.
ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err);
this->on_connection_state(false, 0, err);
}
}
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected) {
this->state_ = State::DISCOVERING;
int discover_err = this->backend_->discover_services();
if (discover_err != 0) {
// Synchronous refusal: no discovery completion will follow.
ESP_LOGW(TAG, "[%s] Service discovery refused, err=%d", this->address_str_, discover_err);
this->backoff_.register_failure(this->address_str_);
// Deliberate teardown: its report must not charge the backoff again.
this->disconnect();
}
return;
}
bool was_connected = this->state_ == State::CONNECTED;
bool cancelled = this->cancel_requested_;
this->cancel_requested_ = false;
this->state_ = State::IDLE;
if (was_connected) {
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
for (auto *node : this->nodes_) {
node->on_disconnected();
}
// Continuations leave the backend's event-drain stack first.
this->defer([this]() { this->disconnect_callbacks_.call(); });
} else {
if (cancelled) {
// status carries the refusal code when the teardown settled
// synchronously; 0 on a backend-completed cancel.
ESP_LOGD(TAG, "[%s] Connect attempt cancelled, status=%d", this->address_str_, error);
} else {
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
this->backoff_.register_failure(this->address_str_);
}
this->defer([this]() { this->connect_failed_callbacks_.call(); });
}
}
void BLEClient::on_service_discovery_done(int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
this->backoff_.register_failure(this->address_str_);
// The teardown is deliberate: do not charge the backoff again for its
// connection report.
this->disconnect();
return;
}
ble_device_base::GattServiceTable table{};
if (!this->nodes_.empty()) {
// Materialize only when a node will read it: a client with no nodes
// would pay the build/free cycle on every (re)connect for nothing.
table = this->backend_->get_service_table();
if (table.service_count == 0) {
// A failed materialization is indistinguishable from a service-less
// peer, and a real GATT peer always exposes at least GAP/GATT: fail
// the discovery before CONNECTED so the teardown resolves through
// connect_failed, never a spurious on_disconnect.
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str_);
this->backend_->release_services();
this->backoff_.register_failure(this->address_str_);
this->disconnect();
return;
}
}
// CONNECTED before the fan-out so nodes may consult connected() from
// their own on_connected().
this->state_ = State::CONNECTED;
for (auto *node : this->nodes_) {
node->on_connected(table);
if (this->state_ != State::CONNECTED || this->cancel_requested_) {
// A node tore the link down mid-fan-out: on_disconnect fires with no
// preceding on_connect, so leave a trace of why.
ESP_LOGW(TAG, "[%s] A node aborted the connection during setup", this->address_str_);
this->backend_->release_services();
return;
}
}
this->backend_->release_services();
this->backoff_.reset();
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
this->defer([this]() { this->connect_callbacks_.call(); });
}
void BLEClient::on_write_result(uint16_t handle, int error) {
if (error != 0) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Write on handle 0x%04x completed with status %d", this->address_str_, handle, error);
}
for (auto *node : this->nodes_) {
node->on_write_result(handle, error);
}
}
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
if (error != 0) {
// Breadcrumb even when no node claims the handle.
ESP_LOGD(TAG, "[%s] Read on handle 0x%04x completed with status %d", this->address_str_, handle, error);
}
for (auto *node : this->nodes_) {
node->on_read_result(handle, data, len, error);
}
}
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
// Every node sees every notification and filters by handle (legacy parity).
for (auto *node : this->nodes_) {
node->on_notify(handle, data, len);
}
}
void BLEClient::on_notify_state(uint16_t handle, bool enabled, int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Notify %s on handle 0x%04x failed, status=%d", this->address_str_,
enabled ? "enable" : "disable", handle, error);
}
for (auto *node : this->nodes_) {
node->on_notify_state(handle, enabled, error);
}
}
void BLEClient::on_pairing_result(int status) {
if (status != 0) {
ESP_LOGW(TAG, "[%s] Pairing failed, status=%d", this->address_str_, status);
} else {
ESP_LOGI(TAG, "[%s] Paired", this->address_str_);
}
for (auto *node : this->nodes_) {
node->on_pairing_result(status);
}
}
void BLEClient::dump_config() {
ESP_LOGCONFIG(TAG,
"BLE Client:\n"
" Address: %s\n"
" Auto connect: %s",
this->address_str_, YESNO(this->auto_connect_));
if (this->enabled && this->state_ == State::IDLE) {
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
}
}
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
@@ -0,0 +1,149 @@
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
//
// Compiled on every platform with a GATT backend except esp32, which keeps
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
// family migrates - the exclusive gates make the same class names resolve to
// exactly one definition per build, so codegen is shared.
//
// Connects are sighting-gated like the legacy engine: the client is a parsed
// advertisement listener, captures the peer's address type from the sighting,
// and asks the backend to connect only when enabled and idle.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_BLE_CLIENT_LEGACY_ENGINE)
#include "ble_client_node.h"
#include "connect_backoff.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#include <functional>
namespace esphome::ble_client {
class BLEClient : public Component,
public ble_device_base::ESPBTDeviceListener,
public ble_device_base::GattClientListener {
public:
void dump_config() override;
// Public field for legacy parity (the switch platform republishes it).
bool enabled{true};
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
}
void set_address(uint64_t address);
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
void set_enabled(bool enabled);
const char *address_str() const { return this->address_str_; }
void register_ble_node(BLEClientNode *node);
// One registration spelling shared with the esp32 engine's bridge.
void register_gatt_node(BLEClientNode *node) { this->register_ble_node(node); }
bool connected() const { return this->state_ == State::CONNECTED; }
bool idle() const { return this->state_ == State::IDLE; }
/// Action-initiated connect (no sighting needed; uses the last captured
/// address type, public until a sighting arrives). No-op unless idle.
void connect();
void disconnect();
/// Legacy-named deferral used by the automation twins: neutral listener
/// callbacks run inside the backend's event drain, so automation chain
/// continuations must leave that stack first.
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
// Backend ops for nodes and actions - the frozen node-facing surface.
// Only write_characteristic has an in-tree caller; subscribing means
// notify_characteristic plus a CCCD write_descriptor (the caller's job
// per the contract).
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return this->backend_->write_characteristic(handle, data, len, response);
}
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
int read_descriptor(uint16_t handle) { return this->backend_->read_descriptor(handle); }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return this->backend_->write_descriptor(handle, data, len);
}
int notify_characteristic(uint16_t handle, bool enable) {
return this->backend_->notify_characteristic(handle, enable);
}
int pair() { return this->backend_->pair(); }
int unpair() { return bluetooth_connection::unpair_device(this->address_); }
// Automation callback registration.
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callbacks_.add(std::forward<F>(callback));
}
// Fired when a connect attempt dies before being established; the user
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
template<typename F> void add_on_connect_failed_callback(F &&callback) {
this->connect_failed_callbacks_.add(std::forward<F>(callback));
}
// ---- ble_device_base::ESPBTDeviceListener ----
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
// ---- ble_device_base::GattClientListener ----
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_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_pairing_result(int status) override;
protected:
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
void attempt_connect_();
// Group 1: pointers / containers
ble_device_base::BLEGattConnection *backend_{nullptr};
// Codegen-sized (ESPHOME_BLE_CLIENT_MAX_NODES); filled during setup.
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> nodes_;
// Group 2: 8-byte types
uint64_t address_{0};
// Group 3: callback managers (pointer-sized when empty)
LazyCallbackManager<void()> connect_callbacks_;
LazyCallbackManager<void()> disconnect_callbacks_;
LazyCallbackManager<void()> connect_failed_callbacks_;
// Group 4: 4-byte types
// Backoff so an undiscoverable database or a dead peer cannot produce a
// battery-draining connect loop.
ConnectBackoff backoff_;
// Group 5: arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
// Group 6: 1-byte types
State state_{State::IDLE};
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
// Distinguishes a captured public type from the never-sighted default.
bool address_type_known_{false};
bool auto_connect_{true};
// A user-initiated teardown in flight; its failure report is not a
// connect failure and must not feed the backoff.
bool cancel_requested_{false};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_BLE_CLIENT_LEGACY_ENGINE
@@ -0,0 +1,68 @@
// The single BLEClientNode both ble_client engines share. The neutral
// callback surface is the one interface node components build on; the raw
// esp32 surface below it remains for components that have not migrated yet.
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#endif
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
#endif
#include <cstdint>
namespace esphome::ble_client {
class BLEClient;
class BLEClientNode {
public:
#ifdef USE_BLE_CLIENT_GATT_NODES
// Neutral surface, delivered by both engines. The table is borrowed: copy
// handles during on_connected(). All nodes see all completions; filter by
// handle.
// A node that disconnects from inside on_connected() aborts the fan-out;
// the user's on_disconnect may then fire without a preceding on_connect.
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
virtual void on_disconnected() {}
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
// One in-flight registration per handle; its completion fans out to every
// node, so a refused duplicate request still sees on_notify_state.
virtual void on_notify_state(uint16_t handle, bool enabled, 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_pairing_result(int status) {}
#endif
#ifdef USE_BLE_CLIENT_LEGACY_ENGINE
// Legacy raw surface; components overriding these need the legacy engine
// until migrated to the neutral surface above.
virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {}
virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {}
virtual void loop() {}
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
esp32_ble_tracker::ClientState node_state;
#endif
BLEClient *parent() const { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_{nullptr};
};
} // namespace esphome::ble_client
@@ -0,0 +1,168 @@
// The ble_client.ble_write action: a node on the platform-neutral interface,
// so one implementation serves both engines (the esp32 bridge and the
// neutral engine).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include <tuple>
#include <vector>
// One of the two engine headers resolves per build.
#include "ble_client.h"
#include "ble_client_gatt.h"
#include "ble_client_node.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
static const char *const BLE_WRITE_TAG = "ble_client.automation";
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_gatt_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
result = this->write(this->value_.data, this->len_);
} else {
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value.data(), value.size());
}
// on write failure, continue the automation chain rather than stopping so
// that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
// Initiate the write; the completion arrives in on_write_result. The
// response-less path can complete synchronously inside the call, so the
// handle is armed before the backend is touched.
bool write(const uint8_t *data, size_t len) {
if (!this->ble_client_->connected()) {
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
if (!this->resolved_) {
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
if (err != 0) {
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
return false;
}
return true;
}
void on_connected(const ble_device_base::GattServiceTable &table) override {
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
const auto *chr =
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
if (chr == nullptr) {
char char_buf[ble_device_base::UUID_STR_LEN];
char service_buf[ble_device_base::UUID_STR_LEN];
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
this->service_uuid_.to_str(service_buf));
return;
}
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
this->write_response_ = true;
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
this->write_response_ = false;
} else {
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
return;
}
this->char_handle_ = chr->value_handle;
this->resolved_ = true;
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
this->ble_client_->address_str());
}
void on_disconnected() override {
this->resolved_ = false;
this->char_handle_ = 0;
if (this->num_running_ != 0)
this->stop_complex();
}
void on_write_result(uint16_t handle, int error) override {
if (this->num_running_ == 0) {
return;
}
if (!this->resolved_ || handle != this->char_handle_) {
// A parked chain waiting on a completion that never matches would
// otherwise stall silently until disconnect.
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
this->char_handle_);
return;
}
if (error != 0) {
// Continue the chain (legacy parity) but leave a breadcrumb.
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
}
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
ble_device_base::ESPBTUUID service_uuid_;
ble_device_base::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
bool write_response_{false};
bool resolved_{false};
};
} // namespace esphome::ble_client
#endif // USE_BLE_CLIENT_GATT_NODES
@@ -0,0 +1,43 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cstdint>
namespace esphome::ble_client {
/// Reconnect backoff after repeated connect/discovery failures, shared by
/// both engines. 256 ms ticks in a uint16_t keep it 4 bytes; the ~4.7 h tick
/// wrap can at worst reinstate one stale hold-off of a minute.
class ConnectBackoff {
public:
bool holding_off() const {
return this->failures_ != 0 && static_cast<uint16_t>(now() - this->start_) < this->failures_ * STEP_TICKS;
}
void register_failure(const char *address_str) {
if (this->failures_ < MAX_STEPS)
this->failures_++;
this->start_ = now();
esph_log_w("ble_client", "[%s] Holding off reconnect for %u s", address_str, this->failures_ * 10u);
}
void reset() { this->failures_ = 0; }
private:
// ~10 s per consecutive failure, capped so a flapping peer retries within
// a minute at worst.
static constexpr uint16_t STEP_TICKS = 40; // x 256 ms
static constexpr uint8_t MAX_STEPS = 6;
static uint16_t now() { return static_cast<uint16_t>(millis() >> 8); }
uint16_t start_{0};
uint8_t failures_{0};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT
+162 -26
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, TimePeriod
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,22 @@ 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 compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT;
distinct from the proxy's validated connection budget). Called by
bluetooth_connection.new_gatt_backend() once per backend instance."""
cg.add_define("USE_BLE_GATT_CLIENT")
_request_gatt_connection_slot()
_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
@@ -136,28 +243,35 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
return config
# The historical scan window default shared by the trackers that do not pin
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
supports_active: bool = False,
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
bk72xx/rp2 100/30 ms the reference scan rates of the respective stacks;
LN882H's SDK recommends 100/50 ms). Pass supports_active=True only when
the tracker supports active scanning; it exposes the `active` option
(whose own default is on, esp32_ble_tracker behavior).
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
callable evaluated per validation when the user omits the key (esp32 uses
this to record that the window was defaulted, so a later validation step
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract every current proxy client assumes it, so a passive-only
tracker must not share this schema.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period,
cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period,
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
}
if supports_active:
schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean
return cv.All(cv.Schema(schema), validate_scan_parameters)
@@ -217,3 +331,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,81 @@
// 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;
/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency,
/// e.g. a service table failing its own bounds checks.
static constexpr int GATT_ERR_UNLIKELY = 0x0E;
/// 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;
/// ATT MTU before negotiation completes (Bluetooth spec default).
static constexpr uint16_t DEFAULT_ATT_MTU = 23;
// 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"
@@ -136,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
// BLEScanResult's bda is most-significant octet first; the neutral ingest
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
// address_str_to() then produce exactly the historical esp32 values.
uint8_t mac_lsb_first[6];
uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
for (uint8_t i = 0; i < 6; i++)
mac_lsb_first[i] = scan_result.bda[5 - i];
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
@@ -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
+32 -18
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);
uint8_t address_[6]{0};
// 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_[MAC_ADDRESS_SIZE]{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,48 @@
#include "ble_gatt_client.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/core/log.h"
namespace esphome::ble_device_base {
static const char *const TAG = "ble_gatt_client";
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
// 32-bit range math: a corrupt first/count pair cannot wrap past the check.
uint32_t end = uint32_t(service.first_characteristic) + service.characteristic_count;
if (end > table.characteristic_count) {
ESP_LOGW(TAG, "characteristic range out of bounds");
return nullptr;
}
for (uint32_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
const ESPBTUUID &uuid) {
uint32_t end = uint32_t(characteristic.first_descriptor) + characteristic.descriptor_count;
if (end > table.descriptor_count) {
// Corrupt range, not a missing descriptor.
ESP_LOGW(TAG, "descriptor range out of bounds");
return nullptr;
}
for (uint32_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == uuid)
return &table.descriptors[i];
}
return nullptr;
}
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
const GattDescriptor *desc = find_descriptor(table, characteristic, ESPBTUUID::from_uint16(CCCD_UUID));
return desc != nullptr ? desc->handle : 0;
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -0,0 +1,195 @@
// 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 - the hub wrapper streaming the raw database, or a direct
// consumer owning a dedicated backend and resolving handles by UUID -
// drives it and receives completions through the GattClientListener
// interface. 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, so the
// values are wire-frozen - API clients interpret them):
// 0 success
// 1..0x11 ATT error codes (Bluetooth spec) - reserved; a backend whose
// native error codes land in this window must remap them out
// 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
// -1..-15 reserved for future contract sentinels
// 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 - the one place
/// with genuine runtime polymorphism (several consumer types, one non-virtual
/// backend). Methods default to no-ops; consumers override what they consume.
/// No destructor: components are never destroyed.
/// on_connection_state carries the negotiated MTU and an HCI status/reason.
/// Codegen wires the listener before setup(), so backends skip null checks.
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. An accepted
// operation's completion is delivered from the event loop, NEVER
// synchronously from inside the op call - a synchronous terminal
// on_connection_state from within gatt_disconnect() would re-enter the
// consumer mid-teardown. Semantics beyond the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// Returning 0 means the request is accepted, not that the radio acted: the
// backend owns integration with its platform's scan/connect arbitration
// (Bluedroid parks the request for the tracker's promote loop, which owns
// scan-stop/coex/one-connect-at-a-time; the rp2 backend opens immediately
// and relies on sighting-gated consumers). Consumers must not assume
// connect timing.
// - gatt_disconnect: also cancels a connect in progress (named to coexist
// with a platform stack's own void disconnect() on one backend class).
// Nonzero means nothing to tear down and no completion will follow; an
// accepted teardown (0) always reaches a terminal on_connection_state.
// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not
// started closing - the in-flight connect resumes and completes normally.
// False once the teardown owns the link (or nothing was scheduled).
// - 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 gatt_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 and without 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.gatt_disconnect() } -> std::same_as<int>;
{ conn.cancel_gatt_disconnect() } -> std::same_as<bool>;
{ 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;
// Characteristic property bits (the Bluetooth-spec declaration byte carried
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
// exist on the other platforms).
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
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;
}
const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid);
const GattDescriptor *find_descriptor(const GattServiceTable &table, const GattCharacteristic &characteristic,
const ESPBTUUID &uuid);
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic);
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
+66 -44
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,45 +77,46 @@ 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
/// support active scanning yet still refuse the runtime switch
/// (esp32_ble_tracker drives its mode through its own tracker API).
/// a passive-only controller can never switch, and a hub may support
/// active scanning yet still refuse the runtime switch (esp32_ble_tracker
/// drives its mode through its own tracker API).
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
@@ -0,0 +1,153 @@
#include "scan_response_merger.h"
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "esphome/core/helpers.h"
#include <cstring>
namespace esphome::ble_device_base {
void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, bool raw_only) {
// A partial bind is treated as unbound; never dereference half a binding.
if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr)
return;
this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only,
*this->scan_continuous_ ? nullptr : this->log_tag_);
}
void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len, uint32_t now) {
// One pass: find a same-device entry (deliver + reuse) while remembering the
// first free slot as the fallback.
PendingAdv *slot = nullptr;
PendingAdv *free_slot = nullptr;
for (auto &p : this->pending_adv_) {
if (!p.used) {
if (free_slot == nullptr)
free_slot = &p;
continue;
}
if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) {
// Same device advertised again before its scan response arrived — deliver
// the previous advertisement (its scan response is not coming) and reuse
// the slot, so no frame is ever lost.
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
slot = &p;
break;
}
}
if (slot == nullptr)
slot = free_slot;
if (slot == nullptr) {
// Table full — degrade gracefully: deliver the advertisement unmerged.
this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false);
return;
}
slot->used = true;
this->pending_count_++;
memcpy(slot->mac, mac, MAC_ADDRESS_SIZE);
slot->addr_type = addr_type;
slot->rssi = rssi;
slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data);
memcpy(slot->data, data, slot->data_len);
slot->stored_ms = now;
}
void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint8_t data_len) {
// Fast-out on the empty table (sweep/flush use the same guard); this is the
// hottest caller.
if (this->pending_count_ != 0) {
for (auto &p : this->pending_adv_) {
if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) {
// Append in place: the slot is released on delivery, so its 62-byte
// buffer (legacy adv + scan response) holds the merged frame directly.
const uint8_t room = sizeof(p.data) - p.data_len;
const uint8_t add = (data_len <= room) ? data_len : room;
memcpy(p.data + p.data_len, data, add);
p.used = false;
this->pending_count_--;
// The advertisement's RSSI, not the scan response's (header contract).
this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false);
return;
}
}
}
// Unmatched scan-response: goes out on the raw callback only (HA merges per
// address); local listeners/triggers receive each advertisement exactly once
// via the merged/plain path above.
this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true);
}
void ScanResponseMerger::sweep(uint32_t now) {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) {
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
void ScanResponseMerger::flush() {
if (this->pending_count_ == 0)
return;
for (auto &p : this->pending_adv_) {
if (p.used) {
p.used = false;
this->pending_count_--;
this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false);
}
}
}
void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only, const char *log_unclaimed_tag) {
// Raw callback (the raw-advertisement path). Both full advertisements and
// unmatched scan responses (raw_only) are forwarded.
if (this->raw_callback_.is_set()) {
const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac),
.data = data,
.data_len = data_len,
.rssi = rssi,
.addr_type = addr_type};
this->raw_callback_.invoke(adv);
}
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Scan-response-only frames are never parsed for local sensors/triggers.
if (raw_only)
return;
ESPBTDevice device;
device.from_scan_result(mac, rssi, addr_type, data, data_len);
// The listener list holds sensors AND the tracker's automation triggers
// (the triggers are listeners, exactly like esp32_ble_tracker), so one
// loop feeds both and ORs into `found`.
bool found = false;
for (auto *listener : this->listeners_) {
if (listener->parse_device(device)) {
found = true;
}
}
if (!found && log_unclaimed_tag != nullptr)
this->discovered_log_.log_device(log_unclaimed_tag, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
}
void AdvDispatcher::on_scan_end() {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
}
} // namespace esphome::ble_device_base
#endif // USE_BLE_SCAN_RESPONSE_MERGER
@@ -0,0 +1,152 @@
// Shared support for trackers whose controller delivers advertisement and
// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates
// both into one result before ESPHome sees it):
//
// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is
// held briefly, its scan response is appended on arrival and the pair is
// delivered as ONE merged frame. Merged delivery is what the receiving side
// is built around: Home Assistant keeps the latest raw frame per device and
// skips re-parsing when it is unchanged — split delivery alternates two raw
// frames per device and defeats both.
//
// AdvDispatcher — the delivery half every such tracker repeats: raw
// callback, listener parsing, discovered-device log. Trackers delegate
// their BLEHub register_listener / set_raw_advertisement_callback here.
//
// The merger delivers straight into the tracker's AdvDispatcher — bind() wires
// the pair once in setup(). Single-task use only (every tracker calls this on
// the ESPHome main task). The clock is caller-provided: pass the same clock to
// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(),
// never mixed).
#pragma once
#include "esphome/core/defines.h"
// Emitted (cg.add_define) by each tracker that adopts the merger, so builds
// whose tracker merges in-stack (esp32) never compile this code.
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/helpers.h"
#include <cstdint>
namespace esphome::ble_device_base {
/// The delivery half of a split-report tracker, shared so the dispatch
/// contract (raw-callback ordering, raw_only gate, discovered-log policy)
/// lives in one place. Owns the members every tracker otherwise duplicates;
/// the tracker's BLEHub methods delegate here.
class AdvDispatcher {
public:
void register_listener(ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; }
/// Dispatch one (possibly merged) advertisement: the raw callback, and —
/// unless raw_only — parsing for listeners/triggers. raw_only marks
/// unmatched scan-response frames: forwarded on the raw callback only, never
/// parsed for local sensors/triggers (Home Assistant merges per address).
/// log_unclaimed_tag: when non-null, a device no listener claimed is logged
/// under this tag (esp32_ble_tracker parity: pass the tracker TAG on
/// one-shot scans, nullptr on continuous scans, which would spam).
void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only, const char *log_unclaimed_tag);
/// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup.
void on_scan_end();
protected:
RawAdvertisementCallback raw_callback_{};
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// Parsed-advertisement consumers registered through ble_device_base.
// Codegen-sized: no heap allocation, no std::vector template instantiations.
StaticVector<ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
// Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only
// writer so a no-listener build does not carry an unused vector.
DiscoveredDeviceLog discovered_log_{};
#endif
};
class ScanResponseMerger {
public:
/// Wire the merger's output; call once in the tracker's setup(). Every
/// delivered frame goes to dispatcher->dispatch(); scan_continuous is read
/// at each delivery (runtime continuous flips are honored) to decide the
/// unclaimed-device log tag, so both pointers must outlive the merger —
/// tracker members always do.
void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) {
this->dispatcher_ = dispatcher;
this->scan_continuous_ = scan_continuous;
this->log_tag_ = log_tag;
}
/// Hold a scannable advertisement, waiting for its scan response. The
/// tracker calls this only when it wants the merge (scannable advertisement
/// while an active scan runs) and delivers everything else directly. A
/// same-device re-advertisement delivers the held frame (its scan response
/// is not coming) and reuses the slot; a full table degrades gracefully to
/// unmerged delivery.
void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
uint32_t now);
/// A scan response arrived: append it to the held advertisement from the
/// same device and deliver the pair as one frame. The merged frame reports
/// the ADVERTISEMENT's RSSI — every unmerged path reports the
/// advertisement's measurement, so a device's RSSI must not jump between two
/// measurements depending on merge timing. Unmatched responses are delivered
/// raw_only.
void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len);
/// Timeout flush (call from loop() with the stash_adv() clock): deliver
/// held advertisements whose scan response never arrived (device didn't
/// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS.
void sweep(uint32_t now);
/// Deliver every held advertisement now (scan period/scan is ending, before
/// on_scan_end fires): unmerged delivery, same as the timeout path.
void flush();
/// Lets loop() skip the cross-TU sweep() call in the common case (empty:
/// passive scan, or every pair already matched).
bool empty() const { return this->pending_count_ == 0; }
private:
/// All delivery funnels through here: an unbound merger (bind() not called)
/// drops the frame instead of jumping through a null pointer, mirroring the
/// guard-before-invoke convention of the ble_hub.h callback slots.
void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len,
bool raw_only);
// 62 bytes = legacy adv (31) + scan response (31), the same merged maximum
// as ESP-IDF delivers on ESP32.
struct PendingAdv {
bool used{false};
uint8_t mac[MAC_ADDRESS_SIZE];
uint8_t addr_type;
int8_t rssi;
uint8_t data_len; // <= sizeof(data)
uint8_t data[62];
uint32_t stored_ms;
};
// Sized for the unanswered case: a pair that IS answered normally matches
// within one report-queue drain, so a slot is held for the full timeout only
// by scannable devices that never reply. 8 concurrent such advertisers
// before the merge degrades (frames still delivered, just unmerged) at
// ~80 B each.
static constexpr size_t MAX_PENDING_ADV = 8;
// On air a scan response follows its advertisement by T_IFS (150 µs) — the
// timeout only covers HOST-side report queuing under WiFi/BLE coexistence,
// measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin,
// while staying below any device's re-advertising period.
static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300;
AdvDispatcher *dispatcher_{nullptr};
const bool *scan_continuous_{nullptr}; // read at delivery; see bind()
const char *log_tag_{nullptr};
// pending_count_ mirrors the number of set `used` flags; both are updated
// together on every transition.
PendingAdv pending_adv_[MAX_PENDING_ADV];
uint8_t pending_count_{0};
};
} // namespace esphome::ble_device_base
#endif // USE_BLE_SCAN_RESPONSE_MERGER
@@ -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,290 @@
"""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 the neutral ble_client through gatt_client_schema() +
new_gatt_backend().
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components import rp2040_ble
from esphome.config_helpers import (
filter_source_files_from_platform,
frameworks_for_platforms,
)
import esphome.config_validation as cv
from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
registers with (the Bluedroid header includes the tracker's), 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 and
# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's
# btstack_memory.cpp replaces those pools via linker --wrap (requested by
# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself
# belongs to the platform stack that owns the pools.
RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS
# Slot limits for the hub platforms running the connection-capable proxy;
# the backend registry itself is _PLATFORM_BACKENDS below.
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"
DOMAIN = "bluetooth_connection"
@dataclass
class _ConnectionData:
rp2_backend_count: int = 0
# GATT connection slots claimed this run, for the platform cap check.
slot_consumers: list[str] = field(default_factory=list)
def _get_data() -> _ConnectionData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = _ConnectionData()
return CORE.data[DOMAIN]
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:
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 registers
# as a raw client (it is the tracker's ESPBTClient).
await esp32_ble_tracker.register_raw_client(backend, config)
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
from esphome.components import ota
# The backend drops its link when an OTA starts (esp32 tracker parity).
ota.request_ota_state_listeners()
# More than one backend outgrows the prebuilt BTstack pools: swap them for
# the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's
# btstack_memory.cpp. Keyed to backend registrations (the same event that
# grows the count that sizes the pools), so single-backend builds emit no
# flags and stay byte-identical to previous releases.
data = _get_data()
data.rp2_backend_count += 1
if data.rp2_backend_count == 2:
rp2040_ble.add_btstack_pool_overrides()
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, and stack
registration. The esp32 fragments import their stack lazily because those
imports register esp32-only automations as a side effect; rp2040_ble is
side-effect-free, so it is imported at module scope (the cap constant
needs it there anyway)."""
backend_class: cg.MockObjClass
schema_fragment: Callable[[], cv.Schema]
register: Callable[[cg.MockObj, ConfigType], Awaitable[None]]
# Selects the backend's alias-ladder arm (order-independent arms).
define: str
# The backend's on-demand materializer gate, when it has one.
materializer_define: str | None = 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,
"USE_BLE_GATT_BACKEND_BLUEDROID",
materializer_define="USE_BLUEDROID_GATT_SERVICE_TABLE",
),
PLATFORM_RP2: _PlatformBackend(
RP2GattClient, _rp2_schema_fragment, _rp2_register, "USE_BLE_GATT_BACKEND_RP2"
),
}
# 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, plus the component keys (setup_priority and
friends now apply to the backend, the slot's real Component). Same
platform rules as gatt_client_schema()."""
return (
gatt_client_schema(platform)
.extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)})
.extend(cv.COMPONENT_SCHEMA)
)
def consume_gatt_slot(
consumer: str, count: int = 1
) -> Callable[[ConfigType], ConfigType]:
"""Validator claiming GATT connection slots - the one spelling for every
claimant. Platforms whose BLE stack owns a connection budget (esp32, rp2)
are charged there and their stack's final validation reports an
overcommit; the neutral ledger covers any future backend platform without
one (the cap check in FINAL_VALIDATE_SCHEMA)."""
def validator(config: ConfigType) -> ConfigType:
_get_data().slot_consumers.extend([consumer] * count)
if CORE.is_esp32:
from esphome.components import esp32_ble
esp32_ble.consume_connection_slots(count, consumer)(config)
elif CORE.target_platform == PLATFORM_RP2:
rp2040_ble.consume_connection_slots(count, consumer)(config)
return config
return validator
# Platforms whose BLE stack owns its own connection budget: consume_gatt_slot
# charges it there, and the stack's final validation is the one place an
# overcommit is reported (never two messages for one misconfiguration).
_STACK_BUDGET_PLATFORMS = {PLATFORM_ESP32, PLATFORM_RP2}
def _validate_slot_totals(config: ConfigType) -> ConfigType:
# Skipped in testing mode so grouped component builds can co-exist
# (mirrors esp32_ble.validate_connection_slots).
if CORE.testing_mode:
return config
if CORE.target_platform in _STACK_BUDGET_PLATFORMS:
return config
if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None:
# Any backend platform without a stack budget must carry a cap here
# or fail loudly, never fail open.
if CORE.target_platform in _PLATFORM_BACKENDS:
raise cv.Invalid(
f"{CORE.target_platform} has a GATT backend but no slot cap "
"in HUB_MAX_CONNECTIONS"
)
return config
claimed = _get_data().slot_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
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
(the consume_gatt_slot validators), not here.
service_table is honored by the Bluedroid backend only: forward
scaffolding for the first esp32 direct consumer, load-bearing on no
current build (rp2 ignores the define and always materializes - its
proxy hub streams through get_service_table(), so it must keep the
materializer regardless of the flag).
"""
from esphome.components import ble_device_base
entry = _backend_entry()
ble_device_base.request_gatt_client()
cg.add_define(entry.define)
if service_table and entry.materializer_define is not None:
cg.add_define(entry.materializer_define)
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
# The backend is the slot's real Component: component keys from the
# connection entry (setup_priority, ...) apply to it. Consumers whose own
# schema carries keys that register_component would misapply to the
# backend (e.g. a polling interval) must not put them in this config.
await cg.register_component(backend, config)
await entry.register(backend, config)
return backend
# Named so tests can pin the hub entry against bluetooth_proxy's platform
# list (this module cannot import bluetooth_proxy to derive it).
SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = {
"bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
"gatt_service_table_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]),
# 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.BK72XX_ARDUINO,
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
"bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
}
FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS)
@@ -0,0 +1,68 @@
#include "bluetooth_connection.h"
#ifdef USE_ESP32
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#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 // USE_BLUETOOTH_PROXY_CONNECTIONS
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
namespace esphome::bluetooth_connection {
// Address-scoped Bluedroid maintenance. Gated with the connection surface:
// the advertisement-only arm no longer dispatches these requests at all.
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_BLE && USE_BLE_GATT_CLIENT

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