Compare commits

..
Author SHA1 Message Date
kbx81 890f0408a5 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-08-06 23:27:44 -05: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
J. Nick KostonandGitHub 2dfcde477f [ble_client] Reject descriptor_uuid combined with notify at validation time (#18109) 2026-08-05 19:09:45 -05:00
J. Nick KostonandGitHub f971e404c7 [core] Resolve GCC multilib include dirs for clang-tidy (#18111) 2026-08-05 19:09:13 -05:00
J. Nick KostonandGitHub 524b278811 [core] Defer stdlib imports out of the upload and logs fast path (#18105) 2026-08-05 19:09:00 -05:00
J. Nick KostonandGitHub 7f80276cf4 [ble_device_base] Document the None output for unset UUIDs in to_str() (#18108) 2026-08-05 23:02:13 +00:00
J. Nick KostonandGitHub 26256d3d2e [ble_device_base] [ble_client] Restore unset UUID state so notify sensors set up again (#18098) 2026-08-05 22:21:24 +00:00
Edvard FilistovičandGitHub 1bd94bb805 [bluetooth_proxy] Scanner-state sync in set_mode; platform-gate tests (#18100) 2026-08-05 21:41:07 +00:00
J. Nick KostonandGitHub 28aedc2b14 [core] Drop the duplicated FileLock import from a merge collision (#18103) 2026-08-05 16:08:06 -05:00
J. Nick KostonandGitHub b700193a0a [core] Generalize the resolver's async thread dispatch into run_async (#18088) 2026-08-05 20:57:08 +00:00
J. Nick KostonandGitHub 4fd2ddee55 [core] Trim the stacktrace gate commentary (#18097) 2026-08-05 20:50:48 +00:00
J. Nick KostonandGitHub a2d1f73bca [core] Defer voluptuous and bundle imports out of the upload and logs fast path (#18093) 2026-08-05 20:39:19 +00:00
Jesse Hills f2472563f4 Merge branch 'release' into dev 2026-08-06 08:09:23 +12:00
Jesse HillsandGitHub 90d6d8f5ca Merge pull request #18083 from esphome/bump-2026.7.4
2026.7.4
2026-08-06 08:08:37 +12:00
Jesse HillsandGitHub d4b3d37165 Update URL for ESPHome Builder issue reporting (#16250) 2026-08-06 08:07:30 +12:00
563b6ade0f [bluetooth_proxy] Platform-neutral advertisement proxy via ble_device_base (#17880)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-05 15:06:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
17f3c9d780 Bump CodSpeedHQ/action from 5.0.1 to 5.0.2 (#18094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 15:50:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
98622bd029 Bump esphome/workflows/.github/workflows/stale.yml from 203cea60ebfd18e2b966e57750750e0417a9feec to 61fd37a044cad4e9aa4303027b2a61b6a34da855 (#18095)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 15:50:36 -04:00
J. Nick KostonandGitHub 253ecdd145 [core] Resolve the stacktrace decoder lazily behind an address gate (#18048) 2026-08-05 14:34:16 -05:00
Keith BurzinskiGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1cc83172ab [ota] Multi-key OTA signature verification for external RSA signing (#17981)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-05 14:24:38 -05:00
b521b5e1ca [modbus_server] Add tests for U_WORD_S and S_WORD_S (#17832)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-05 14:47:48 -04:00
puddly 3e0f81bf1e Improve Zigbee/WiFi collision warning 2026-08-05 14:40:38 -04:00
puddly 6111791706 Have zigbee proxying piggyback off of serial proxying? 2026-08-05 14:40:38 -04:00
puddly 7a05749231 Classify UART traffic for arbitrary protocol passthrough 2026-08-05 14:40:38 -04:00
puddly 997e218376 Reset state more reliably 2026-08-05 14:40:38 -04:00
puddly 973da47da6 Simplify startup state machine by using direct NVRAM access 2026-08-05 14:40:38 -04:00
puddly e80aa9579b Handle more of the EZSP protocol and try to detect the bootloader 2026-08-05 14:40:38 -04:00
puddly a060db1251 Fix EZSP and ASH protocol parsing/forwarding 2026-08-05 14:39:01 -04:00
J. Nick KostonandGitHub 202b31f711 [ble_device_base] Deprecate address_str in favor of address_str_to (#18092) 2026-08-05 18:33:32 +00:00
Marek PilchandGitHub fc682dc75b [modbus_server] Support byte-swapped word types U_WORD_S and S_WORD_S (#17829) 2026-08-05 14:11:37 -04:00
Oliver KleineckeGitHubClaudepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Oliver KleineckeCopilot Autofix powered by AI
4627f07a7b [mcp4461] nonvolatile-by-default persistence, TCON boot sync, wiper actions (#17561)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-05 14:11:03 -04:00
Edvard FilistovičandGitHub 9bfce75bc5 [ln882h_ble_tracker] Use the shared ble_device_base automation layer (#18089) 2026-08-05 16:46:01 +00:00
b93b21eab1 [modbus_controller] Refactor to simplify message handling (#11781)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 11:02:56 -05:00
e31a43af17 [esp32_camera_web_server] Fix MJPEG stream ending on a stale semaphore (#18052)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-05 11:40:15 -04:00
Edvard FilistovičandGitHub 0496627d2b [bk72xx_ble_tracker] Automation triggers and actions (#17776) 2026-08-05 09:31:51 -05:00
Edvard FilistovičGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick Koston
d548454dbe [ln882h_ble_tracker] Automation triggers and actions (#17778)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-05 09:30:46 -05:00
Kevin AhrendtandGitHub 64f0e38a51 [micro_wake_word] Support adding and removing wake word models at runtime (#17927) 2026-08-05 10:24:46 -04:00
TesseractTimmeeGitHubrootpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
3964ef61f8 [zigbee] Resolution attribute for esp32 sensors (#17973)
Co-authored-by: root <root@core-ssh.local.hass.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-05 10:23:57 -04:00
2d5d34d33f [zigbee] cleanup, docstrings, refactor connected state on esp32 (1/3) (#18007)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-05 10:19:57 -04:00
J. Nick KostonandGitHub 6bcbdd79c3 [core] Lift the log line processor into esphome/stacktrace.py (#18076) 2026-08-05 08:43:14 -05:00
Pete KeenandGitHub 3d093c0ae8 [esp32_rmt_led_strip] Add RGBW channel ordering (#18028) 2026-08-05 07:57:59 -04:00
Edvard FilistovičandGitHub 450232964b [tests] Unignore component-test config fixture directories (#18084) 2026-08-04 21:58:31 -05:00
J. Nick KostonandGitHub 112d8b26ee [core] Skip source range tracking when loading the validated config cache (#18046) 2026-08-04 21:24:38 -05:00
Clyde StubbsandGitHub 27dcf64b45 [lvgl] Add pause option to round_trip animation timing (#17574) 2026-08-05 14:16:22 +12:00
97d33a5679 [lvgl] Add lvgl.widget.set_z_index action (#17993)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:14:32 +12:00
Edvard FilistovičandGitHub 0a99b007a9 [ble_device_base] Advertise runtime scan-mode switching in HubCapabilities (#18079) 2026-08-05 01:56:32 +00:00
Jesse Hills e2ca80ec41 Bump version to 2026.7.4 2026-08-05 13:19:43 +12:00
920ff9c25f [esp32] explicitly disable BLE 5.0 (#18047)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills 56a90d2b25 Bump bundled esphome-device-builder to 1.9.2 (#18051) 2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills b4166a883e Bump bundled esphome-device-builder to 1.9.1 (#18025) 2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills b8703a8a1b Bump bundled esphome-device-builder to 1.9.0 (#18017) 2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills 57bb4e4e77 Bump bundled esphome-device-builder to 1.8.2 (#17995) 2026-08-05 13:19:42 +12:00
J. Nick KostonandJesse Hills de93815c6b [api] Fix double free when overflow buffer drain is re-entered (#17969) 2026-08-05 13:19:42 +12:00
Jesse Hills d4372ed008 [esp32] Restrict toolchain validation to supported values (#17972) 2026-08-05 13:19:42 +12:00
a199ac41ee [espidf] Use forward slashes in generated component CMakeLists (#17965)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-05 13:19:42 +12:00
J. Nick KostonandJesse Hills c0f494450d [git] Fix device adoption failing on first attempt: lock the clone cache against concurrent resolutions (#17923) 2026-08-05 13:19:42 +12:00
6b6903e568 [epaper_spi] Default init sequence to empty not None (#17966)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:19:42 +12:00
esphome[bot]andJesse Hills d0f68802b9 Bump bundled esphome-device-builder to 1.8.1 (#17964) 2026-08-05 13:19:42 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
7862520450 Bump bundled esphome-device-builder to 1.8.0 (#17953)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-05 13:19:42 +12:00
dependabot[bot]andJesse Hills cbdddc8020 Bump platformdirs from 4.10.0 to 4.11.0 (#17756)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 13:19:41 +12:00
dependabot[bot]andJesse Hills 7d1317ad53 Bump filelock from 3.29.0 to 3.32.0 (#17759)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 13:19:41 +12:00
J. Nick KostonandGitHub ad18bbc644 [esp32_ble] Migrate the BLE count machinery to the shared slot counter factory (#18059) 2026-08-05 00:54:07 +00:00
1bae91abaa [ln882h_ble_tracker] BLE 5.x scanner for LN882H (#16691)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-04 22:45:47 +00:00
J. Nick KostonandGitHub 1f5df20cb2 [rp2040_ble] Codegen sized StaticVector for scan listeners (#18058) 2026-08-04 21:04:22 +00:00
J. Nick KostonandGitHub d47acf5d0b [core] Read the mqtt IP discovery flag without importing the mqtt component (#18066) 2026-08-04 15:12:57 -05:00
J. Nick KostonandGitHub 79a305d69a [core] Drop the duplicate ESPHome version parser (#18065) 2026-08-04 14:48:39 -05:00
J. Nick KostonandGitHub d344fd74d1 [core] Read the esp32 variant for esptool without importing the esp32 package (#18067) 2026-08-04 19:47:07 +00:00
J. Nick KostonandGitHub 68640f8074 [core] Shared slot count factory for codegen sized listener storage (#18057) 2026-08-04 19:46:44 +00:00
J. Nick KostonandGitHub 342f3d6994 [core] Share the stacktrace analyzer resolution in platform_hooks (#18075) 2026-08-04 14:38:28 -05:00
J. Nick KostonandGitHub a904426431 [core] Unbind the esp-idf toolchain from the esp32 component package (#18068) 2026-08-04 14:38:10 -05:00
Edvard FilistovičandGitHub b00f32db8a [bthome_mithermometer] Gate log-only MAC helper by log level (#18064) 2026-08-04 18:59:11 +00:00
Edvard FilistovičandGitHub 914cab301f [rp2_ble_tracker] Extract stamp-and-start helper; pin capability/switch independence (#18072) 2026-08-04 18:20:57 +00:00
Marek PilchandGitHub 8bd9f213e5 [modbus] Add unit tests for U_WORD_S and S_WORD_S (#17831) 2026-08-04 12:17:27 -04:00
J. Nick KostonandGitHub 5abd100b53 [core] Resolve platform CLI hooks from a registry instead of importing the platform package (#18044) 2026-08-04 11:08:56 -05:00
J. Nick KostonandGitHub adb86a052c [core] Ship secrets referenced by remote package files in config bundles (#18053) 2026-08-04 11:05:52 -05:00
J. Nick KostonandGitHub c2ddc17063 [libretiny] Give each platform its own CONFIG_SCHEMA instance (#18056) 2026-08-04 11:05:04 -05:00
Edvard FilistovičandGitHub 37b6578259 [bk72xx_ble_tracker] Brace single-statement loop flagged by clang-tidy (#18070) 2026-08-04 15:57:42 +00:00
Edvard FilistovičandGitHub c6a37847e6 [ble_device_base] Scan-mode request contract on BLEHub (#18061) 2026-08-04 15:18:31 +00:00
61a4722c93 [modbus_controller] Add bus-fairness integration test (xfail until #11781) (#17345)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:15:31 -05:00
Edvard FilistovičandGitHub dbf7167622 [bk72xx_ble] Codegen-sized StaticVector for scan listeners (#18055) 2026-08-04 04:31:43 +00:00
Jesse HillsandGitHub 4ceae9cf85 [ci] Replace pre-commit with prek (#18029) 2026-08-04 16:18:46 +12:00
dafc86d960 [esp32] explicitly disable BLE 5.0 (#18047)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-08-03 22:36:52 -05:00
Edvard FilistovičandGitHub 56b1c59eb2 [ln882h_ble] Scan primitives and main-task scan report queue (#17835) 2026-08-03 21:54:07 -05:00
J. Nick KostonandGitHub 1b3891866c [core] Parse stored framework versions without importing the validation stack (#18045) 2026-08-03 20:59:38 -05:00
J. Nick KostonandGitHub 11c3c06b12 [rp2_ble_tracker] Add active scanning support (#18035) 2026-08-03 20:51:17 -05:00
Jonathan SwobodaandGitHub 735c64d138 [core] Support local library directories via file:// on the native toolchain (#18005) 2026-08-03 21:48:57 -04:00
J. Nick KostonandGitHub 4f67932e0d [substitutions][core] Expand templated !include paths to on-disk candidates during bundle discovery (#17647) 2026-08-03 20:48:11 -05:00
esphome[bot]andGitHub 25d5985775 Bump bundled esphome-device-builder to 1.9.2 (#18051) 2026-08-04 01:29:21 +00:00
J. Nick KostonandGitHub a1c2983394 [core] Hash entity keys from the raw name to fix collisions (#17949) 2026-08-03 20:05:25 -05:00
J. Nick KostonandGitHub 4424af8c5b [api] Move runtime log client out of the component package (#18043) 2026-08-03 19:57:52 -05:00
J. Nick KostonandGitHub e8dadf2852 [esp32] Flag crash records captured by a different firmware build (#17770) 2026-08-03 19:56:20 -05:00
J. Nick KostonandGitHub 2b6bf1f0fe [esp8266] Move wifi rate tables to DRAM to fix beacon parse crash (#17968) 2026-08-03 19:54:43 -05:00
J. Nick KostonandGitHub 644f279972 [logger] Stub out ROM ets_putc on ESP8266 when serial logging is disabled (#17970) 2026-08-03 19:53:51 -05:00
J. Nick KostonandGitHub 43deec3063 [espnow] Stop polling the Wi-Fi driver every loop and disable loop when idle (#18027) 2026-08-03 19:53:37 -05:00
J. Nick KostonandGitHub a50369fc0e [rp2_ble_tracker] BLE tracker for Raspberry Pi Pico W (#18002) 2026-08-03 19:53:07 -05:00
5d89e432da [modbus_controller] Span response path; keep sensor addresses as configured (#17677)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 19:15:00 -05:00
0b34c97742 [midea] Add ESP-IDF framework support (#12646)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-03 19:21:27 -04:00
kbx81 9c4016a871 [zigbee_proxy] Drop usb_uart_id removal error, component is unreleased 2026-08-03 16:58:53 -05:00
kbx81 5846977cf6 [zigbee_proxy] Auto-detect USB UART channel from uart_id, drop usb_uart_id
The usb_uart_id key was redundant: uart_id already points at the channel.
A new usb_uart.is_usb_uart_channel() helper checks the config tree (use_id
resolution does not narrow the ID type), and zigbee_proxy uses it to enable
the RX callback fast path and USB timeout defaults automatically.
2026-08-03 16:55:28 -05:00
kbx81 ca42862742 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-08-03 16:32:34 -05:00
dependabot[bot]andGitHub 66db4fdd61 Bump github/codeql-action/analyze from 4.37.3 to 4.37.4 (#18041)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 15:14:07 -05:00
dependabot[bot]andGitHub 9931647a8c Bump ruff from 0.16.0 to 0.16.1 (#18038)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 15:13:51 -05:00
dependabot[bot]andGitHub 77546b6111 Bump github/codeql-action/init from 4.37.3 to 4.37.4 (#18040)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 15:13:39 -05:00
dependabot[bot]andGitHub 050b4c3c94 Bump cryptography from 48.0.1 to 50.0.0 (#18037)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 15:13:26 -05:00
dependabot[bot]andGitHub 3be32846e4 Bump filelock from 3.32.0 to 3.32.2 (#18039)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 15:13:02 -05:00
J. Nick KostonandGitHub f8b83cc8a2 [wifi] Notify connect state listeners after driver initiated roams (#18034) 2026-08-03 16:39:14 +00:00
J. Nick KostonandGitHub 4f058585bb [rp2040_ble] Add controller scan primitives (#18001) 2026-08-03 11:14:34 -05:00
Egor VorontsovGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
4d9c514a1a [i2s_audio] Implemented I2S PDM Microphone DSR selection (#17751)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-03 09:22:03 -04:00
Jonathan SwobodaandGitHub 6b10e8bd15 [esp32_hosted] Bump esp_hosted to 2.12.12, esp_wifi_remote to 1.6.3 (#18030) 2026-08-02 23:58:47 -04:00
Edvard FilistovičandGitHub 67654fd1e1 [bk72xx_ble_tracker] Fix scan start retry and stale-millis underflow (#17992) 2026-08-03 02:55:20 +00:00
esphome[bot]andGitHub 2604169080 Bump bundled esphome-device-builder to 1.9.1 (#18025) 2026-08-02 19:46:03 -05:00
74fbee7d8d [ble_device_base] Share scan parameter validation between trackers (#18003)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-02 19:28:22 -05:00
d6ad1560fa [nrf52] feat: add support for zephyr pwm (#16483)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: tomaszduda23 <tomaszduda23@gmail.com>
2026-08-02 21:16:21 +00:00
Bonne EgglestonandGitHub fcc2c26367 [modbus] Restore function code byte for custom codes in deprecated on_modbus_data (#18006) 2026-08-02 11:40:34 -05:00
esphome[bot]andGitHub e36d7fe82b Bump bundled esphome-device-builder to 1.9.0 (#18017) 2026-08-01 22:08:57 -05:00
d38a458de9 [modbus] Rework the client queue as a per-frame state machine (#17922)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-01 21:34:36 -05:00
Jonathan SwobodaandGitHub 5821915aad [heatpumpir] Expose configured min/max as the visual temperature range (#17985) 2026-08-01 12:37:44 -04:00
esphome[bot]andGitHub fae2f7a11d Bump bundled esphome-device-builder to 1.8.2 (#17995) 2026-08-01 08:52:42 -05:00
Keith BurzinskiandGitHub 1df2759db6 [nextion] Reconnect stale HTTP connection during TFT upload (#17824) 2026-07-31 23:05:45 -05:00
Keith BurzinskiandGitHub ce9b221be4 [serial_proxy] Restrict port operations to the subscriber and harden subscription handling (#17796) 2026-07-31 23:05:06 -05:00
Edvard FilistovičandGitHub e50fae3f46 [ln882h_ble] BLE controller support for LN882H (#17777) 2026-07-31 22:40:57 +00:00
ArdumineandGitHub 15711f8112 [openthread_info] Add more sensors for Openthread info (#17276) 2026-07-31 12:13:44 -04:00
Jesse HillsandGitHub 7ad662781d [ci] Order jobs to follow the CI flow and close ci-status gaps (#17979) 2026-07-31 07:53:59 -04:00
TesseractTimmeeGitHubrootpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2d1530adf7 [zigbee] Add more units (#17982)
Co-authored-by: root <root@core-ssh.local.hass.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-31 07:16:07 -04:00
Jesse HillsandGitHub 40b4cd8e9b [ci] Drop the unused Codecov upload token (#17977) 2026-07-31 06:32:33 -04:00
Jesse HillsandGitHub e11a295680 [ci] Report an empty Codecov upload when pytest is skipped (#17976) 2026-07-31 16:27:05 +12:00
Jesse HillsandGitHub f9cd789f7c [ci] Tell stacked pull requests apart from hand-built chains (#17980) 2026-07-31 15:39:36 +12:00
Jesse HillsandGitHub 80c17fb2cd [ci] Authenticate the stale workflow as esphome[bot] (#17974) 2026-07-31 13:52:01 +12:00
J. Nick KostonandGitHub fb21320826 [api] Fix double free when overflow buffer drain is re-entered (#17969) 2026-07-30 14:48:17 -10:00
Jesse HillsandGitHub 3899429cfa [esp32] Restrict toolchain validation to supported values (#17972) 2026-07-30 20:34:44 -04:00
44f08d15db [espidf] Use forward slashes in generated component CMakeLists (#17965)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-30 20:34:04 -04:00
J. Nick KostonandGitHub 963a3379a6 [core] Log when a git source update is skipped and when it will next refresh (#17943) 2026-07-30 13:06:32 -10:00
J. Nick KostonandGitHub 4951f4fc2e [git] Fix device adoption failing on first attempt: lock the clone cache against concurrent resolutions (#17923) 2026-07-30 11:44:12 -10:00
a87ad66746 [epaper_spi] Default init sequence to empty not None (#17966)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:25:17 -04:00
Jonathan SwobodaandGitHub 60662830dc [ci] Switch stale to the shared real-activity workflow (#17960) 2026-07-30 15:41:10 -04:00
esphome[bot]andGitHub 33ccaf8a85 Bump bundled esphome-device-builder to 1.8.1 (#17964) 2026-07-30 08:54:39 -10:00
Pieter ViljoenandGitHub d64e1ebba3 [ble_client] Do not walk the GATT cache after releasing services (#17919) 2026-07-30 08:31:32 -10:00
esphome[bot]GitHubesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
6733e930eb Bump bundled esphome-device-builder to 1.8.0 (#17953)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-30 08:01:17 -04:00
Jesse HillsandGitHub c23a107a87 [config_validation] Add underscore/hyphen normalization to one_of (#17792) 2026-07-30 18:36:28 +12:00
Kevin AhrendtandGitHub 4224d90567 [runtime_image] Allow decoding into a caller-owned buffer (#17936) 2026-07-29 23:32:21 -04:00
Jonathan SwobodaandGitHub fb1b1db87f [esp32][mdns][runtime_image] Bump recommended Arduino to 3.3.11 and platform to 55.03.311 (#17875) 2026-07-29 18:04:36 -04:00
Jonathan SwobodaandGitHub be78b8011d [esp32] Add platformio toolchain deprecation warning (#17947) 2026-07-29 17:17:39 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a99d03e172 Bump docker/login-action from 4.5.2 to 4.6.0 in the docker-actions group (#17944)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 15:49:19 -04:00
c7d16dde15 [voice_assistant] Fix playback when CPU is loaded (#17043)
Co-authored-by: Kevin Ahrendt <kevin.ahrendt@openhomefoundation.org>
2026-07-29 19:48:43 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
be7748ccb6 Bump CodSpeedHQ/action from 4.19.1 to 5.0.1 (#17946)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 15:35:07 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
41902d7538 Bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 (#17945)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 15:30:49 -04:00
J. Nick KostonandGitHub ca1f89e500 [ci] Enable the ruff rule requiring explicit encoding on text file I/O (#17897) 2026-07-29 13:44:39 -04:00
Kevin AhrendtandGitHub 306d200b02 [api] Stop advertising external wake words the device cannot load (#17926) 2026-07-29 13:27:06 -04:00
98ee7e0f82 [lvgl] Add lvgl.theme.update action (#17678)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 13:08:52 -04:00
Keith BurzinskiGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Copilot Autofix powered by AI
ad3e2f83b8 [sgp4x] Fix datasheet conformance issues (#17828)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-29 12:57:54 -04:00
33dacb06ff [opentherm] Restore idle output when stopped (#17834)
Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com>
2026-07-29 12:57:03 -04:00
Fran FodorandGitHub 363a91c185 [epaper_spi] Add Inkplate 6COLOR support (#17717) 2026-07-30 00:31:22 +10:00
Clyde StubbsandGitHub 4209f242e5 [mipi_spi] Add Waveshare ESP32-S3-Touch-LCD-3.5B (#17513) 2026-07-28 23:18:49 -04:00
9af0302bb9 [core] Expand AGENTS.md AI contributor guidance (#17741)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-07-28 22:47:43 -04:00
Brandon HarveyandGitHub 4c571c3ec7 [sen6x] Rename voc/nox sensor keys to voc_index/nox_index (#17725) 2026-07-28 22:35:53 -04:00
Brandon HarveyandGitHub a9df82fa0a [sen5x] Rename voc/nox sensor keys to voc_index/nox_index (#17724) 2026-07-28 22:35:34 -04:00
Brandon HarveyandGitHub 6ad804fbc5 [sgp4x] Rename voc/nox sensor keys to voc_index/nox_index (#17723) 2026-07-28 22:35:07 -04:00
Brandon HarveyandGitHub 227f1d3842 [core] Reject configs that set both keys in cv.rename_key (#17916) 2026-07-28 22:32:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
72f904dcfb Bump docker/login-action from 4.5.1 to 4.5.2 in the docker-actions group (#17912)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 15:41:50 -04:00
J. Nick KostonandGitHub 324e222bd2 [improv_serial] Reduce per-loop overhead (#16019) 2026-07-28 15:38:29 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7b1d70c2c0 Bump actions/stale from 10.4.0 to 11.0.0 (#17913)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 15:28:01 -04:00
Kobi HikriandGitHub b80fa4ae19 [ci] Bind the branch name to env before sanitising it into a docker tag (#17910) 2026-07-28 12:17:50 -04:00
Edvard FilistovičandGitHub a4611907a9 [captive_portal] Re-apply json_escape move lost in release merge (#17906) 2026-07-28 08:23:01 -04:00
Jesse Hills 17bd1b2a42 Merge branch 'release' into dev 2026-07-28 22:44:03 +12:00
Jesse HillsandGitHub 985a08e247 Merge pull request #17905 from esphome/bump-2026.7.3
2026.7.3
2026-07-28 22:43:16 +12:00
Edvard FilistovičandGitHub 95786459c1 [ble_device_base] Replace raw-advertisement std::function with a lightweight callback slot (#17902) 2026-07-28 09:54:58 +00:00
Jesse Hills 4f3db4c15a Bump version to 2026.7.3 2026-07-28 21:32:52 +12:00
esphome[bot]andJesse Hills 56512abb7e Update webserver local assets to 20260728-053845 (#17899)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-28 21:32:51 +12:00
Clyde StubbsandJesse Hills 71349a6feb [light] Ensure binary light is off with brightness 0 (#17893) 2026-07-28 21:32:51 +12:00
Jonathan SwobodaandJesse Hills 489e3d17ca [ethernet] Set SPI CS hold time for ENC28J60 (#17885) 2026-07-28 21:32:51 +12:00
Jesse Hills d93772fed6 [captive_portal] Escape SSID when building config JSON (#17872) 2026-07-28 21:32:51 +12:00
J. Nick KostonandJesse Hills 50f02aa523 [git] Fix submodule update failure when cloning libraries on the esp-idf toolchain (#17862) 2026-07-28 21:32:51 +12:00
J. Nick KostonandJesse Hills 5a87ad8fc0 [wifi] Lock scan results shared with the captive portal web task (#17850) 2026-07-28 21:32:51 +12:00
esphome[bot]andJesse Hills e40579ad93 Bump bundled esphome-device-builder to 1.7.0 (#17871) 2026-07-28 21:32:51 +12:00
Rui MarinhoandJesse Hills 37fe59dc37 [espidf] Include .cc, .cxx and .c++ sources in the app source glob (#17754) 2026-07-28 21:32:51 +12:00
Jonathan SwobodaandJesse Hills 3829d368ff [espidf] Honor compile_process_limit in native ESP-IDF builds (#17857) 2026-07-28 21:32:42 +12:00
Marcus VoßandJesse Hills 013c5d7217 [esp32_ble] Forward ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT to GAP handlers (#17833) 2026-07-28 21:29:28 +12:00
esphome[bot]andJesse Hills e38ae51de2 Bump bundled esphome-device-builder to 1.6.10 (#17801) 2026-07-28 21:29:28 +12:00
333ad42e29 Update webserver local assets to 20260728-053845 (#17899)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-28 21:28:23 +12:00
Edvard FilistovičandGitHub bb8ffac269 [const] Move CONF_SCAN_PARAMETERS and CONF_WINDOW to components/const (#17900) 2026-07-27 21:19:17 -10:00
Edvard FilistovičandGitHub 10303b3fa7 [ble_device_base] Add mac_lsb_first_to_uint64 address-packing helper (#17901) 2026-07-27 21:17:40 -10:00
Jesse HillsandGitHub 93eec5c961 [ci] Attribute device class sync commits to the esphome[bot] account (#17898) 2026-07-28 16:19:00 +12:00
J. Nick KostonandGitHub 16930c4e2c [ci] Read the GitHub event file as UTF-8 (#17896) 2026-07-27 17:33:08 -10:00
Edvard FilistovičandGitHub c2476911a3 [bluetooth_proxy] Answer UNPAIR with BluetoothDeviceUnpairingResponse (#17895) 2026-07-27 17:14:37 -10:00
Clyde StubbsandGitHub 98d99fb9fd [light] Ensure binary light is off with brightness 0 (#17893) 2026-07-28 14:19:44 +12:00
Jesse HillsandGitHub ce6c122449 [core] Move JSON string escaping into helpers (#17879) 2026-07-27 22:16:57 -04:00
Brandon HarveyandGitHub f1f8b102e2 [core] Add optional deprecation warning to cv.rename_key (#17740) 2026-07-27 22:10:34 -04:00
Egor VorontsovandGitHub e1ab5a85bb [i2s_audio] Eliminated a double unit conversion in read_() (#17752) 2026-07-27 20:28:40 -04:00
0959141f88 [nrf52] Clone the sdk-nrf manifest repository shallow (#17892)
Co-authored-by: esphbot <esphome@openhomefoundation.org>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-27 19:58:50 -04:00
Edvard FilistovičandGitHub 01616c4f30 [bk72xx_ble_tracker] BLE 5.x scanner for BK72xx (#17135) 2026-07-27 11:58:29 -10:00
J. Nick KostonGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
7c89e81449 [ci] Persist the integration test ccache across runs (#17735)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-27 11:26:49 -10:00
Jonathan SwobodaandGitHub 6420de53f0 [ethernet] Set SPI CS hold time for ENC28J60 (#17885) 2026-07-27 17:24:53 -04:00
J. Nick KostonGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fa8c7e60da [host] Speed up builds with ccache when available (#17728)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-27 20:47:12 +00:00
5eeb780538 [modbus] Fold the repeated PDU validation idioms into shared helpers (#17888)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:44:15 -10:00
19511f5787 [modbus] Command lifecycle: PDU-carrying callbacks, on_sent(), notified queue clearing (#17886)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:35:51 -10:00
Jesse HillsandGitHub 56028d0932 [safe_mode] Prevent OTA rollback when entering deep sleep (#17699) 2026-07-27 16:30:52 -04:00
ca08803425 [nrf52] add OTA for Adafruit_nRF52_Bootloader (#17381)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-27 16:03:22 -04:00
e209bbb5ae [logger] flush lines for host logger (#17878)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-07-27 15:48:29 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
580b3a1a69 Bump CodSpeedHQ/action from 4.18.5 to 4.19.1 (#17890)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 15:35:53 -04:00
J. Nick KostonandGitHub c395ea00cc [libretiny] Speed up builds with ccache when available (#17726) 2026-07-27 18:54:32 +00:00
J. Nick KostonandGitHub 89654fea66 [rp2] Speed up builds with ccache when available (#17727) 2026-07-27 18:52:29 +00:00
J. Nick KostonandGitHub 700c0b0460 [esp8266] Speed up builds with ccache when available (#17722) 2026-07-27 08:42:52 -10:00
J. Nick KostonandGitHub c0aa121c3d [espidf] Install only the toolchains for the variants being built (#17688) 2026-07-27 08:39:03 -10:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jonathan Swoboda
345bd11a2c Bump ruff from 0.15.22 to 0.16.0 (#17818)
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-07-27 12:08:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
071ef5016d Bump actions/setup-python from 6.3.0 to 7.0.0 in /.github/actions/restore-python (#17732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:47:45 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fdc2974c0d Bump docker/login-action from 4.4.0 to 4.5.1 in the docker-actions group across 1 directory (#17817)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 11:47:31 -04:00
899ed02ef2 [remote_base] support haier short IR message (#17826)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-07-27 09:52:15 -04:00
Jesse HillsandGitHub a930baab7c [captive_portal] Escape SSID when building config JSON (#17872) 2026-07-27 19:42:44 +12:00
J. Nick KostonandGitHub 43526a815e [git] Fix submodule update failure when cloning libraries on the esp-idf toolchain (#17862) 2026-07-27 19:42:21 +12:00
J. Nick KostonandGitHub 8f321bf346 [wifi] Lock scan results shared with the captive portal web task (#17850) 2026-07-27 17:31:24 +12:00
91cbcab25e [web_server] Add assumed_state to cover JSON detail (#16800)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-27 14:01:45 +12:00
67e0058e72 [modbus] Typed client send helpers and response callbacks (#17435)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 13:14:54 -10:00
esphome[bot]andGitHub b395a87cad Bump bundled esphome-device-builder to 1.7.0 (#17871) 2026-07-26 13:13:55 -10:00
Jonathan SwobodaandGitHub 8a7d2d0ca0 [ci] Enforce list form for platform domains in test fixtures (#17869) 2026-07-26 19:01:15 -04:00
Rui MarinhoandGitHub ed066cf0fe [espidf] Include .cc, .cxx and .c++ sources in the app source glob (#17754) 2026-07-27 10:34:29 +12:00
Jonathan SwobodaandGitHub 741fbbff5a [dallas_temp][ds2484] Fix one_wire test configs for grouped CI batches (#17868) 2026-07-26 17:49:57 -04:00
Edvard FilistovičandGitHub cf17749307 [ble_device_base] Shared address-type string and discovered-device log (#17861) 2026-07-26 00:49:29 -10:00
Bonne EgglestonandGitHub 98f2a0b4a4 [modbus] Typed send helpers; migrate in-tree callers off send() (#17859) 2026-07-25 21:57:35 -10:00
J. Nick KostonGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
9dd7dcb06d [espidf] Also skip installing gdb and ULP toolchains ESPHome never runs (#17687)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-25 20:07:14 -10:00
559614077d [ezo_pmp] Fix ml/min and ml unit capitalization (#17803)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 00:22:49 -04:00
b5e18eb101 [opentherm] Fix l/min unit capitalization to L/min (#17804)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 00:16:07 -04:00
J. Nick KostonandGitHub e0644ab20a [esp32] Capture fault address and raw cause in crash handler (#17769) 2026-07-25 18:12:53 -10:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
98e9dd7954 Bump github/codeql-action/init from 4.37.2 to 4.37.3 (#17785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 00:09:46 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fbb1a76e2a Bump github/codeql-action/analyze from 4.37.2 to 4.37.3 (#17786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 00:09:37 -04:00
Jonathan SwobodaandGitHub 7fbb647c65 [espidf] Honor compile_process_limit in native ESP-IDF builds (#17857) 2026-07-25 23:59:33 -04:00
a2c749c277 [modbus] PackedBits and right-sized typed PDU builders (#17848)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-25 11:18:46 -10:00
0833e91fb5 [modbus] Turn the ModbusDevice alias into a working compatibility shim (#17854)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 09:39:07 -10:00
eaa79b3696 [modbus] Frame accessors, PDU-relative lengths, and span-based send_pdu (#17846)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 03:09:05 +00:00
Ryan GammonandGitHub 58385bebff [mipi_spi] Add ST77916 / ESP-VoCat model (#17679) 2026-07-25 02:57:17 +00:00
Michael PöttgenandGitHub eb7575f59e [gsl3670] Add a model configuration for the Guition JC8012P4A1 (#17843) 2026-07-25 11:33:17 +10:00
6648ccc278 [modbus_controller] Qualify modbus::EntityType at use sites (#17845)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:01:53 -10:00
9ac4039c01 [modbus] Rename core enums to EntityType, FunctionCode and ExceptionCode (#17844)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:28:22 -10:00
5e2d428e69 [modbus] Heap-free response path (#17377)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-07-24 11:45:59 -10:00
d12300679e [bk72xx_ble] Scan primitives and main-task scan report queue (#17802)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-24 00:26:33 -10:00
Marcus VoßandGitHub e224a781ff [esp32_ble] Forward ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT to GAP handlers (#17833) 2026-07-23 23:47:27 -10:00
a210c27d14 [api][climate][water_heater][core] Add temperature unit support to climate and water heater c++ entities (#16477)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 13:58:39 -10:00
esphome[bot]andGitHub f0800336b8 Bump bundled esphome-device-builder to 1.6.10 (#17801) 2026-07-23 00:02:31 -10:00
Edvard FilistovičandGitHub 33fa9f5069 [bk72xx_ble] BLE controller support for BK72xx (BLE 5.x) (#17775) 2026-07-23 08:54:30 +00:00
StasGitHubKeith Burzinskipre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
427a504308 [sgp4x] Fix sgp4x VOC baseline restoration (#15667)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-23 02:49:38 -05:00
kbx81 e0a054dbcb [zigbee_proxy] Harden ASH sessions, fix UAF/boot-stall/overflows, reduce latency
- Unsubscribe on API disconnect (use-after-free) + loop() subscriber guard
- Bounds-checked frame building; cap forwarded RSTACK/ERROR payloads
- Explicit client ACKs, duplicate re-ACK, NAK on reject (both ASH sides)
- Client->NCP TX queue with NAK overflow; retry client frames on API backpressure
- Harvest EUI64 during boot; implement NETWORK_INFO request/response and push
- Proceed after boot timeout instead of stalling setup; periodic NCP recovery
- zwave-style inline UART fast path; process piggybacked ACKs before sequence check
- Wire up bootloader detection; heap-free hex logging
2026-07-22 23:20:51 -05:00
kbx81 5e822b828e Fix zigbee proxy handlers for new non-virtual dispatch, drop deprecated rp2040 platform key in test 2026-07-22 22:31:12 -05:00
kbx81 ef646a9303 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-07-22 22:20:25 -05:00
Jesse HillsandGitHub dd2fbdd43a [github] Add developer-facing feature PR classification (#17795) 2026-07-23 14:48:25 +12:00
Jesse Hills d7d1e5c2f6 Merge branch 'release' into dev 2026-07-23 14:42:36 +12:00
Jesse HillsandGitHub b832e135cb Merge pull request #17794 from esphome/bump-2026.7.2
2026.7.2
2026-07-23 14:41:52 +12:00
Jesse Hills 66615a24a7 Bump version to 2026.7.2 2026-07-23 13:55:39 +12:00
esphome[bot]andJesse Hills 52ace9c448 Bump bundled esphome-device-builder to 1.6.9 (#17793) 2026-07-23 13:55:39 +12:00
J. Nick KostonandJesse Hills e1cedaba87 [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) 2026-07-23 13:55:38 +12:00
f7bf4e9727 [mipi_spi] fix partial update bug (#17747)
Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com>
2026-07-23 13:55:38 +12:00
Kevin AhrendtandJesse Hills e46bdf9e18 [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) 2026-07-23 13:55:38 +12:00
Brandon HarveyandJesse Hills a08070a836 [sen5x] Add model option to override autodetection (#17764) 2026-07-23 13:55:38 +12:00
dependabot[bot]andJesse Hills 18f93c0e41 Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 13:55:38 +12:00
rwalker777GitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>kbx81Claude Opus 4.7J. Nick Koston
deba7a1f0c [ethernet][network][wifi] Add network priority for multi-interface support (#14255)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: kbx81 <kbx81x@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-22 20:55:04 -05:00
esphome[bot]andGitHub 4817904637 Bump bundled esphome-device-builder to 1.6.9 (#17793) 2026-07-22 15:49:38 -10:00
J. Nick KostonandGitHub 98c6fa294a [bundle] Remap absolute file paths when compiling an extracted bundle (#17765) 2026-07-23 11:28:22 +12:00
312b0c53c2 [mipi_spi] fix partial update bug (#17747)
Co-authored-by: clyde <2366188+clydebarrow@users.noreply.github.com>
2026-07-22 20:50:11 +00:00
Kevin AhrendtandGitHub 5dfce9b90c [sendspin] Bump sendspin-cpp to v0.7.0 (#17781) 2026-07-22 08:41:33 -10:00
Brandon HarveyandGitHub 69085a7a42 [sen5x] Add model option to override autodetection (#17764) 2026-07-21 13:18:21 -10:00
dependabot[bot]andGitHub 2edb089f71 Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 (#17734)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 13:09:45 -10:00
dependabot[bot]andGitHub 078b1aa1d6 Bump aioesphomeapi from 45.6.2 to 45.7.0 (#17768)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 23:02:30 +00:00
dependabot[bot]andGitHub 1f8fea5379 Bump actions/checkout from 7.0.0 to 7.0.1 (#17733)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 12:58:29 -10:00
dependabot[bot]andGitHub 9c211abc54 Bump github/codeql-action/analyze from 4.37.1 to 4.37.2 (#17761)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 12:37:31 -10:00
dependabot[bot]andGitHub 4afb619672 Bump actions/setup-python from 6.3.0 to 7.0.0 (#17731)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 12:32:31 -10:00
Edvard FilistovičandGitHub 4d3b4659d7 [ble_device_base] Platform-neutral BLE layer; esp32_ble_tracker implements BLEHub (#17150) 2026-07-21 12:32:14 -10:00
J. Nick KostonandGitHub 37be04f58e [core] Reduce memory footprint of esphome upload (#17684) 2026-07-21 12:21:12 -10:00
dependabot[bot]andGitHub 1e4bf48ea3 Bump github/codeql-action/init from 4.37.1 to 4.37.2 (#17758)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 11:45:39 -10:00
dependabot[bot]andGitHub 52c6a50b6d Bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#17760)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 11:45:27 -10:00
dependabot[bot]andGitHub 008c85c7d5 Bump astral-sh/setup-uv from 8.3.2 to 9.0.0 in /.github/actions/restore-python (#17757)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 11:34:03 -10:00
dependabot[bot]andGitHub e84880e4ec Bump platformdirs from 4.10.0 to 4.11.0 (#17756)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 11:33:49 -10:00
dependabot[bot]andGitHub a30f9f7c65 Bump filelock from 3.29.0 to 3.32.0 (#17759)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 10:39:43 -10:00
Jesse Hills e931919fb4 Merge branch 'release' into dev 2026-07-21 10:45:17 +12:00
Jesse HillsandGitHub bbc5669efa Merge pull request #17739 from esphome/bump-2026.7.1
2026.7.1
2026-07-21 10:44:34 +12:00
Guanzhong ChenandJesse Hills 718b04c15a [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) 2026-07-21 09:27:55 +12:00
Jonathan SwobodaandJesse Hills 24a8634a4b [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) 2026-07-21 09:27:55 +12:00
Jonathan SwobodaandJesse Hills fc6664d737 [emc2101] Fix negative external temperatures reported as large positives (#17494) 2026-07-21 09:27:55 +12:00
dependabot[bot]andJesse Hills 388e411469 Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 09:17:18 +12:00
Jesse Hills a8ebdcb8f2 Bump version to 2026.7.1 2026-07-21 08:25:51 +12:00
J. Nick KostonandJesse Hills f5f1c48f51 [esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737) 2026-07-21 08:25:51 +12:00
J. Nick KostonandJesse Hills 7c55de311f [wireguard] Mark private keys sensitive, stop redacting public keys (#17736) 2026-07-21 08:25:46 +12:00
J. Nick KostonandJesse Hills 307faa6c38 [espidf] Resume interrupted toolchain downloads instead of restarting (#17706) 2026-07-21 08:18:16 +12:00
J. Nick KostonandJesse Hills 5f2adcf9b3 [platformio] Include cache path in invalid library error (#17692) 2026-07-21 08:18:16 +12:00
7738464f0b [http_request] Fix usage of http response body (#17713)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-21 08:18:16 +12:00
J. Nick KostonandJesse Hills 5a86e26f68 [platformio] Re-download library when cached copy is missing its manifest (#17691)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-21 08:18:16 +12:00
J. Nick KostonJesse Hillspre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
786b47d8c2 [core] Auto-clean the PlatformIO build environment when the Python version changes (#17671)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 83092ea05c Bump bundled esphome-device-builder to 1.6.8 (#17708) 2026-07-21 08:18:15 +12:00
629afd38f6 [light] Fix pulse and other effects (#17645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 3ffc3a9610 [espidf] Make openocd-esp32 optional so its libusb check cannot break installs (#17686) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 9de7bd7461 [git] Detect interrupted clones and re-clone automatically (#17690) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 5df922e0df [platformio] Accept git URLs passed as the library name (#17697) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills b571d2a5ab [micro_wake_word] Download models in parallel (#17701) 2026-07-21 08:18:15 +12:00
Jesse Hills 231a2897c0 [ssd1306] Fix offset_x being ignored on SH1106/SH1107 displays (#17700) 2026-07-21 08:18:15 +12:00
Brandon HarveyandJesse Hills 2223b14794 Split multi-token build.flags entries when generating ESP-IDF component CMakeLists (#17649) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 3d340f4d90 Bump bundled esphome-device-builder to 1.6.7 (#17696) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills 2f99466f3a Bump bundled esphome-device-builder to 1.6.6 (#17681) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills f05e15522c Bump bundled esphome-device-builder to 1.6.5 (#17675) 2026-07-21 08:18:15 +12:00
Jonathan SwobodaandJesse Hills f37dad683b [esp32] Bump recommended ESP-IDF to 5.5.5 and Arduino to 3.3.10 (#17669) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills a0fb14bf54 Bump bundled esphome-device-builder to 1.6.4 (#17662) 2026-07-21 08:18:15 +12:00
Jesse Hills d4443be0c1 [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 0dfb573e18 [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 572fc033cd Ship component requirements.txt files in the sdist and wheel (#17660) 2026-07-21 08:18:15 +12:00
J. Nick KostonandJesse Hills 4062f0a323 Pin cryptography to 48.0.1 on Intel macOS (#17658) 2026-07-21 08:18:15 +12:00
Brandon HarveyandJesse Hills cd40fb1c68 [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) 2026-07-21 08:18:15 +12:00
Jonathan SwobodaandJesse Hills 7afe7750cd [espidf] Suggest installing missing system libraries when the tools install fails (#17619) 2026-07-21 08:18:15 +12:00
esphome[bot]andJesse Hills e08bdf8cca Bump bundled esphome-device-builder to 1.6.3 (#17651) 2026-07-21 08:18:15 +12:00
dependabot[bot]andJesse Hills b3172ecae8 Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 08:18:14 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2b4d9c0af9 Bump bundled esphome-device-builder to 1.6.2 (#17640)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-21 08:18:14 +12:00
Jesse Hills 3a10d2c187 [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 05e2c6b133 [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 2797349c75 [http_request] Fix use-after-return of header collection state in IDF backend (#17627) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills 95a01ac2ab [core] Improve framework mirror selection, download errors, and version parsing (#17615) 2026-07-21 08:18:14 +12:00
J. Nick KostonandJesse Hills ea01c909b7 [micro_wake_word] Include the local model file in bundles (#17604) 2026-07-21 08:18:14 +12:00
Jonathan SwobodaandJesse Hills d3655597ea [as3935_i2c] Use repeated start when reading registers (#17584) 2026-07-21 08:18:14 +12:00
J. Nick KostonandGitHub cd8d76fa34 [esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737) 2026-07-21 08:17:01 +12:00
J. Nick KostonandGitHub 500d4aa9e9 [wireguard] Mark private keys sensitive, stop redacting public keys (#17736) 2026-07-20 20:13:56 +00:00
J. Nick KostonandGitHub 271c1b409a [espidf] Resume interrupted toolchain downloads instead of restarting (#17706) 2026-07-21 08:12:17 +12:00
J. Nick KostonandGitHub 9a0e8606cd [platformio] Include cache path in invalid library error (#17692) 2026-07-21 08:08:46 +12:00
tomaszduda23andGitHub d5681ac6a0 [nrf52] add platformio deprecation warning (#17705) 2026-07-20 20:29:00 +02:00
057143838d [network] Fix IPAddress IPv6 support on HOST platform (#17067)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.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-07-20 18:18:37 +00:00
4268d91da3 [http_request] Fix usage of http response body (#17713)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-07-20 17:07:28 +00:00
6f39030b41 [core] Prevent blocking-warning log time from cascading (#17710)
Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
2026-07-20 06:28:56 -10:00
3e1a9e8a3a [platformio] Re-download library when cached copy is missing its manifest (#17691)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-19 19:34:56 -10:00
J. Nick KostonGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
bb1318dce1 [core] Auto-clean the PlatformIO build environment when the Python version changes (#17671)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-19 19:32:38 -10:00
esphome[bot]andGitHub 26fcc068b6 Bump bundled esphome-device-builder to 1.6.8 (#17708) 2026-07-19 19:32:22 -10:00
2a96fa44ff [light] Fix pulse and other effects (#17645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-07-20 17:09:02 +12:00
J. Nick KostonandGitHub 5fa93513f2 [espidf] Make openocd-esp32 optional so its libusb check cannot break installs (#17686) 2026-07-20 17:05:50 +12:00
J. Nick KostonandGitHub 4a1f54ce4b [git] Detect interrupted clones and re-clone automatically (#17690) 2026-07-20 16:34:29 +12:00
J. Nick KostonandGitHub 76655cf951 [platformio] Accept git URLs passed as the library name (#17697) 2026-07-20 16:32:20 +12:00
J. Nick KostonandGitHub dfb988c563 [micro_wake_word] Download models in parallel (#17701) 2026-07-20 16:16:15 +12:00
Jesse HillsandGitHub 0443b4849b [ssd1306] Fix offset_x being ignored on SH1106/SH1107 displays (#17700) 2026-07-20 15:51:46 +12:00
Brandon HarveyandGitHub 977376d55c Split multi-token build.flags entries when generating ESP-IDF component CMakeLists (#17649) 2026-07-19 12:20:43 -10:00
esphome[bot]andGitHub 1f93345af8 Bump bundled esphome-device-builder to 1.6.7 (#17696) 2026-07-19 09:17:53 -10:00
esphome[bot]andGitHub 96dd2382c1 Bump bundled esphome-device-builder to 1.6.6 (#17681) 2026-07-18 21:25:56 -10:00
esphome[bot]andGitHub 3064fb0d48 Bump bundled esphome-device-builder to 1.6.5 (#17675) 2026-07-18 11:46:14 -10:00
Jonathan SwobodaandGitHub 4c9dab9c98 [esp32] Bump recommended ESP-IDF to 5.5.5 and Arduino to 3.3.10 (#17669) 2026-07-18 11:14:42 -10:00
tomaszduda23andGitHub 0b0e706349 [nrf52] add platform: ultrasonic test (#17665) 2026-07-18 08:05:19 -04:00
tomaszduda23andGitHub 017040ec8e [zigbee] add radio power off stats (#17521) 2026-07-18 09:09:52 +02:00
TomandGitHub 52fe461e9c [ds248x] Add OneWireBus platform for DS248x I2C-to-1Wire bridges (#12717) 2026-07-17 22:01:15 -07:00
esphome[bot]andGitHub a7dad14449 Bump bundled esphome-device-builder to 1.6.4 (#17662) 2026-07-17 17:44:52 -10:00
Jesse HillsandGitHub 1493a09521 [nrf52] Install PlatformIO toolchain Python packages into a dedicated venv (#17635) 2026-07-17 23:22:23 -04:00
J. Nick KostonandGitHub 2a59c0ad9e [logs] Cap the logs reconnect backoff for deep-sleep devices (#17656) 2026-07-17 17:16:14 -10:00
Jonathan SwobodaandGitHub 70421fb14b [espidf] Prune tool download cache after install to shrink the ESP-IDF cache (#17661) 2026-07-17 22:49:50 -04:00
J. Nick KostonandGitHub bacc223360 Ship component requirements.txt files in the sdist and wheel (#17660) 2026-07-17 15:59:12 -10:00
J. Nick KostonandGitHub 94be1da893 Pin cryptography to 48.0.1 on Intel macOS (#17658) 2026-07-17 15:46:16 -10:00
Brandon HarveyandGitHub cb66dc01ab [core] Fix srcFilter exclusions being silently ignored on Windows (#17648) 2026-07-17 21:45:13 -04:00
Jonathan SwobodaandGitHub ebaf32c82a [ci] Add LibreTiny clang-tidy environments (#17491) 2026-07-17 21:38:55 -04:00
Jonathan SwobodaandGitHub f918c299b1 [espidf] Suggest installing missing system libraries when the tools install fails (#17619) 2026-07-17 19:46:45 -04:00
dependabot[bot]andGitHub 328a2018d2 Bump aioesphomeapi from 45.6.1 to 45.6.2 (#17654)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 13:32:47 -10:00
dependabot[bot]andGitHub a4a9feac16 Bump esphome/workflows/.github/workflows/lock.yml from 2026.4.1 to 2026.7.0 (#17652)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 12:26:04 -10:00
esphome[bot]andGitHub 6f27c7f3fe Bump bundled esphome-device-builder to 1.6.3 (#17651) 2026-07-17 12:25:18 -10:00
dependabot[bot]andGitHub 300ab1be35 Bump aioesphomeapi from 45.6.0 to 45.6.1 (#17653)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 12:00:54 -10:00
esphome[bot]GitHubesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
7aed5fb94b Bump bundled esphome-device-builder to 1.6.2 (#17640)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-17 08:08:28 -04:00
Keith BurzinskiandGitHub 2132cae1c9 [nextion] Fix RP2040 clang-tidy failure from TFT upload guard mismatch (#17638) 2026-07-17 08:07:38 -04:00
Jesse HillsandGitHub 735f8d607d [nrf52] Set ZEPHYR_SDK_INSTALL_DIR for Zephyr SDK discovery (#17633) 2026-07-18 00:04:35 +12:00
Jonathan SwobodaandGitHub cc6392785f [runtime_image] Prevent integer overflow in image buffer size calculation (#17624) 2026-07-16 18:36:18 -04:00
Jonathan SwobodaandGitHub 7a2d13da90 [web_server_idf] Use core format_hex_to helper for digest auth (fixes Arduino build) (#17608) 2026-07-16 15:59:53 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e2df9fb554 Bump ruff from 0.15.21 to 0.15.22 (#17628)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 15:43:43 -04:00
Jonathan SwobodaandGitHub 4507d05822 [http_request] Fix use-after-return of header collection state in IDF backend (#17627) 2026-07-16 15:34:49 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3caae3031a Bump github/codeql-action/init from 4.37.0 to 4.37.1 (#17629)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 15:33:38 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a624659856 Bump github/codeql-action/analyze from 4.37.0 to 4.37.1 (#17630)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 15:33:30 -04:00
Jonathan SwobodaandGitHub 6c8d0050ab [esp32_ble_server] Validate descriptor write length before copying (#17626) 2026-07-16 15:32:44 -04:00
Jonathan SwobodaandGitHub 09da2766ed [bluetooth_proxy] Bound GATT characteristic/descriptor enumeration to allocated size (#17625) 2026-07-16 15:32:09 -04:00
72bd45538e [opentherm] Rename OpenthermData accessors that collide with vendor SDK type macros (#17611)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 15:25:45 -04:00
Jonathan SwobodaandGitHub 5b37049dff [midea] Restrict to platforms where MideaUART actually builds (#17613) 2026-07-16 15:23:46 -04:00
Jonathan SwobodaandGitHub c0e78a5574 [core] Improve framework mirror selection, download errors, and version parsing (#17615) 2026-07-16 15:22:19 -04:00
Jonathan SwobodaandGitHub f53394e46f [mlx90393] Compile out on BK72xx where the bundled library cannot build (#17614) 2026-07-16 15:14:18 -04:00
J. Nick KostonandGitHub 2218edf20c [web_server] Switch entity id to the new format and drop name_id (#17586) 2026-07-16 08:49:43 -10:00
J. Nick KostonandGitHub dcdd044234 [web_server] Use alarm_control_panel as the domain in JSON (#17594) 2026-07-16 08:48:47 -10:00
J. Nick KostonandGitHub bc8510c6d8 [micro_wake_word] Include the local model file in bundles (#17604) 2026-07-16 08:09:30 -04:00
J. Nick KostonandGitHub 5b4ae22f58 [api] Don't spam tracebacks when decoding a crash without a local build (#17597) 2026-07-16 08:08:28 -04:00
J. Nick KostonandGitHub 14e71e190c [ci] Restore memory impact detail for ESP-IDF builds (#17587) 2026-07-16 08:00:19 -04:00
Guanzhong ChenandGitHub 2333e6eef5 [zephyr] implement ISRInternalGPIOPin::digital_write (#17601) 2026-07-16 07:57:30 -04:00
Jonathan SwobodaandGitHub d748c04b28 [libretiny] Code fixes for upcoming LibreTiny clang-tidy scans (#17596) 2026-07-16 07:25:46 -04:00
Jonathan SwobodaandGitHub 8d1a0446a2 [haier][teleinfo][hlk_fm22x][rp2040_ble] Rename enum members that collide with vendor SDK macros (#17595) 2026-07-16 07:25:37 -04:00
0887e01828 [improv_base] Bump Improv library to 1.2.6 (#17599)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 05:17:01 +00:00
J. Nick KostonandGitHub f88621f2e1 [network] Remove deprecated IPAddress::str() (#17589) 2026-07-16 05:12:06 +00:00
J. Nick KostonandGitHub ec3f7ec16e [api] Remove outdated API version warning (#17593) 2026-07-16 05:07:26 +00:00
J. Nick KostonandGitHub 880cb1db43 [voice_assistant] Remove deprecated Timer::to_string() (#17591) 2026-07-16 05:06:49 +00:00
J. Nick KostonandGitHub f336c45177 [water_heater] Remove deprecated WaterHeaterCall::get_state() (#17592) 2026-07-16 05:06:38 +00:00
J. Nick KostonandGitHub b12d392e02 [esp32_ble] Remove deprecated ESPBTUUID::to_string() (#17590) 2026-07-16 05:06:30 +00:00
49a1d2bb1b [veml3235] Overhaul auto-gain and fix latent config bugs (#17551)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 23:08:14 -05:00
J. Nick KostonandGitHub e327a7f52f [scheduler] Remove deprecated set_retry/cancel_retry (#17585) 2026-07-15 17:58:41 -10:00
Jonathan SwobodaandGitHub 90e838d327 [ci] Extend variant clang-tidy scans to USB logger, tsens and LP peripheral code (#17580) 2026-07-15 23:07:34 -04:00
Jonathan SwobodaandGitHub f91aa7aa13 [as3935_i2c] Use repeated start when reading registers (#17584) 2026-07-16 14:26:23 +12:00
lsellensandGitHub be3b27f378 [rc522_i2c] Change default address to match whats in the docs (#17566) 2026-07-15 21:20:07 -04:00
Jesse HillsandGitHub 3439a08b68 [deep_sleep] Add on_wake automation triggers (#17569) 2026-07-15 21:19:41 -04:00
dedca344f9 [aqi] Add extended_range option for over-range AQI values (#17570)
Co-authored-by: jas <jas@asspa.in>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:17:58 -04:00
Hajo NoerenbergandGitHub c4975e1870 [cc1101] Add FOCCFG and BSCFG config options (#17577) 2026-07-15 21:17:39 -04:00
Jonathan SwobodaandGitHub 5a3c2f4d11 [ci] Add rp2 clang-tidy environment (#17486) 2026-07-15 21:17:21 -04:00
Jonathan SwobodaandGitHub e700b01406 [haier] Fix outdoor defrost temperature reporting the coil temperature (#17492) 2026-07-15 21:16:38 -04:00
Jonathan SwobodaandGitHub d53d4c5b58 [emc2101] Fix negative external temperatures reported as large positives (#17494) 2026-07-15 21:16:24 -04:00
Jesse Hills 3c34fc950e Merge branch 'release' into dev 2026-07-16 12:59:58 +12:00
Jesse Hills b0489e21b2 Merge branch 'beta' into dev 2026-07-16 09:45:16 +12:00
Jesse HillsandGitHub a3ab7961e0 [core] Fix wait_until crash when re-entered from its own continuation (#17571) 2026-07-16 08:14:55 +12:00
Hajo NoerenbergandGitHub 54987d7d23 [cc1101] Export CC1101Listener to Python (#17576) 2026-07-15 12:38:08 -04:00
esphome[bot]GitHubesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
053ce39fc6 Bump bundled esphome-device-builder to 1.6.1 (#17575)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-07-15 08:51:50 -04:00
Jesse HillsandGitHub b295b8d5a2 [api] Warn when Home Assistant actions are sent with no subscribed client (#17560) 2026-07-15 23:11:47 +12:00
esphome[bot]andGitHub 4d3d06959b Bump bundled esphome-device-builder to 1.6.0 (#17573) 2026-07-14 23:59:19 -10:00
Jesse HillsandGitHub a5583dcba6 [tests] Add test_display component to free touchscreen tests from display pins (#17540) 2026-07-15 11:22:47 +12:00
luar123andGitHub e2b62bcd00 [zigbee] bump esp-zigbee-sdk to 2.0.3 (#17564) 2026-07-14 17:27:17 -04:00
Jonathan SwobodaandGitHub 4275343231 [atc_mithermometer] Make duplicate-packet counter per-instance (#17496) 2026-07-15 09:23:37 +12:00
dependabot[bot]andGitHub 4ebe49b141 Bump clang-tidy from 22.1.7 to 22.1.8 (#17565)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 09:54:02 -10:00
Jesse Hills d3adb6fb81 Merge branch 'beta' into dev 2026-07-14 16:46:31 +12:00
esphome[bot]andGitHub 2753ab1f45 Bump bundled esphome-device-builder to 1.5.0 (#17558) 2026-07-13 16:50:08 -10:00
b6b5b61640 [mcp23017] reset IPOL registers to 0x00 on setup (#17177)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-07-14 02:38:07 +00:00
Jesse HillsandGitHub db9a09d05a [script] Recursively expand nested packages when merging component tests (#17557) 2026-07-14 14:11:03 +12:00
Keith BurzinskiandGitHub ca77cc585c [nextion] Fix unbounded queue growth and OOM crash when display sends no data (#17553) 2026-07-14 11:56:03 +12:00
Jonathan SwobodaandGitHub f1e4726f4e [veml7700][as7341][ltr501] Fix device class on raw-count sensors (#17549) 2026-07-14 11:53:31 +12:00
J. Nick KostonandGitHub 49bbceb1da [libretiny] Keep renamed board generic-ln882hki validating against generic-ln882h (#17542) 2026-07-14 11:51:54 +12:00
Jesse HillsandGitHub 65d6c028ce [web_server] Add HTTP digest authentication with selectable scheme (#17541) 2026-07-14 10:33:44 +12:00
Clyde StubbsandGitHub 5e3e2f82c9 [script] Fix duplicate import in build_codeowners.py (#17543) 2026-07-13 10:16:53 -04:00
Jesse HillsandGitHub d78cb09b17 [web_server] Use dict-style packages in tests so they can be batch-grouped (#17544) 2026-07-13 23:23:55 +12:00
Keith BurzinskiandGitHub bcfb438a81 [esp32] Do not require verification_key with Secure Boot V2 signing schemes (#17497) 2026-07-13 16:23:40 +12:00
Jesse HillsandGitHub a8dfd00cc6 [web_server] Add CORS origin checking with allowed_origins (#17530) 2026-07-13 15:38:48 +12:00
Jesse HillsandGitHub 4a82b10783 [ci] Group component test output into collapsible CI log sections (#17536) 2026-07-13 15:29:35 +12:00
Jesse HillsandGitHub 07460ebee4 [gsl3670] Fix i2c package variant in esp32-s3-idf test (#17535) 2026-07-13 15:28:55 +12:00
Jesse Hills cdc42e5e55 Merge branch 'beta' into dev 2026-07-13 13:25:21 +12:00
Jonathan SwobodaandGitHub b3e03868b3 [mipi_rgb] Test in isolation to avoid bus/pin merge conflicts (#17534) 2026-07-13 12:00:22 +12:00
Jesse HillsandGitHub 0ff11674ef [mipi_rgb] Use dict-style packages in test so it can be batch-grouped (#17533) 2026-07-12 18:38:38 -04:00
Keith BurzinskiandGitHub 434cffb745 [zwave_proxy] Fix parser gaps and harden frame and subscription handling (#17461) 2026-07-13 08:58:17 +12:00
J. Nick KostonandGitHub 2b3027a7fd [api] Provision encryption keys over an encrypted zero-PSK noise connection (#17482) 2026-07-13 08:55:30 +12:00
Jesse HillsandGitHub e27a14ec70 [core] Classify entity metadata visibility for the visual editor (#17503) 2026-07-12 08:33:29 -04:00
luar123andGitHub 91c42381f6 [zigbee] prevent task watchdog trigger with large configs. (#17506) 2026-07-12 08:32:01 -04:00
luar123andGitHub a39607476a [zigbee] Fix merge endpoint (#17511) 2026-07-12 08:31:39 -04:00
J. Nick KostonandGitHub b098571a6f [web_server] Fix unused function warning for json_state_str (#17524) 2026-07-11 14:31:14 -10:00
J. Nick KostonandGitHub 614fd88829 [mdns] Fix missing device info TXT records when native API is not enabled (#17520) 2026-07-11 14:31:01 -10:00
J. Nick KostonandGitHub c0636e2bf7 [core] Make config-hash independent of machine-local paths (#17523) 2026-07-11 14:30:47 -10:00
esphome[bot]andGitHub 65353006c8 Bump bundled esphome-device-builder to 1.4.3 (#17522) 2026-07-11 13:32:45 -10:00
tomaszduda23andGitHub 35a99f478e [deep_sleep] feed watchdog in deep sleep (#17516) 2026-07-11 09:48:11 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b6a4dd237e Update tzdata requirement from >=2026.2 to >=2026.3 (#17510)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 09:45:42 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0ef85783dc Bump actions/stale from 10.3.0 to 10.4.0 (#17509)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 09:45:23 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5020179210 Bump ruff from 0.15.20 to 0.15.21 (#17508)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 09:45:02 -04:00
54529412dc [mipi_dsi] New model for M5Stack Tab5 (#17500)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:38:01 -04:00
Clyde StubbsGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
e6525b5d93 [mipi][mipi_spi] SWRESET handling improved (#17504)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-07-11 09:36:26 -04:00
RaymondandGitHub 9f62cf9243 [mipi_dsi] Add JC8012P4A1-V2 (#17457) 2026-07-11 21:24:00 +10:00
esphome[bot]andGitHub 478bca026c Bump bundled esphome-device-builder to 1.4.2 (#17512) 2026-07-10 16:37:00 -10:00
e1719cd85d [mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:06:40 -04:00
esphome[bot]andGitHub 83aaed71e1 Bump bundled esphome-device-builder to 1.4.1 (#17507) 2026-07-10 10:30:19 -10:00
Jesse HillsandGitHub 88ca0d44e0 [docs] Document web server as an open HTTP API by design in threat model (#17465) 2026-07-10 16:38:23 +12:00
esphome[bot]andGitHub 7a1e0bbbec Bump bundled esphome-device-builder to 1.4.0 (#17495) 2026-07-09 15:36:42 -10:00
Jonathan SwobodaandGitHub b2226b91ff [web_server] Serialize entity state strings without a copy buffer (#17488) 2026-07-09 16:51:04 -04:00
dependabot[bot]andGitHub ba84f2ec55 Bump aioesphomeapi from 45.5.2 to 45.6.0 (#17490)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 19:53:42 +00:00
dependabot[bot]andGitHub 4b19de0c1a Bump CodSpeedHQ/action from 4.18.4 to 4.18.5 (#17489)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 19:35:16 +00:00
Jonathan SwobodaandGitHub 4292e7988e [mcp4461] Fix wiper increment/decrement write length (#17487) 2026-07-09 15:27:05 -04:00
Jonathan SwobodaandGitHub 9f21fd0b55 [usb_uart] Fix output chunk length truncated to 8 bits (#17480) 2026-07-09 15:04:42 -04:00
Jesse Hills 9327d011fc Merge branch 'beta' into dev 2026-07-09 16:54:49 +12:00
Jonathan SwobodaandGitHub 435dde67d0 [ci] Stop per-PR cache copies from crowding the 10GB Actions cache quota (#17463) 2026-07-08 23:29:23 -04:00
Jonathan SwobodaandGitHub 9c92ab63fb [gsl3670] Reference the test display explicitly so grouped CI builds validate (#17462) 2026-07-09 13:43:56 +12:00
Elvin LuffandGitHub acc8381cbb [epaper_spi] Remove noop deep sleep command (#15595) 2026-07-09 11:29:39 +10:00
Jesse Hills 0a8a7e22d2 Bump version to 2026.8.0-dev 2026-07-09 12:30:54 +12:00
kbx81 f8bec0813d fix 2026-03-13 16:48:56 -05:00
kbx81 84762e6ae0 oops 2026-03-13 16:46:13 -05:00
kbx81 2edf313ee3 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-03-13 16:45:23 -05:00
kbx81 ae9c999052 fix 2026-02-28 23:21:30 -06:00
kbx81 7d2f6fbf55 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-28 23:12:31 -06:00
kbx81 608bef86cc Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-26 23:42:43 -06:00
kbx81 6514dc2fe1 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-26 20:55:50 -06:00
kbx81 240afd23b3 ... 2026-02-26 14:31:17 -06:00
kbx81 156c2a8cb0 optimize 2026-02-26 14:30:31 -06:00
kbx81 908c47bb5e preen, tune 2026-02-25 23:28:44 -06:00
kbx81 6df3a30740 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-25 17:33:27 -06:00
kbx81 0aaf59dbed Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-24 16:51:04 -06:00
kbx81 249c5bb724 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-23 18:01:56 -06:00
kbx81 54ea8dd207 Merge remote-tracking branch 'upstream/dev' into 20260218-zigbee-proxy 2026-02-19 18:31:15 -06:00
puddly 4cfb794b62 WIP 2026-02-19 18:22:03 -05:00
kbx81 917af8ff31 [zigbee_proxy] New component 2026-02-19 14:34:29 -06:00
819 changed files with 52861 additions and 12833 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ contact_links:
url: https://github.com/esphome/esphome-webserver/issues/new/choose
about: Report an issue with the ESPHome web server.
- name: Report an issue with the ESPHome Builder / Dashboard
url: https://github.com/esphome/dashboard/issues/new/choose
url: https://github.com/esphome/device-builder/issues/new/choose
about: Report an issue with the ESPHome Builder / Dashboard.
- name: Report an issue with the ESPHome API client
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
+5
View File
@@ -6,6 +6,7 @@
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
@@ -20,6 +21,10 @@
- esphome/esphome.io#<esphome.io PR number goes here>
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
## Test Environment
- [ ] ESP32
+4 -2
View File
@@ -3,8 +3,10 @@ description: >
Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
(toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
natively (clang-tidy for IDF/Arduino and the component test batches) shares
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", so all toolchains are present regardless of the chip).
one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", and _get_configured_targets() in espidf/toolchain.py
skips per-variant narrowing whenever CI is set, so all toolchains are
present regardless of the chip a job builds.
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
Python venv already restored.
inputs:
+2 -2
View File
@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
@@ -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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -13,6 +13,7 @@ module.exports = {
'merging-to-release',
'merging-to-beta',
'chained-pr',
'stacked-pr',
'core',
'small-pr',
'medium-pr',
@@ -22,11 +23,13 @@ module.exports = {
'has-tests',
'needs-tests',
'needs-docs',
'needs-developer-docs',
'needs-codeowners',
'too-big',
'labeller-recheck',
'bugfix',
'new-feature',
'new-feature-developer',
'breaking-change',
'developer-breaking-change',
'undocumented-api-change',
@@ -40,5 +43,17 @@ module.exports = {
// Keep matching the old esphome-docs name during the transition period
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
/esphome\/esphome-docs#\d+/
],
DEVELOPER_DOCS_PR_PATTERNS: [
/https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
/esphome\/developers\.esphome\.io#\d+/
],
// Files whose developer-facing changes are documented via Python docstrings
// only - developers.esphome.io has no reference page for them yet, so PRs
// touching nothing but these files (and tests/) skip needs-developer-docs.
DEV_DOCS_EXEMPT_FILES: [
'esphome/config_validation.py'
]
};
+60 -4
View File
@@ -1,4 +1,4 @@
const { DOCS_PR_PATTERNS } = require('./constants');
const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
const {
COMPONENT_REGEX,
detectComponents,
@@ -33,8 +33,41 @@ async function fetchPrFileContent(github, context, path) {
}
}
// Check whether a pull request is part of a GitHub stack.
//
// GitHub's stacked pull request feature adds a `stack` object to the pull
// request resource. It is present on every pull request in the stack -
// including the bottom one, whose base is already `dev` - and is absent
// entirely on standalone pull requests.
//
// The `pull_request_target` webhook payload is not guaranteed to carry this
// field, so fall back to asking the API when it is missing. Guessing wrong
// here is costly: a stacked pull request mistaken for a manually chained one
// gets a label that blocks merging.
async function isStackedPr(github, context) {
const pr = context.payload.pull_request;
if (pr.stack != null) {
return true;
}
try {
const { owner, repo } = context.repo;
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
return data.stack != null;
} catch (error) {
// Treat an API failure as "not stacked" so a chained pull request still
// gets its blocking label rather than silently slipping through.
console.log('Failed to check stack membership:', error.message);
return false;
}
}
// Strategy: Merge branch detection
async function detectMergeBranch(context) {
async function detectMergeBranch(github, context) {
const labels = new Set();
const baseRef = context.payload.pull_request.base.ref;
@@ -42,7 +75,11 @@ async function detectMergeBranch(context) {
labels.add('merging-to-release');
} else if (baseRef === 'beta') {
labels.add('merging-to-beta');
} 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') {
// A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr');
}
@@ -245,6 +282,7 @@ async function detectPRTemplateCheckboxes(context) {
const checkboxPatterns = [
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
{ pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
@@ -355,12 +393,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
const labels = new Set();
// Check for missing tests
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
labels.add('needs-tests');
}
// Check for missing docs.
// `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
// `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
// deliberately excluded here: its docs live on developers.esphome.io and are
// checked separately below. `new-component` / `new-platform`
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
// i.e. the new component/platform is actually loadable from YAML.
const docsEligible =
@@ -376,6 +416,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
}
}
// Check for missing developer docs. `new-feature-developer` requires a
// developers.esphome.io PR link, unless every changed file outside tests/ is
// in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
if (allLabels.has('new-feature-developer')) {
const prBody = context.payload.pull_request.body || '';
const nonTestFiles = prFiles
.map(file => file.filename)
.filter(file => !file.startsWith('tests/'));
const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
if (!onlyExemptFiles && !hasDevDocsLink) {
labels.add('needs-developer-docs');
}
}
// Check for missing CODEOWNERS
if (allLabels.has('new-component')) {
const codeownersModified = prFiles.some(file =>
+2 -2
View File
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
// Early exit for release and beta branches only
if (baseRef === 'release' || baseRef === 'beta') {
const branchLabels = await detectMergeBranch(context);
const branchLabels = await detectMergeBranch(github, context);
const finalLabels = Array.from(branchLabels);
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
@@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => {
deprecatedResult,
maintainerAccess
] = await Promise.all([
detectMergeBranch(context),
detectMergeBranch(github, context),
detectComponentPlatforms(changedFiles, apiData),
detectNewComponents(github, context, prFiles),
detectNewPlatforms(github, context, prFiles, apiData),
@@ -1,6 +1,14 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors');
const {
detectMergeBranch,
detectNewPlatforms,
detectNewComponents,
detectPRSize,
detectPRTemplateCheckboxes,
detectRequirements,
} = require('../detectors');
const { MANAGED_LABELS } = require('../constants');
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
// to check for CONFIG_SCHEMA in newly added files.
@@ -29,6 +37,107 @@ const API_DATA = {
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
// ---------------------------------------------------------------------------
// detectMergeBranch
// ---------------------------------------------------------------------------
// 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 } = {}) {
const pull_request = { number: 1, base: { ref: baseRef } };
if (stack !== undefined) {
pull_request.stack = stack;
}
return {
repo: { owner: 'esphome', repo: 'esphome' },
payload: { pull_request }
};
}
// A GitHub API mock exposing only rest.pulls.get, with a call counter so
// tests can assert whether the API fallback was actually invoked.
function makeStackGithub({ stack = null, error = null } = {}) {
const state = { calls: 0 };
const github = {
rest: {
pulls: {
get: async () => {
state.calls++;
if (error) throw error;
return { data: { stack } };
}
}
}
};
return { github, state };
}
const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 };
describe('detectMergeBranch', () => {
it('base ref release adds merging-to-release only and never checks the stack', async () => {
const { github, state } = makeStackGithub({ stack: STACK_INFO });
const context = makeMergeContext('release', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']);
assert.equal(state.calls, 0);
});
it('base ref beta adds merging-to-beta only and never checks the stack', async () => {
const { github, state } = makeStackGithub({ stack: STACK_INFO });
const context = makeMergeContext('beta', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']);
assert.equal(state.calls, 0);
});
it('stack present on the webhook payload adds stacked-pr without calling the API', async () => {
const { github, state } = makeStackGithub();
const context = makeMergeContext('feature-branch', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
assert.equal(state.calls, 0);
});
it('stack absent from payload falls back to the API and adds stacked-pr', async () => {
const { github, state } = makeStackGithub({ stack: STACK_INFO });
const context = makeMergeContext('feature-branch');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
assert.equal(state.calls, 1);
});
it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => {
const { github, state } = makeStackGithub();
const context = makeMergeContext('dev', { stack: STACK_INFO });
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
assert.equal(state.calls, 0);
});
it('not stacked, base ref not dev adds chained-pr', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('feature-branch');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
});
it('not stacked, base ref dev adds no labels', async () => {
const { github } = makeStackGithub({ stack: null });
const context = makeMergeContext('dev');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), []);
});
it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => {
const { github, state } = makeStackGithub({ error: new Error('API unavailable') });
const context = makeMergeContext('feature-branch');
const labels = await detectMergeBranch(github, context);
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
assert.equal(state.calls, 1);
});
});
// ---------------------------------------------------------------------------
// detectNewPlatforms
// ---------------------------------------------------------------------------
@@ -146,6 +255,125 @@ describe('detectNewComponents', () => {
});
});
// ---------------------------------------------------------------------------
// detectPRTemplateCheckboxes
// ---------------------------------------------------------------------------
const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
function makeBodyContext(body) {
return { payload: { pull_request: { body } } };
}
describe('detectPRTemplateCheckboxes', () => {
it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
assert.ok(labels.has('new-feature-developer'));
assert.ok(!labels.has('new-feature'));
});
it('unticked developer-facing feature checkbox adds no label', async () => {
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
assert.ok(!labels.has('new-feature-developer'));
});
it('ticked new feature checkbox does not add new-feature-developer', async () => {
const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
assert.ok(labels.has('new-feature'));
assert.ok(!labels.has('new-feature-developer'));
});
});
// ---------------------------------------------------------------------------
// detectRequirements
// ---------------------------------------------------------------------------
describe('detectRequirements', () => {
// PR body without any docs-PR link.
const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
// File sets: a normal source change vs. one confined to the exempt core validators.
const SOURCE_FILES = [
{ filename: 'esphome/components/foo/foo.py' },
{ filename: 'tests/components/foo/common.yaml' },
];
const VALIDATOR_FILES = [
{ filename: 'esphome/config_validation.py' },
{ filename: 'tests/unit_tests/test_config_validation.py' },
];
it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-tests'));
assert.ok(!labels.has('needs-docs'));
});
it('new-feature-developer with has-tests does not add needs-tests', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
assert.ok(!labels.has('needs-tests'));
});
it('new-feature without a docs link still adds needs-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-docs'));
});
it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-developer-docs'));
});
it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
assert.ok(!labels.has('needs-developer-docs'));
});
it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
assert.ok(!labels.has('needs-developer-docs'));
});
it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-developer-docs'));
});
it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-docs'));
});
it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
assert.ok(!labels.has('needs-developer-docs'));
});
it('validator changes mixed with other source files are not exempt', async () => {
const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-developer-docs'));
});
});
// ---------------------------------------------------------------------------
// MANAGED_LABELS
// ---------------------------------------------------------------------------
describe('MANAGED_LABELS', () => {
it('includes new-feature-developer so the workflow syncs it', () => {
assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
});
it('includes needs-developer-docs so the workflow syncs it', () => {
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
});
});
// ---------------------------------------------------------------------------
// detectPRSize
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate a token
id: generate-token
@@ -35,7 +35,7 @@ jobs:
# Scope the minted App token to the minimum needed by auto-label-pr/*.js.
permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
- name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+3 -3
View File
@@ -21,15 +21,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- 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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
+12 -10
View File
@@ -61,9 +61,9 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
push: ${{ steps.tag.outputs.push }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
@@ -71,11 +71,13 @@ jobs:
- name: Determine tag and whether to push
id: tag
env:
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
# Sanitize the branch name into a valid docker tag: replace invalid
# characters, ensure the first character is valid (tags must start
# with [A-Za-z0-9_]), and cap the length at 128 characters.
branch="${{ github.head_ref || github.ref_name }}"
branch="$HEAD_REF"
tag="${branch//[^a-zA-Z0-9_.-]/-}"
case "$tag" in
[a-zA-Z0-9_]*) ;;
@@ -96,7 +98,7 @@ jobs:
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -145,16 +147,16 @@ jobs:
- "ha-addon"
- "docker"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to the GitHub container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -180,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
@@ -202,7 +204,7 @@ jobs:
- nrf52
- host
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run tests
working-directory: .github/scripts/auto-label-pr
@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo
+337 -292
View File
@@ -28,13 +28,13 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate cache-key
id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
@@ -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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -65,202 +65,9 @@ jobs:
python -m venv venv
. venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
pylint:
name: Check pylint
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run pylint
run: |
. venv/bin/activate
pylint -f parseable --persistent=n esphome
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
ci-custom:
name: Run script/ci-custom
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
- name: Run script/ci-custom
run: |
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
script/ci_check_duplicate_test_ids.py
import-time:
name: Check import esphome.__main__ time
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Check import time against budget and write waterfall HAR
run: |
. venv/bin/activate
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out esphome (this PR)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Set up uv
# Mirrors the install shape device-builder's own CI uses
# (esphome/device-builder#192): uv replaces pip for the
# 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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
# overlay the PR's esphome so the downstream tests run
# against this PR's Python code. ``--system`` installs into
# the runner's Python instead of a venv.
run: |
uv pip install --system -e './device-builder[esphome,test]'
uv pip install --system -e ./esphome
- name: Run device-builder pytest
# ``-n auto`` runs under pytest-xdist (matches device-builder's
# own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code. ``tests/e2e/slow``
# is excluded: those are real multi-minute toolchain compiles
# (LibreTiny SDK clone, native ESP-IDF install) that device-builder
# runs in its own dedicated jobs, not this smoke check.
working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
determine-jobs:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -291,7 +98,7 @@ jobs:
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -350,6 +157,169 @@ jobs:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
ci-custom:
name: Run script/ci-custom
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
- name: Run script/ci-custom
run: |
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
script/ci_check_duplicate_test_ids.py
script/ci_check_test_fixture_list_form.py
pylint:
name: Check pylint
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run pylint
run: |
. venv/bin/activate
pylint -f parseable --persistent=n esphome
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
lint-format:
name: Check lint and formatting
runs-on: ubuntu-latest
needs:
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with:
# Keep in sync with requirements_test.txt.
prek-version: "0.4.11"
# This job only runs on pull requests, so nothing ever populates
# the cache on dev. Every run would miss and then write a per-pull
# request copy, which is what the old seed-cache job existed to
# avoid. Building the hooks from scratch takes seconds, so skip it.
cache: false
env:
PREK_SKIP: pylint,ci-custom
# Pushes any fixes the hooks made back to the pull request. This step
# must keep its default name: the GitHub App that performs the push
# locates the workflow run by that name.
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
with:
msg: apply automatic formatting fixes
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
codecov-empty-upload:
name: Report no coverage to Codecov
runs-on: ubuntu-24.04
needs:
- determine-jobs
# ``pytest`` is the only job that uploads coverage, and it is skipped when
# every changed file is CI-irrelevant (see ``should_run_core_ci`` in
# ``script/determine-jobs.py``). With no upload Codecov never reports a
# result, so the required ``codecov/patch`` status stays pending forever and
# the pull request can never be merged. Tell Codecov up front that this
# commit has nothing to cover so it publishes a passing status instead.
#
# ``force`` skips Codecov's own check that every changed file is ignorable;
# ``determine-jobs`` has already decided none of these files can affect
# coverage, and Codecov would otherwise fail the status for paths it does
# not recognise as non-testable (``docker/**``, ``.yamllint``).
if: needs.determine-jobs.outputs.core-ci == 'false'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Report empty upload to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
run_command: empty-upload
force: true
fail_ci_if_error: true
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
@@ -363,10 +333,28 @@ jobs:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
- name: Set up Python 3.13
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Restore Python virtual environment
@@ -378,7 +366,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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
@@ -408,33 +396,46 @@ jobs:
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
- name: Print ccache statistics
# 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 }}
cpp-unit-tests:
name: Run C++ unit tests
import-time:
name: Check import esphome.__main__ time
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run cpp_unit_test.py
- name: Check import time against budget and write waterfall HAR
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
benchmarks:
name: Run CodSpeed benchmarks
@@ -447,7 +448,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -465,7 +466,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4
uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2
with:
run: |
. venv/bin/activate
@@ -473,6 +474,33 @@ jobs:
pytest tests/benchmarks/python/ --codspeed --no-cov
mode: simulation
cpp-unit-tests:
name: Run C++ unit tests
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run cpp_unit_test.py
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
@@ -488,7 +516,6 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- id: clang-tidy
@@ -504,10 +531,19 @@ jobs:
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
cache_sdk_nrf: true
ignore_errors: false
- id: clang-tidy
name: Run script/clang-tidy for RP2
options: --environment rp2-tidy --grep USE_RP2
pio_cache_key: tidyrp2
- id: clang-tidy
name: Run script/clang-tidy for LibreTiny
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
pio_cache_key: tidylibretiny
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -567,10 +603,21 @@ jobs:
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
changed=""
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
changed="--changed"
fi
if [ -n "${{ matrix.environments }}" ]; then
rc=0
for env in ${{ matrix.environments }}; do
echo "::group::clang-tidy $env"
script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1
echo "::endgroup::"
done
exit $rc
else
script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
@@ -594,7 +641,7 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -659,7 +706,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
@@ -674,7 +720,7 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -739,12 +785,12 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 S3
options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
@@ -754,11 +800,11 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
# yamllint disable-line rule:line-length
options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -825,7 +871,7 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
matrix:
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps:
@@ -845,7 +891,7 @@ jobs:
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -990,7 +1036,7 @@ jobs:
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -1016,69 +1062,62 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
pre-commit-seed-cache:
name: Seed pre-commit cache
runs-on: ubuntu-latest
needs:
- common
# Saves a dev-scoped pre-commit cache that pull request runs can
# restore, since pre-commit.ci lite itself never runs on dev pushes.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
- name: Check out esphome (this PR)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Inlined from esphome/pre-commit-action with a restore-only cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env:
SKIP: pylint,ci-custom
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
# Mirrors the install shape device-builder's own CI uses
# (esphome/device-builder#192): uv replaces pip for the
# 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
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
# overlay the PR's esphome so the downstream tests run
# against this PR's Python code. ``--system`` installs into
# the runner's Python instead of a venv.
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
uv pip install --system -e './device-builder[esphome,test]'
uv pip install --system -e ./esphome
- name: Run device-builder pytest
# ``-n auto`` runs under pytest-xdist (matches device-builder's
# own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code. ``tests/e2e/slow``
# is excluded: those are real multi-minute toolchain compiles
# (LibreTiny SDK clone, native ESP-IDF install) that device-builder
# runs in its own dedicated jobs, not this smoke check.
working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
memory-impact-target-branch:
name: Build target branch for memory impact
@@ -1094,7 +1133,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps:
- name: Check out target branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.base_ref }}
@@ -1276,7 +1315,7 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1345,7 +1384,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1379,21 +1418,27 @@ jobs:
ci-status:
name: CI Status
runs-on: ubuntu-24.04
# Listed in the same order the jobs are defined above. One job is
# deliberately left out: "benchmarks" reports through CodSpeed rather than
# this check.
needs:
- common
- determine-jobs
- ci-custom
- pylint
- lint-format
- pytest
- codecov-empty-upload
- integration-tests
- import-time
- cpp-unit-tests
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- clang-tidy-esp32-variants
- determine-jobs
- device-builder
- test-build-components-split
- test-esp32-platformio
- pre-commit-ci-lite
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
- memory-impact-comment
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
+3 -3
View File
@@ -52,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5
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@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+11 -11
View File
@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get tag
id: tag
# yamllint disable rule:line-length
@@ -60,9 +60,9 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.x"
- name: Build
@@ -70,7 +70,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -92,9 +92,9 @@ jobs:
os: "ubuntu-24.04-arm"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -102,12 +102,12 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to docker hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -168,7 +168,7 @@ jobs:
- ghcr
- dockerhub
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -182,13 +182,13 @@ jobs:
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+35 -50
View File
@@ -6,61 +6,46 @@ on:
- cron: "30 0 * * *"
workflow_dispatch:
permissions:
issues: write # actions/stale labels, comments on, and closes stale issues
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
concurrency:
group: lock
# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
# needs no permissions at all.
permissions: {}
jobs:
stale:
if: github.repository_owner == 'esphome'
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
operations-per-run: 400
# 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
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
# Live only on dev: a workflow_dispatch from any other branch is a dry run
dry-run: ${{ github.ref != 'refs/heads/dev' }}
days-before-stale: 90
days-before-close: 7
stale-label: stale
exempt-label: not-stale
ignored-users: esphbot,codecov-commenter
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
# The 90 day stale policy for PRs
# - PRs
# - No PRs marked as "not-stale"
# - No Issues (see below)
days-before-pr-stale: 90
days-before-pr-close: 7
stale-pr-label: "stale"
exempt-pr-labels: "not-stale"
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Thank you for your contribution!
Please make sure to update to the latest ESPHome version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
# The 90 day stale policy for Issues
# - Issues
# - No Issues marked as "not-stale"
# - No PRs (see above)
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "not-stale"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest ESPHome version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
+2 -2
View File
@@ -5,7 +5,7 @@ on:
types: [opened, reopened, labeled, unlabeled, synchronize]
permissions:
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
@@ -20,7 +20,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
+17 -17
View File
@@ -28,26 +28,26 @@ jobs:
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Checkout Home Assistant
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: home-assistant/core
path: lib/home-assistant
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Set up uv
# An order of magnitude faster than pip on cold boots, with its
# own wheel cache. ``--system`` (below) installs into the
# setup-python interpreter so subsequent ``pre-commit`` /
# 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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -58,19 +58,19 @@ jobs:
- name: Install Home Assistant
run: |
uv pip install --system -e lib/home-assistant
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
uv pip install --system -r requirements.txt -r requirements_test.txt
- name: Sync
run: |
python ./script/sync-device_class.py
- name: Apply pre-commit auto-fixes
- name: Apply prek auto-fixes
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
# files. pre-commit exits non-zero whenever a hook touches anything,
# files. prek exits non-zero whenever a hook touches anything,
# which would otherwise abort the workflow before the auto-fixes
# can flow into the sync PR.
#
# SKIP:
# PREK_SKIP:
# - no-commit-to-branch is a local guard against committing on
# dev/release/beta; CI runs on dev by definition, and
# peter-evans/create-pull-request creates the branch itself.
@@ -79,25 +79,25 @@ jobs:
# the runtime deps (HA + requirements*.txt); main CI already
# gates pylint on real PRs.
env:
SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files || true
PREK_SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py prek run --all-files || true
- name: Verify pre-commit clean
- name: Verify prek clean
# Second pass: re-run all hooks against the now-fixed tree.
# Auto-fixers exit 0 (nothing to change); any remaining failure
# from a check-only hook (flake8 / yamllint / ci-custom) is a
# real issue and fails the workflow loudly. Same SKIP list as
# real issue and fails the workflow loudly. Same PREK_SKIP list as
# above for the same reasons.
env:
SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files
PREK_SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py prek run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>
author: esphomebot <esphome@openhomefoundation.org>
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
branch: sync/device-classes
delete-branch: true
title: "Synchronise Device Classes from Home Assistant"
+2
View File
@@ -133,6 +133,8 @@ CTestTestfile.cmake
.gcc-flags.json
config/
# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
!tests/component_tests/**/config/
tests/build/
tests/.esphome/
/.temp-clang-tidy.cpp
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.15
rev: v0.16.0
hooks:
# Run the linter.
- id: ruff
+63 -22
View File
@@ -191,11 +191,14 @@ This document provides essential context for AI models interacting with this pro
my_component_ns = cg.esphome_ns.namespace("my_component")
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -229,7 +232,12 @@ This document provides essential context for AI models interacting with this pro
- **Sensor:**
```python
from esphome.components import sensor
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
cv.polling_component_schema("60s")
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
@@ -238,7 +246,10 @@ This document provides essential context for AI models interacting with this pro
- **Binary Sensor:**
```python
from esphome.components import binary_sensor
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
```
@@ -246,7 +257,10 @@ This document provides essential context for AI models interacting with this pro
- **Switch:**
```python
from esphome.components import switch
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
CONFIG_SCHEMA = switch.switch_schema().extend({...})
async def to_code(config):
var = await switch.new_switch(config)
```
@@ -263,10 +277,13 @@ This document provides essential context for AI models interacting with this pro
```python
from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -316,11 +333,14 @@ This document provides essential context for AI models interacting with this pro
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema({
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
})
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
}
)
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
@@ -368,7 +388,10 @@ This document provides essential context for AI models interacting with this pro
```
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
* **Configuration Validation:**
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
@@ -381,6 +404,7 @@ This document provides essential context for AI models interacting with this pro
.extend(i2c.i2c_device_schema(0x48))
.extend(spi.spi_device_schema(cs_pin_required=True))
```
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
## 5. Key Files & Entrypoints
@@ -388,7 +412,7 @@ This document provides essential context for AI models interacting with this pro
* **Configuration:**
* `pyproject.toml`: Defines the Python project metadata and dependencies.
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
* `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
* `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`.
* **CI/CD Pipeline:** Defined in `.github/workflows`.
* **Static Analysis & Development:**
* `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.
@@ -396,7 +420,7 @@ This document provides essential context for AI models interacting with this pro
## 6. Development & Testing Workflow
* **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`.
* **Testing:**
* **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis.
@@ -469,9 +493,9 @@ This document provides essential context for AI models interacting with this pro
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
2. **Make Changes:** Adhere to all coding conventions and patterns.
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
4. **Lint:** Run `pre-commit` to ensure code is compliant.
4. **Lint:** Run `prek` to ensure code is compliant.
5. **Commit:** Commit your changes. There is no strict format for commit messages.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository.
@@ -617,6 +641,7 @@ This document provides essential context for AI models interacting with this pro
_component_state = []
_use_feature = None
def enable_feature():
global _use_feature
_use_feature = True
@@ -636,20 +661,24 @@ This document provides essential context for AI models interacting with this pro
DOMAIN = "my_component"
@dataclass
class MyComponentData:
feature_enabled: bool = False
item_count: int = 0
items: list[str] = field(default_factory=list)
def _get_data() -> MyComponentData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = MyComponentData()
return CORE.data[DOMAIN]
def request_feature() -> None:
_get_data().feature_enabled = True
def add_item(item: str) -> None:
_get_data().items.append(item)
```
@@ -704,10 +733,22 @@ This document provides essential context for AI models interacting with this pro
```
* **Deprecation Pattern (Python):**
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
```python
CONFIG_SCHEMA = cv.All(
cv.rename_key(
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
),
cv.Schema({ ... }),
)
```
For other deprecations, warn manually during validation:
```python
# Remove before 2026.6.0
if CONF_OLD_KEY in config:
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
_LOGGER.warning(
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
)
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
```
## 9. English Language
+10
View File
@@ -69,11 +69,14 @@ esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
esphome/components/bk72xx/* @kuba2k2
esphome/components/bk72xx_ble/* @Bl00d-B0b
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias-
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_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis
@@ -145,6 +148,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
@@ -284,6 +288,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
@@ -291,6 +296,8 @@ esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
@@ -345,6 +352,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
@@ -433,6 +441,7 @@ esphome/components/rp2/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz
esphome/components/rp2_ble_tracker/* @bdraco
esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet @ximex
@@ -623,6 +632,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zephyr_pwm/* @wiomoc
esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @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.7.0
PROJECT_NUMBER = 2026.8.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
View File
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
recursive-include esphome requirements.txt
+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.6.1
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.3
RUN \
platformio settings set enable_telemetry No \
+69 -51
View File
@@ -2,37 +2,31 @@
import argparse
from collections.abc import Callable
from contextlib import suppress
from datetime import datetime
import functools
import getpass
import importlib
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
from typing import Protocol
import argcomplete
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one.
from esphome import const
import esphome.codegen as cg
from esphome.config import iter_component_configs, read_config, strip_default_ids
from esphome import const, platform_hooks
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API,
CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG_TOPIC,
@@ -52,6 +46,8 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_ESP32,
KEY_VARIANT,
SECRETS_FILES,
Toolchain,
)
@@ -59,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
@@ -488,8 +485,6 @@ def has_web_server_ota() -> bool:
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
from esphome.components.mqtt import CONF_DISCOVER_IP
if CONF_MQTT not in CORE.config:
return False
# Default Enabled
@@ -622,6 +617,8 @@ def _resolve_network_devices(
def run_miniterm(config: ConfigType, port: str, args) -> int:
from datetime import datetime
from aioesphomeapi import LogParser
import serial
@@ -634,18 +631,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
return 1
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
process_stacktrace = module.process_stacktrace
except (AttributeError, ImportError):
_LOGGER.info(
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
CORE.target_platform,
)
backtrace_state = False
# Decoder resolution, crash isolation, and disable-after-failure
# all live in LogLineProcessor, shared with the API log path.
processor = LogLineProcessor(config, CORE.target_platform)
ser = serial.Serial()
ser.baudrate = baud_rate
ser.port = port
@@ -685,11 +673,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
"utf8", "backslashreplace"
)
safe_print(parser.parse_line(line, time_str))
if process_stacktrace is not None:
backtrace_state = process_stacktrace(
config, line, backtrace_state
)
processor.process_line(line)
except serial.SerialException:
_LOGGER.error("Serial port closed!")
return 0
@@ -704,6 +688,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
def _wrap_to_code(name, comp, yaml_util):
import esphome.codegen as cg
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
@@ -739,6 +725,7 @@ def write_cpp(config: ConfigType) -> int:
def generate_cpp_contents(config: ConfigType) -> None:
from esphome import yaml_util
from esphome.config import iter_component_configs
_LOGGER.info("Generating C++ source...")
@@ -776,6 +763,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
check_placeholder_credentials(config)
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
# working on machines that cannot run the toolchain.
if CORE.is_esp8266:
from esphome.components.esp8266 import check_rosetta
check_rosetta()
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -924,9 +918,10 @@ def upload_using_esptool(
mcu = "esp8266"
if CORE.is_esp32:
from esphome.components.esp32 import get_esp32_variant
mcu = get_esp32_variant().lower()
# Same lookup as esp32.get_esp32_variant(), read directly so the
# serial upload path does not import the esp32 package; both the
# validator and the warm-cache apply_to_core populate this key.
mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower()
line_callbacks: list[Callable[[str], str | None]] = []
if (
@@ -980,6 +975,8 @@ def upload_using_esptool(
def upload_using_platformio(config: ConfigType, port: str) -> int:
import shutil
from esphome.platformio import toolchain
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
@@ -1017,6 +1014,8 @@ def upload_using_picotool(config: ConfigType) -> int:
the mass storage copy approach that causes "disk not ejected properly"
warnings on macOS.
"""
import subprocess
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
@@ -1123,6 +1122,8 @@ def check_permissions(port: str):
"the USB cable can be used for data and is not a power-only cable."
)
if not (os.access(port, os.R_OK | os.W_OK)):
import getpass
raise EsphomeError(
"You do not have read or write permission on the selected serial port. "
"To resolve this issue, you can add your user to the dialout group "
@@ -1135,12 +1136,11 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if module.upload_program(config, args, host):
return 0, host
except AttributeError:
pass
platform_upload = platform_hooks.get_platform_hook(
CORE.target_platform, "upload_program"
)
if platform_upload is not None and platform_upload(config, args, host):
return 0, host
port_type = get_port_type(host)
@@ -1400,12 +1400,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool:
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if module.show_logs(config, args, devices):
return 0
except AttributeError:
pass
platform_show_logs = platform_hooks.get_platform_hook(
CORE.target_platform, "show_logs"
)
if platform_show_logs is not None and platform_show_logs(config, args, devices):
return 0
if "logger" not in config:
raise EsphomeError("Logger is not configured!")
@@ -1423,7 +1422,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
if has_api() and (
network_devices := _resolve_network_devices(devices, config, args)
):
from esphome.components.api.client import run_logs
from esphome.api_client import run_logs
return run_logs(
config,
@@ -1457,6 +1456,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
from esphome.config import strip_default_ids
if getattr(args, "no_defaults", False):
user_config = getattr(config, "user_config", None)
@@ -1510,10 +1510,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
m = _LEGACY_REDACTION_RE.search(line)
if m is None:
continue
key = m.group("key")
if not in_substitutions:
unmarked.add(m.group("key"))
# Public keys (e.g. wireguard's peer_public_key) are not secret;
# redacting them and telling maintainers to mark them cv.sensitive
# would be wrong on both counts. Substitution keys are user-named
# with no schema behind them, so anything secret-shaped there
# (public or not) stays conservatively redacted.
if "public" in key.split("_"):
continue
unmarked.add(key)
lines[i] = (
f"{line[: m.start()]}{m.group('key')}: "
f"{line[: m.start()]}{key}: "
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
)
output = "\n".join(lines)
@@ -1698,7 +1706,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
from esphome.bundle import ConfigBundleCreator
creator = ConfigBundleCreator(config)
@@ -2483,7 +2491,12 @@ def parse_args(argv):
# a deprecation warning).
arguments = argv[1:]
argcomplete.autocomplete(parser)
# argcomplete only does anything when the shell-completion machinery
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
if "_ARGCOMPLETE" in os.environ:
import argcomplete
argcomplete.autocomplete(parser)
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
args, unknown_args = parser.parse_known_args(arguments)
@@ -2543,10 +2556,11 @@ def run_esphome(argv):
return 0
# Bundle support: if the configuration is a .esphomebundle, extract it
# and rewrite conf_path to the extracted YAML config.
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
# and rewrite conf_path to the extracted YAML config. The suffix check
# stays inline so the ordinary run never imports esphome.bundle.
if conf_path.name.lower().endswith(BUNDLE_EXTENSION):
from esphome.bundle import prepare_bundle_for_compile
if is_bundle_path(conf_path):
_LOGGER.info("Extracting config bundle %s...", conf_path)
conf_path = prepare_bundle_for_compile(conf_path)
# Update the argument so downstream code sees the extracted path
@@ -2582,9 +2596,13 @@ def run_esphome(argv):
)
if config is None:
from esphome.config import read_config
config = read_config(
command_line_substitutions,
skip_external_update=skip_external,
# Snapshot only needed by `esphome config --no-defaults`.
snapshot_user_config=getattr(args, "no_defaults", False),
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
+8 -27
View File
@@ -20,6 +20,7 @@ from . import (
RAM_SECTIONS,
MemoryAnalyzer,
)
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
@@ -759,45 +760,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
# Find firmware.elf
elf_file = None
for elf_candidate in [
build_path / "firmware.elf",
build_path / ".pioenvs" / build_path.name / "firmware.elf",
]:
if elf_candidate.exists():
elf_file = str(elf_candidate)
break
if not elf_file:
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
elf_path = find_elf_path(build_path)
if not elf_path:
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
# Find idedata.json - check current directory first, then home
device_name = build_path.name
idedata_candidates = [
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
]
elf_file = str(elf_path)
idedata = None
for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
if idedata_path := find_idedata_path(build_path):
try:
with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
print(
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr,
)
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
+72
View File
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
]
def find_elf_path(build_path: Path) -> Path | None:
"""Locate the firmware ELF inside an ESPHome build directory.
The layout depends on the toolchain that produced the build, so try each
known one in turn.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the ELF file, or None if no known layout matches
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
build_path / "firmware.elf",
build_path / ".pioenvs" / name / "firmware.elf",
# LibreTiny uses raw_firmware.elf
build_path / "raw_firmware.elf",
build_path / ".pioenvs" / name / "raw_firmware.elf",
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
):
if candidate.is_file():
return candidate
return None
def idedata_candidates(build_path: Path) -> list[Path]:
"""Return the idedata locations searched for a build directory, in order.
Exposed so a caller reporting "not found" can name the paths it tried
without keeping its own copy of the list.
Args:
build_path: Path to an ESPHome build directory
Returns:
The candidate idedata JSON paths, most specific first
"""
name = build_path.name
return [
# In .pioenvs for test builds
build_path / ".pioenvs" / name / "idedata.json",
# Both toolchains cache it in the data dir, which holds this build dir:
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
build_path.parent.parent / "idedata" / f"{name}.json",
# Regular builds, invoked from the config dir or from anywhere
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
Path.home() / ".esphome" / "idedata" / f"{name}.json",
]
def find_idedata_path(build_path: Path) -> Path | None:
"""Locate the idedata JSON belonging to an ESPHome build directory.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the idedata JSON, or None if it was not found
"""
for candidate in idedata_candidates(build_path):
if candidate.is_file():
return candidate
return None
def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories.
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
import logging
from typing import TYPE_CHECKING, Any
import warnings
# Suppress protobuf version warnings
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
)
from aioesphomeapi import APIClient, parse_log_message
from aioesphomeapi.log_runner import async_run
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
from esphome.core import CORE
from esphome.stacktrace import LogLineProcessor
from esphome.util import safe_print
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import (
SubscribeLogsResponse, # pylint: disable=no-name-in-module
)
_LOGGER = logging.getLogger(__name__)
async def async_run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
) -> None:
"""Run the logs command in the event loop."""
from datetime import datetime
conf = config["api"]
name = config["esphome"]["name"]
port: int = int(conf[CONF_PORT])
noise_psk: str | None = None
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
noise_psk = key
_LOGGER.info(
"Starting log output from %s using esphome API", " or ".join(addresses)
)
cli = APIClient(
addresses[0], # Primary address for compatibility
port,
"", # Password auth removed in 2026.1.0
client_info=f"ESPHome Logs {__version__}",
noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
provide_time=False,
)
# Decoder resolution policy lives in LogLineProcessor.
processor = LogLineProcessor(config, CORE.target_platform)
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
time_ = datetime.now().astimezone()
message: bytes = msg.message
text = message.decode("utf8", "backslashreplace")
nanoseconds = time_.microsecond // 1000
timestamp = (
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
)
for parsed_msg in parse_log_message(text, timestamp):
# safe_print handles the dashboard \033 escaping and falls back
# to backslashreplace encoding on stdouts that can't represent
# the wifi signal-bar block characters (Windows redirected
# cp1252 pipe).
safe_print(parsed_msg)
for raw_line in text.splitlines():
processor.process_line(raw_line)
# Safe to fall back to plaintext here only for this diagnostics use
# case: the stream is one-way from device to client, and this code
# never accepts commands or acts on any message the device sends.
# An on-path attacker could still both inject fabricated log lines
# and passively read the device's log output (and any state data
# delivered when subscribe_states is enabled), so this does lose
# confidentiality as well as authentication/integrity. That tradeoff
# is acceptable for operator-visible logs, which aioesphomeapi also
# warns may come from an unverified device. Never mirror this opt-in
# for any connection that sends data to the device or uses Home
# Assistant actions.
stop = await async_run(
cli,
on_log,
name=name,
subscribe_states=subscribe_states,
allow_plaintext_fallback=True,
# 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,
)
try:
await asyncio.Event().wait()
finally:
await stop()
def run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
) -> None:
"""Run the logs command."""
with suppress(KeyboardInterrupt):
asyncio.run(
async_run_logs(config, addresses, subscribe_states=subscribe_states)
)
+111 -18
View File
@@ -11,43 +11,136 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from itertools import count
import logging
import threading
from typing import cast
_LOGGER = logging.getLogger(__name__)
# How long the orphan watcher waits for an abandoned coroutine before giving
# up, so a hung operation does not park a watcher thread forever.
ORPHAN_WAIT_TIMEOUT = 300.0
_runner_ids = count(1)
class AsyncDispatchTimeout(TimeoutError):
"""The caller stopped waiting; the coroutine was abandoned.
A subclass so callers can tell the dispatcher's own expiry apart from a
``TimeoutError`` raised inside the coroutine, while existing
``except TimeoutError`` handlers keep working.
"""
class AsyncThreadRunner[T](threading.Thread):
"""Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in
``exception`` so ``event`` is always set — this prevents callers waiting
on ``event`` from hanging forever when the coroutine crashes.
Typical usage::
runner = AsyncThreadRunner(lambda: my_coro(arg))
runner.start()
if not runner.event.wait(timeout=5.0):
... # timed out
if runner.exception is not None:
raise runner.exception
result = runner.result
``event`` is always set, even when the coroutine crashes, so waiters
never hang; ``completed`` distinguishes a delivered result (even a
legitimate ``None``) from a coroutine that never finished. Prefer
:func:`run_async`; use this class directly only when a failure should
degrade to a default value instead of raising.
"""
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
super().__init__(daemon=True)
super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}")
self._coro_factory = coro_factory
self.result: T | None = None
self.exception: BaseException | None = None
self.completed = False
self.event = threading.Event()
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
# Capture all exceptions so ``event`` is always set — otherwise a
# crash would hang the waiter forever.
# Distinguishes a delivered result from "never ran", since None
# is a valid result value.
self.completed = True
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
# Capture everything, including BaseException — otherwise a
# cancellation or SystemExit would leave ``exception`` unset and
# waiters would mistake the empty ``result`` for success.
self.exception = exc
finally:
self.event.set()
def run(self) -> None:
asyncio.run(self._runner())
try:
asyncio.run(self._runner())
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
# asyncio.run itself can fail before _runner executes (e.g. loop
# creation under fd exhaustion); record it so waiters never hang.
# A failure during loop cleanup after the coroutine completed
# must not clobber the delivered result, hence the guard.
if self.exception is None and not self.completed:
self.exception = exc
else:
_LOGGER.debug(
"Event loop teardown failed after outcome recorded",
exc_info=True,
)
finally:
self.event.set()
def run_async[T](
coro_factory: Callable[[], Awaitable[T]],
timeout: float | None = None,
on_orphan: Callable[[T], None] | None = None,
) -> T:
"""Run a coroutine in a daemon-thread event loop and return its result.
Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish
within ``timeout`` seconds; the thread is abandoned and exits with the
interpreter. If the abandoned coroutine later produces a result,
``on_orphan`` (if given) is called with it so resources such as a
connected socket can be released; delivery is best effort and bounded
by ``ORPHAN_WAIT_TIMEOUT``.
"""
runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory)
runner.start()
if not runner.event.wait(timeout):
def _cleanup() -> None:
if not runner.event.wait(ORPHAN_WAIT_TIMEOUT):
# The one state where a resource can genuinely leak; leave
# a trace so a recurring hang is attributable.
_LOGGER.info(
"Orphan watcher gave up after %.0fs; a late result may leak",
ORPHAN_WAIT_TIMEOUT,
)
return
if not runner.completed:
# The only place an abandoned thread's real error surfaces;
# without it a late failure hides behind the TimeoutError.
# INFO, not DEBUG: it fires at most once per abandoned
# operation and the cause may not reproduce on a rerun.
_LOGGER.info(
"Abandoned async operation failed",
exc_info=runner.exception,
)
return
if (result := runner.result) is None:
return
if on_orphan is None:
_LOGGER.debug("Discarding late result; no on_orphan handler")
return
try:
on_orphan(result)
except Exception: # pylint: disable=broad-except
# INFO, not DEBUG: a failed release means a real leak, and
# it fires at most once per abandoned operation.
_LOGGER.info("Error releasing orphaned result", exc_info=True)
threading.Thread(
target=_cleanup, daemon=True, name="async-orphan-cleanup"
).start()
raise AsyncDispatchTimeout("Timed out waiting for async operation")
if (exc := runner.exception) is not None:
raise exc
if not runner.completed:
raise RuntimeError("Async operation finished without a result or an exception")
return cast("T", runner.result)
+12
View File
@@ -210,15 +210,27 @@ def get_component_cmakelists() -> str:
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
+172 -9
View File
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
import io
import json
import logging
from pathlib import Path
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
import re
import shutil
import tarfile
@@ -20,6 +20,7 @@ from typing import Any
from esphome import const, yaml_util
from esphome.const import (
BUNDLE_EXTENSION,
CONF_ESPHOME,
CONF_EXTERNAL_COMPONENTS,
CONF_INCLUDES,
@@ -29,10 +30,12 @@ from esphome.const import (
CONF_TYPE,
)
from esphome.core import CORE, EsphomeError
from esphome.util import filter_yaml_files
_LOGGER = logging.getLogger(__name__)
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
DOMAIN = "bundle"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
@@ -49,6 +52,7 @@ class ManifestKey(StrEnum):
MANIFEST_VERSION = "manifest_version"
ESPHOME_VERSION = "esphome_version"
CONFIG_FILENAME = "config_filename"
CONFIG_DIR = "config_dir"
FILES = "files"
HAS_SECRETS = "has_secrets"
@@ -120,6 +124,153 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
@dataclass
class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list)
# Directories whose YAML files are scanned for !secret references but
# never bundled, e.g. git package checkouts the builder re-fetches.
secret_scan_dirs: set[Path] = field(default_factory=set)
# Original config dir parsed from an extracted bundle's manifest.json,
# kept in the path flavor of the machine the bundle was created on.
# The checked flag makes the manifest lookup happen at most once per run;
# CORE.data is cleared between runs.
original_config_dir: PurePath | None = None
original_config_dir_checked: bool = False
def _get_data() -> BundleData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = BundleData()
return CORE.data[DOMAIN]
def add_bundle_file(path: Path) -> None:
"""Register a file that a bundle must include.
Bundle discovery walks the validated config, so it only finds files the config
names. Components call this during validation for files it cannot see, such as a
file that is referenced from inside another file.
A relative path is taken as relative to the config directory. Files outside the
config directory are skipped when the bundle is built.
"""
_get_data().extra_files.append(CORE.relative_config_path(path))
def add_secret_scan_dir(path: Path) -> None:
"""Register a directory to scan for ``!secret`` references when bundling.
The directory's files are not added to the bundle. Components call this
for YAML the build consumes without bundling it — such as git-fetched
packages, which the builder re-fetches — so the secrets those files
reference are still shipped in the filtered secrets file.
A relative path is taken as relative to the config directory.
"""
if not path.is_absolute():
path = CORE.relative_config_path(path)
_get_data().secret_scan_dirs.add(path)
def _secret_scan_yaml_files() -> list[Path]:
"""Return the YAML files inside registered secret-scan directories."""
return filter_yaml_files(
f
for scan_dir in _get_data().secret_scan_dirs
for f in yaml_util.find_files(scan_dir, "*")
)
# Windows paths start with a drive letter or contain backslashes; POSIX
# paths do neither in practice, so this is how the flavor of a recorded
# path string is recognized on any host.
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
def _path_flavor(value: str) -> type[PurePath]:
"""Pick the pure path class matching the flavor ``value`` was written in."""
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
return PureWindowsPath
return PurePosixPath
def _load_original_config_dir() -> PurePath | None:
"""Read the original config dir from an extracted bundle's manifest.
Returns None when the current config dir is not an extracted bundle or
the manifest does not record the original config dir.
"""
manifest_path = CORE.config_dir / MANIFEST_FILENAME
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError:
# The common case: this config dir is not an extracted bundle.
return None
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
# A manifest.json is present but unreadable or malformed. Say so
# instead of letting it look identical to "not a bundle".
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
return None
if not isinstance(manifest, dict):
return None
# A manifest.json in the config dir does not have to be ours. Only trust
# one that looks like a bundle manifest for exactly this config file.
version = manifest.get(ManifestKey.MANIFEST_VERSION)
if not isinstance(version, int) or version < 1:
return None
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
return None
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
if not isinstance(config_dir, str) or not config_dir:
return None
return _path_flavor(config_dir)(config_dir)
def remap_bundle_path(value: str) -> Path | None:
"""Remap an absolute path from the machine a bundle was created on.
A bundled config may reference files by absolute path. The referenced
files ship inside the bundle at their config-relative locations, but the
YAML text is copied verbatim, so after extraction on another machine the
absolute reference points at a path that only existed on the creating
machine. The bundle manifest records that machine's config dir; when
``value`` names a path that lived under it, return the corresponding
file next to the extracted config.
``value`` is the raw path string from the config. It is parsed with the
original machine's path flavor, so a bundle created on Windows remaps on
a POSIX build server and vice versa.
Returns None when not compiling an extracted bundle, when ``value`` was
not under the original config dir, or when the bundle does not contain
the file.
"""
data = _get_data()
if not data.original_config_dir_checked:
data.original_config_dir_checked = True
data.original_config_dir = _load_original_config_dir()
original_dir = data.original_config_dir
if original_dir is None:
return None
path = type(original_dir)(value)
if not path.is_absolute():
return None
try:
rel = path.relative_to(original_dir)
except ValueError:
return None
# relative_to is lexical, so ".." segments survive it. Refuse them: the
# remapped file must land strictly inside the extracted config tree.
if ".." in rel.parts:
return None
remapped = CORE.relative_config_path(Path(*rel.parts))
if not remapped.exists():
return None
return remapped
@dataclass
class BundleFile:
"""A file to include in the bundle."""
@@ -146,6 +297,7 @@ class BundleManifest:
config_filename: str
files: list[str]
has_secrets: bool
config_dir: str | None = None
class ConfigBundleCreator:
@@ -186,6 +338,7 @@ class ConfigBundleCreator:
yaml_sources = [
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
]
yaml_sources.extend(_secret_scan_yaml_files())
used_secret_keys = _find_used_secret_keys(yaml_sources)
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
@@ -270,6 +423,13 @@ class ConfigBundleCreator:
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
self._secrets_paths.update(discovered.secrets)
# A !secret inside a file this re-parse does not reach (for example
# a git-fetched package the builder re-fetches) still resolves
# against the config-dir secrets.yaml at build time, so always
# consider that file; filtering no-ops when no key matches.
default_secrets = self._config_dir / yaml_util.SECRET_YAML
if default_secrets.is_file():
self._secrets_paths.add(default_secrets.resolve())
config_resolved = self._config_path.resolve()
for fpath in discovered.files:
if fpath == config_resolved:
@@ -286,13 +446,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
are handled explicitly.
are handled explicitly. Files the config does not name at all are
registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
# Files registered by components during validation
for extra_file in _get_data().extra_files:
self._add_file(extra_file)
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
@@ -405,6 +570,7 @@ class ConfigBundleCreator:
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.ESPHOME_VERSION: const.__version__,
ManifestKey.CONFIG_FILENAME: self._config_path.name,
ManifestKey.CONFIG_DIR: str(self._config_dir),
ManifestKey.FILES: [f.path for f in files],
ManifestKey.HAS_SECRETS: has_secrets,
}
@@ -489,12 +655,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
except tarfile.TarError as err:
raise EsphomeError(f"Failed to read bundle: {err}") from err
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
return BundleManifest(
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
files=manifest.get(ManifestKey.FILES, []),
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
config_dir=config_dir if isinstance(config_dir, str) else None,
)
@@ -587,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
)
def is_bundle_path(path: Path) -> bool:
"""Check if a path looks like a bundle file."""
return path.name.lower().endswith(BUNDLE_EXTENSION)
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
"""Add in-memory bytes to a tar archive with deterministic metadata."""
info = tarfile.TarInfo(name=name)
+3
View File
@@ -49,10 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401
build_registry_entry,
build_registry_list,
extract_registry_entry_config,
get_slot_count,
gpio_pin_expression,
past_safe_mode,
register_component,
register_parented,
set_setup_priority,
slot_counter,
)
from esphome.cpp_types import ( # noqa: F401
NAN,
+8 -1
View File
@@ -61,7 +61,14 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
from esphome import yaml_util
try:
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
# 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
+16 -6
View File
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
zephyr_add_overlay_builder,
zephyr_add_prj_conf,
zephyr_add_user,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -113,6 +113,18 @@ CONFIG_SCHEMA = cv.All(
CONF_ADC_CHANNEL_ID = "adc_channel_id"
def _overlay_io_channels():
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
return f"""
/ {{
zephyr,user {{
io-channels = {entries};
}};
}};
"""
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -173,9 +185,8 @@ async def to_code(config):
if isinstance(pin_number, int):
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
pin_number = GPIO_TO_AIN[pin_number]
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
zephyr_add_overlay(
f"""
zephyr_add_overlay_builder(_overlay_io_channels)
zephyr_add_overlay(f"""
&adc {{
#address-cells = <1>;
#size-cells = <0>;
@@ -190,8 +201,7 @@ async def to_code(config):
zephyr,oversampling = <8>;
}};
}};
"""
)
""")
FILTER_SOURCE_FILES = filter_source_files_from_platform(
+1 -1
View File
@@ -13,6 +13,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
CONF_KEY,
@@ -102,7 +103,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
},
}
CONF_ENCRYPTION = "encryption"
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
+39
View File
@@ -69,6 +69,8 @@ service APIConnection {
rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {}
rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {}
rpc zigbee_proxy_request(ZigbeeProxyRequest) returns (void) {}
rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {}
rpc serial_proxy_configure(SerialProxyConfigureRequest) returns (void) {}
@@ -76,6 +78,7 @@ service APIConnection {
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
}
@@ -315,6 +318,10 @@ message DeviceInfoResponse {
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
// Indicates if Zigbee proxy support is available and features supported
uint32 zigbee_proxy_feature_flags = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
uint64 zigbee_ieee_address = 28 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
}
message ListEntitiesRequest {
@@ -2731,6 +2738,23 @@ message SerialProxyRequestResponse {
string error_message = 4; // Additional detail on failure (optional)
}
// How a port treats the bytes passing through it. RAW is a plain byte pipe; EZSP_ASH lets
// a protocol-aware tap acknowledge NCP frames and read network metadata. A client that is
// about to flash firmware selects RAW first, which definitively disables that injection.
enum SerialProxyMode {
SERIAL_PROXY_MODE_RAW = 0;
SERIAL_PROXY_MODE_EZSP_ASH = 1;
}
message SerialProxySetModeRequest {
option (id) = 151;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyMode mode = 2;
}
// ==================== BLUETOOTH CONNECTION PARAMS ====================
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
@@ -2752,3 +2776,18 @@ message BluetoothSetConnectionParamsResponse {
uint64 address = 1;
int32 error = 2;
}
// ==================== ZIGBEE ====================
enum ZigbeeProxyRequestType {
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0;
}
message ZigbeeProxyRequest {
option (id) = 150;
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZIGBEE_PROXY";
ZigbeeProxyRequestType type = 1;
bytes data = 2;
}
+38 -30
View File
@@ -47,6 +47,9 @@
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
#ifdef USE_ZIGBEE_PROXY
#include "esphome/components/zigbee_proxy/zigbee_proxy.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -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_object_id_hash();
msg.key = entity->get_entity_key();
#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_object_id_hash();
msg.key = entity->get_entity_key();
if (entity->has_own_name()) {
msg.name = entity->get_name();
@@ -794,6 +797,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
// Current feature flags and other supported parameters
msg.feature_flags = traits.get_feature_flags();
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
msg.supported_modes = &traits.get_supported_modes();
msg.visual_min_temperature = traits.get_visual_min_temperature();
msg.visual_max_temperature = traits.get_visual_max_temperature();
@@ -1140,7 +1144,7 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_object_id_hash();
msg.key = camera::Camera::instance()->get_entity_key();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
@@ -1328,7 +1332,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
}
}
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
bool APIConnection::send_voice_assistant_get_configuration_response_(
const VoiceAssistantConfigurationRequest & /*msg*/) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
@@ -1348,22 +1353,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice
}
}
// Filter external wake words
for (auto &wake_word : msg.external_wake_words) {
if (wake_word.model_type != "micro") {
// microWakeWord only
continue;
}
resp.available_wake_words.emplace_back();
auto &resp_wake_word = resp.available_wake_words.back();
resp_wake_word.id = StringRef(wake_word.id);
resp_wake_word.wake_word = StringRef(wake_word.wake_word);
for (const auto &lang : wake_word.trained_languages) {
resp_wake_word.trained_languages.push_back(lang);
}
}
resp.active_wake_words = &config.active_wake_words;
resp.max_active_wake_words = config.max_active_wake_words;
return this->send_message(resp);
@@ -1391,6 +1380,12 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_ZIGBEE_PROXY
void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg);
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
@@ -1468,6 +1463,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
msg.target_temperature_step = traits.get_target_temperature_step();
msg.supported_modes = &traits.get_supported_modes();
msg.supported_features = traits.get_feature_flags();
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
return fill_and_encode_entity_info(wh, msg, conn, remaining_size);
}
@@ -1556,8 +1552,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure
static_cast<uint32_t>(proxies.size()));
return;
}
proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits,
msg.data_size);
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.stop_bits, msg.data_size);
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1566,7 +1562,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len);
}
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
@@ -1575,7 +1571,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->set_modem_pins(msg.line_states);
proxies[msg.instance]->set_modem_pins(this, msg.line_states);
}
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
@@ -1628,6 +1624,15 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
}
}
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->set_mode(this, msg.mode);
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
#endif
@@ -1735,6 +1740,11 @@ void APIConnection::complete_authentication_() {
zwave_proxy::global_zwave_proxy->api_connection_authenticated(this);
}
#endif
#ifdef USE_ZIGBEE_PROXY
if (zigbee_proxy::global_zigbee_proxy != nullptr) {
zigbee_proxy::global_zigbee_proxy->api_connection_authenticated(this);
}
#endif
}
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
@@ -1746,12 +1756,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
// TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
if (!this->client_supports_api_version(1, 14)) {
ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
this->client_api_version_major_, this->client_api_version_minor_);
}
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
@@ -1890,6 +1894,10 @@ bool APIConnection::send_device_info_response_() {
info.port_type = proxy->get_port_type();
}
#endif
#ifdef USE_ZIGBEE_PROXY
resp.zigbee_proxy_feature_flags = zigbee_proxy::global_zigbee_proxy->get_feature_flags();
resp.zigbee_ieee_address = zigbee_proxy::global_zigbee_proxy->get_ieee_address();
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
+5
View File
@@ -218,6 +218,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
@@ -239,6 +243,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
void on_serial_proxy_request(const SerialProxyRequest &msg);
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
#endif
+19 -2
View File
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
// socket->write() can re-enter this function: a log message emitted from an
// lwip callback during the write goes out over the API and lands back in the
// frame helper's write/drain path. If a nested drain ran here it would send
// and free the entry the outer drain is still holding, causing a double free.
// Report "no progress" instead; the outer drain keeps draining, and the
// nested send is enqueued behind the existing backlog.
if (this->draining_)
return 0;
// RAII so the flag is cleared on every return path
struct DrainGuard {
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
~DrainGuard() { this->flag_ = false; }
bool &flag_;
} guard(this->draining_);
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
return sent;
}
// Entry fully sent — free it and advance
Entry::destroy(front);
// Entry fully sent — unlink it before freeing so a freed pointer is never
// reachable from the queue
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--;
Entry::destroy(front);
}
return 0; // All drained
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
uint8_t head_{0};
uint8_t tail_{0};
uint8_t count_{0};
// Guards against re-entrant drains: socket->write() can re-enter the API
// send path (e.g. a log message emitted from an lwip callback), and a nested
// drain would free the entry the outer drain is still holding.
bool draining_{false};
};
} // namespace esphome::api
+61
View File
@@ -173,6 +173,12 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 28, this->zigbee_ieee_address);
#endif
return pos;
}
@@ -238,6 +244,12 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
size += ProtoSize::calc_uint32(2, this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
size += ProtoSize::calc_uint64(2, this->zigbee_ieee_address);
#endif
return size;
}
@@ -4144,6 +4156,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
size += ProtoSize::calc_length(1, this->error_message.size());
return size;
}
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->instance = value;
break;
case 2:
this->mode = static_cast<enums::SerialProxyMode>(value);
break;
default:
return false;
}
return true;
}
#endif
#ifdef USE_BLUETOOTH_PROXY
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
@@ -4181,5 +4206,41 @@ uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const {
return size;
}
#endif
#ifdef USE_ZIGBEE_PROXY
bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->type = static_cast<enums::ZigbeeProxyRequestType>(value);
break;
default:
return false;
}
return true;
}
bool ZigbeeProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 2: {
this->data = value.data();
this->data_len = value.size();
break;
}
default:
return false;
}
return true;
}
uint8_t *ZigbeeProxyRequest::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->type));
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len);
return pos;
}
uint32_t ZigbeeProxyRequest::calculate_size() const {
uint32_t size = 0;
size += this->type ? 2 : 0;
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
#endif
} // namespace esphome::api
+54 -1
View File
@@ -351,6 +351,15 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_TIMEOUT = 3,
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4,
};
enum SerialProxyMode : uint32_t {
SERIAL_PROXY_MODE_RAW = 0,
SERIAL_PROXY_MODE_EZSP_ASH = 1,
};
#endif
#ifdef USE_ZIGBEE_PROXY
enum ZigbeeProxyRequestType : uint32_t {
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0,
};
#endif
} // namespace enums
@@ -533,7 +542,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 312;
static constexpr uint16_t ESTIMATED_SIZE = 322;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -591,6 +600,12 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
#ifdef USE_ZIGBEE_PROXY
uint32_t zigbee_proxy_feature_flags{0};
#endif
#ifdef USE_ZIGBEE_PROXY
uint64_t zigbee_ieee_address{0};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
@@ -3289,6 +3304,22 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 151;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
#endif
uint32_t instance{0};
enums::SerialProxyMode mode{};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
#endif
#ifdef USE_BLUETOOTH_PROXY
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
@@ -3328,5 +3359,27 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
protected:
};
#endif
#ifdef USE_ZIGBEE_PROXY
class ZigbeeProxyRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("zigbee_proxy_request"); }
#endif
enums::ZigbeeProxyRequestType type{};
const uint8_t *data{nullptr};
uint16_t data_len{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:
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
#endif
} // namespace esphome::api
-2
View File
@@ -3,10 +3,8 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY
#ifndef USE_API_VARINT64
#define USE_API_VARINT64
#endif
#endif
namespace esphome::api {} // namespace esphome::api
+40
View File
@@ -856,6 +856,26 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
switch (value) {
case enums::SERIAL_PROXY_MODE_RAW:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
case enums::SERIAL_PROXY_MODE_EZSP_ASH:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_EZSP_ASH");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
#ifdef USE_ZIGBEE_PROXY
template<> const char *proto_enum_to_string<enums::ZigbeeProxyRequestType>(enums::ZigbeeProxyRequestType value) {
switch (value) {
case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO:
return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
@@ -985,6 +1005,12 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
#ifdef USE_ZIGBEE_PROXY
dump_field(out, ESPHOME_PSTR("zigbee_proxy_feature_flags"), this->zigbee_proxy_feature_flags);
#endif
#ifdef USE_ZIGBEE_PROXY
dump_field(out, ESPHOME_PSTR("zigbee_ieee_address"), this->zigbee_ieee_address);
#endif
return out.c_str();
}
@@ -2714,6 +2740,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
return out.c_str();
}
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
@@ -2732,6 +2764,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
return out.c_str();
}
#endif
#ifdef USE_ZIGBEE_PROXY
const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyRequest"));
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZigbeeProxyRequestType>(this->type));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -704,6 +704,28 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_bluetooth_set_connection_params_request(msg);
break;
}
#endif
#ifdef USE_ZIGBEE_PROXY
case ZigbeeProxyRequest::MESSAGE_TYPE: {
ZigbeeProxyRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_zigbee_proxy_request"), msg);
#endif
this->on_zigbee_proxy_request(msg);
break;
}
#endif
#ifdef USE_SERIAL_PROXY
case SerialProxySetModeRequest::MESSAGE_TYPE: {
SerialProxySetModeRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
#endif
this->on_serial_proxy_set_mode_request(msg);
break;
}
#endif
default:
break;
+7
View File
@@ -233,9 +233,16 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){};
#endif
};
} // namespace esphome::api
+8
View File
@@ -399,6 +399,14 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#ifdef USE_ZIGBEE_PROXY
void APIServer::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
// Very infrequent and small - send to all clients rather than tracking a subscription
for (auto &c : this->active_clients())
c->send_message(msg);
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
const std::vector<int32_t> *timings) {
+3
View File
@@ -186,6 +186,9 @@ class APIServer final : public Component,
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
#endif
+7 -168
View File
@@ -1,171 +1,10 @@
from __future__ import annotations
"""Backward-compatibility shim; the log client lives in esphome.api_client.
import asyncio
from datetime import datetime
import importlib
import logging
from typing import TYPE_CHECKING, Any
import warnings
Importing this module executes the whole api component package, which pulls
in the validation stack. CLI code paths should import esphome.api_client
directly so the logs fast path stays light.
"""
# Suppress protobuf version warnings
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
)
from aioesphomeapi import APIClient, parse_log_message
from aioesphomeapi.log_runner import async_run
from esphome.api_client import async_run_logs, run_logs
import contextlib
from esphome.const import CONF_KEY, CONF_PORT, __version__
from esphome.core import CORE, EsphomeError
from esphome.util import safe_print
from . import CONF_ENCRYPTION
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import (
SubscribeLogsResponse, # pylint: disable=no-name-in-module
)
_LOGGER = logging.getLogger(__name__)
class _LogLineProcessor:
"""Feeds incoming log lines to the stack-trace decoder.
Two responsibilities beyond just calling the decoder:
1. Catch EsphomeError. on_log runs inside an asyncio protocol
callback; if an exception escapes, the loop tears the transport
down with "Fatal error: protocol.data_received() call failed."
and ReconnectLogic immediately reconnects, the device replays
the same crash trace, and we loop forever.
2. Disable decoding after the first failure. _decode_pc shells out
to PlatformIO via _run_idedata, which is expensive; a single
crash dump can contain many PC/BT lines and we don't want to
retry the failing subprocess for each one.
"""
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
self._config = config
self._platform_handler = platform_handler
self._decode_enabled = True
self.backtrace_state = False
def process_line(self, raw_line: str) -> None:
if not self._decode_enabled:
return
try:
if self._platform_handler is not None:
self.backtrace_state = self._platform_handler(
self._config, raw_line, self.backtrace_state
)
except EsphomeError as exc:
self._decode_enabled = False
self.backtrace_state = False
# _run_idedata raises EsphomeError with no message; fall back
# to a generic explanation when str(exc) is empty.
detail = str(exc) or "build artifacts not found locally"
_LOGGER.warning(
"Crash trace decoding unavailable: %s. "
"Run 'esphome compile' for this device to enable PC decoding.",
detail,
)
async def async_run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
) -> None:
"""Run the logs command in the event loop."""
conf = config["api"]
name = config["esphome"]["name"]
port: int = int(conf[CONF_PORT])
noise_psk: str | None = None
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
noise_psk = key
if len(addresses) == 1:
_LOGGER.info("Starting log output from %s using esphome API", addresses[0])
else:
_LOGGER.info(
"Starting log output from %s using esphome API", " or ".join(addresses)
)
cli = APIClient(
addresses[0], # Primary address for compatibility
port,
"", # Password auth removed in 2026.1.0
client_info=f"ESPHome Logs {__version__}",
noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
provide_time=False,
)
# Try platform-specific stacktrace handler first, fall back to generic
platform_process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
platform_process_stacktrace = module.process_stacktrace
except (AttributeError, ImportError):
_LOGGER.info(
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
CORE.target_platform,
)
processor = _LogLineProcessor(config, platform_process_stacktrace)
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
time_ = datetime.now().astimezone()
message: bytes = msg.message
text = message.decode("utf8", "backslashreplace")
nanoseconds = time_.microsecond // 1000
timestamp = (
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
)
for parsed_msg in parse_log_message(text, timestamp):
# safe_print handles the dashboard \033 escaping and falls back
# to backslashreplace encoding on stdouts that can't represent
# the wifi signal-bar block characters (Windows redirected
# cp1252 pipe).
safe_print(parsed_msg)
for raw_line in text.splitlines():
processor.process_line(raw_line)
# Safe to fall back to plaintext here only for this diagnostics use
# case: the stream is one-way from device to client, and this code
# never accepts commands or acts on any message the device sends.
# An on-path attacker could still both inject fabricated log lines
# and passively read the device's log output (and any state data
# delivered when subscribe_states is enabled), so this does lose
# confidentiality as well as authentication/integrity. That tradeoff
# is acceptable for operator-visible logs, which aioesphomeapi also
# warns may come from an unverified device. Never mirror this opt-in
# for any connection that sends data to the device or uses Home
# Assistant actions.
stop = await async_run(
cli,
on_log,
name=name,
subscribe_states=subscribe_states,
allow_plaintext_fallback=True,
)
try:
await asyncio.Event().wait()
finally:
await stop()
def run_logs(
config: dict[str, Any],
addresses: list[str],
subscribe_states: bool = True,
) -> None:
"""Run the logs command."""
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(
async_run_logs(config, addresses, subscribe_states=subscribe_states)
)
__all__ = ["async_run_logs", "run_logs"]
+1
View File
@@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType")
CONF_AQI = "aqi"
CONF_CALCULATION_TYPE = "calculation_type"
CONF_EXTENDED_RANGE = "extended_range"
AQI_CALCULATION_TYPE = {
"CAQI": AQICalculatorType.CAQI_TYPE,
@@ -6,7 +6,7 @@ namespace esphome::aqi {
class AbstractAQICalculator {
public:
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0;
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0;
};
} // namespace esphome::aqi
+20 -10
View File
@@ -11,10 +11,12 @@ namespace esphome::aqi {
class AQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range);
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
// extended_range lets the index run past the standard maximum, so clamp to the sensor's range.
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
return static_cast<uint16_t>(std::lround(aqi));
}
@@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator {
{35.5f, 55.5f},
{55.5f, 125.5f},
{125.5f, 225.5f},
{225.5f, std::numeric_limits<float>::max()}
{225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3
// clang-format on
};
@@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator {
{155.0f, 255.0f},
{255.0f, 355.0f},
{355.0f, 425.0f},
{425.0f, std::numeric_limits<float>::max()}
{425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band)
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) {
int grid_index = get_grid_index(value, array);
if (grid_index == -1) {
return -1.0f;
@@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator {
float conc_lo = array[grid_index][0];
float conc_hi = array[grid_index][1];
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
// Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we
// clamp to the standard maximum; with extended_range we keep the extrapolated "over-range"
// value so heavy pollution reports numbers beyond what the standard defines.
if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) {
return aqi_hi;
}
return index;
}
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
// The top band is open-ended: any value at or above its lower breakpoint falls into it,
// and calculate_index() decides whether to clamp or extrapolate.
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
if (in_range) {
return i;
}
+2 -1
View File
@@ -24,6 +24,7 @@ void AQISensor::setup() {
void AQISensor::dump_config() {
ESP_LOGCONFIG(TAG, "AQI Sensor:");
ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI");
ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled");
if (this->pm_2_5_sensor_ != nullptr) {
ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str());
}
@@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() {
return;
}
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_);
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_);
this->publish_state(aqi);
}
+2
View File
@@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component {
void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; }
void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; }
void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; }
void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; }
protected:
void calculate_aqi_();
@@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component {
sensor::Sensor *pm_2_5_sensor_{nullptr};
sensor::Sensor *pm_10_0_sensor_{nullptr};
AQICalculatorType aqi_calc_type_{AQI_TYPE};
bool extended_range_{false};
AQICalculatorFactory aqi_calculator_factory_;
float pm_2_5_value_{NAN};
+13 -10
View File
@@ -9,25 +9,28 @@ namespace esphome::aqi {
class CAQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
// The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We
// therefore always extrapolate the top band past 100 without limit, so the extended_range flag
// (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored.
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
return static_cast<uint16_t>(std::lround(aqi));
}
protected:
static constexpr int NUM_LEVELS = 5;
static constexpr int NUM_LEVELS = 4;
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
// clang-format off
{0.0f, 15.1f},
{15.1f, 30.1f},
{30.1f, 55.1f},
{55.1f, 110.1f},
{110.1f, std::numeric_limits<float>::max()}
{55.1f, 110.1f}
// clang-format on
};
@@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator {
{0.0f, 25.1f},
{25.1f, 50.1f},
{50.1f, 90.1f},
{90.1f, 180.1f},
{180.1f, std::numeric_limits<float>::max()}
{90.1f, 180.1f}
// clang-format on
};
@@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator {
float conc_lo = array[grid_index][0];
float conc_hi = array[grid_index][1];
// The top band is open-ended (see get_grid_index), so for concentrations above the last
// breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class.
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
}
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
// The top band is open-ended: any value at or above its lower breakpoint falls into it.
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
if (in_range) {
return i;
}
+17 -3
View File
@@ -8,14 +8,25 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
)
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns
CODEOWNERS = ["@jasstrong"]
DEPENDENCIES = ["sensor"]
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
def _validate_extended_range(config):
if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI":
raise cv.Invalid(
f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. "
"CAQI has no maximum value by specification, so it is always reported unbounded.",
[CONF_EXTENDED_RANGE],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
AQISensor,
accuracy_decimals=0,
@@ -29,9 +40,11 @@ CONFIG_SCHEMA = (
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
AQI_CALCULATION_TYPE, upper=True
),
cv.Optional(CONF_EXTENDED_RANGE): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
_validate_extended_range,
)
@@ -46,3 +59,4 @@ async def to_code(config):
cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor))
cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE]))
cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False)))
+1 -5
View File
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
uint8_t value;
if (write(&reg, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Writing register failed!");
return 0;
}
if (read(&value, 1) != i2c::ERROR_OK) {
if (!this->read_byte(reg, &value)) {
ESP_LOGW(TAG, "Reading register failed!");
return 0;
}
@@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip {
static const char *const TAG = "beken_spi_led_strip";
struct spi_data_t {
struct SpiData {
SemaphoreHandle_t dma_tx_semaphore;
volatile bool tx_in_progress;
bool first_run;
};
static spi_data_t *spi_data = nullptr;
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void set_spi_ctrl_register(unsigned long bit, bool val) {
static void set_spi_ctrl_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CTRL);
if (val == 0) {
value &= ~bit;
@@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) {
REG_WRITE(SPI_CTRL, value);
}
static void set_spi_config_register(unsigned long bit, bool val) {
static void set_spi_config_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CONFIG);
if (val == 0) {
value &= ~bit;
@@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) {
void spi_dma_tx_enable(bool enable) {
GDMA_CFG_ST en_cfg;
set_spi_config_register(SPI_TX_EN, enable ? 1 : 0);
set_spi_config_register(SPI_TX_EN, enable);
en_cfg.channel = SPI_TX_DMA_CHANNEL;
en_cfg.param = enable ? 1 : 0;
sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg);
@@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) {
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
param |= (div << SPI_CKR_POSI);
REG_WRITE(SPI_CTRL, param);
ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div);
ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div);
}
void spi_dma_tx_finish_callback(unsigned int param) {
spi_data->tx_in_progress = false;
xSemaphoreGive(spi_data->dma_tx_semaphore);
spi_dma_tx_enable(0);
spi_dma_tx_enable(false);
}
void BekenSPILEDStripLightOutput::setup() {
@@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() {
return;
}
spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t));
spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc)
if (spi_data == nullptr) {
ESP_LOGE(TAG, "Cannot allocate spi_data!");
this->mark_failed();
@@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() {
spi_data->first_run = true;
set_spi_ctrl_register(MSTEN, 0);
set_spi_ctrl_register(BIT_WDTH, 0);
set_spi_ctrl_register(MSTEN, false);
set_spi_ctrl_register(BIT_WDTH, false);
spi_set_clock(this->spi_frequency_);
set_spi_ctrl_register(CKPOL, 0);
set_spi_ctrl_register(CKPHA, 0);
set_spi_ctrl_register(MSTEN, 1);
set_spi_ctrl_register(SPIEN, 1);
set_spi_ctrl_register(CKPOL, false);
set_spi_ctrl_register(CKPHA, false);
set_spi_ctrl_register(MSTEN, true);
set_spi_ctrl_register(SPIEN, true);
set_spi_ctrl_register(TXINT_EN, 0);
set_spi_ctrl_register(RXINT_EN, 0);
set_spi_config_register(SPI_TX_FINISH_EN, 1);
set_spi_config_register(SPI_RX_FINISH_EN, 1);
set_spi_ctrl_register(RXOVR_EN, 0);
set_spi_ctrl_register(TXOVR_EN, 0);
set_spi_ctrl_register(TXINT_EN, false);
set_spi_ctrl_register(RXINT_EN, false);
set_spi_config_register(SPI_TX_FINISH_EN, true);
set_spi_config_register(SPI_RX_FINISH_EN, true);
set_spi_ctrl_register(RXOVR_EN, false);
set_spi_ctrl_register(TXOVR_EN, false);
value = REG_READ(SPI_CTRL);
value &= ~CTRL_NSSMD_3;
@@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() {
value = GFUNC_MODE_SPI_DMA;
sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value);
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0);
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false);
GDMA_CFG_ST en_cfg;
GDMACFG_TPYES_ST init_cfg;
@@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() {
init_cfg.dstptr_incr = 0;
init_cfg.srcptr_incr = 1;
init_cfg.src_start_addr = this->dma_buf_;
init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO
init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO
init_cfg.channel = SPI_TX_DMA_CHANNEL;
init_cfg.prio = 0; // 10
init_cfg.u.type4.src_loop_start_addr = this->dma_buf_;
@@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() {
en_cfg.param = 0;
sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg);
spi_dma_tx_enable(0);
spi_dma_tx_enable(false);
value = REG_READ(SPI_CONFIG);
value &= ~(0xFFF << 8);
@@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
// protect from refreshing too often
uint32_t now = micros();
if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) {
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
(now - this->last_refresh_) < *this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
return;
@@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
spi_data->first_run = false;
spi_dma_tx_enable(1);
spi_dma_tx_enable(true);
this->status_clear_warning();
}
@@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
" RGB Order: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
rgb_order, *this->max_refresh_rate_, this->num_leds_);
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
+5 -1
View File
@@ -51,7 +51,11 @@ def _set_core_data(config):
return config
CONFIG_SCHEMA = libretiny.BASE_SCHEMA
# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared
# by every LibreTiny platform, and prepending this platform's _set_core_data
# onto the shared object would run it for every platform's validation once two
# platform modules are imported in one process (device-builder, tests).
CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({})
PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA
+99
View File
@@ -0,0 +1,99 @@
"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips.
The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
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.
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;
prebuilt libble_<chip>.a per SoC). This component only calls into it via the
public ble_api.h.
"""
import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
CODEOWNERS = ["@Bl00d-B0b"]
_LOGGER = logging.getLogger(__name__)
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BK72xxBLE),
# Default off: on the single-core BK72xx, bringing the BLE stack up during
# boot competes with the WiFi connection handshake. Consumers enable the
# stack lazily on first use (e.g. the tracker's first scan start).
cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA)
# Once per registered scan listener; sizes the controller's StaticVector
# listener storage.
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
# Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value
# is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0
# on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this
# SAME key — and add_platformio_option appends list values, it never replaces.
# The platform therefore skips its disable when this component is configured,
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
# bundled 3.0.33 has an older BLE header/library layout — and with
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
# (the reason the libretiny platform sets =0 when BLE is unused), so a
# mismatched BDK can crash the device before WiFi comes up regardless of
# enable_on_boot. Pinning here makes a plain config build against the
# validated BDK without any manual platformio_options.
_LOGGER.warning(
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
"default: the default's older BLE layout can crash the device at boot when "
"BLE is compiled in"
)
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
# which path is available so it doesn't reference a missing symbol.
family = libretiny.get_libretiny_family()
if family == FAMILY_BK7231N:
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
elif family == FAMILY_BK7238:
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
# warn loudly: BK7238 is accepted but not hardware-verified and may be
# WiFi-unstable with BLE on.
_LOGGER.warning(
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
"hang on this family and is not yet hardware-verified. Expect possible "
"instability."
)
cg.add_define("USE_BK72XX_BLE")
@@ -0,0 +1,294 @@
// bk72xx_ble.cpp
//
// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx
// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that
// 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-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
// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at
// steady state.
// Consumers contain no SDK calls of their own.
//
// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny
// beken-72xx builder itself (prebuilt libble_<chip>.a + ble_5_x sources, gated
// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only
// calls into it via the public ble_api.h — no framework patch is required.
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
#ifdef USE_BK72XX_BLE
#include <cstring>
#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".
// ---------------------------------------------------------------------------
#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")
#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."
#endif
#ifndef BK72XX_BLE_NO_SDK
// ---------------------------------------------------------------------------
// Beken BDK BLE 5.x SDK — public API.
// Exposed on the include path by the LibreTiny beken-72xx builder
// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these
// 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
#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().
// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the
// address is derived from the WiFi MAC instead (matching the BDK's own fallback).
extern struct bd_addr common_default_bdaddr;
#endif
// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so
// declare it here.
void ble_entry(void);
}
namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
// 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)
// ---------------------------------------------------------------------------
// BLE notice callback — runs in the BDK BLE task context.
// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice
// carrying a recv_adv_t. Copy it into the queue and return; all dispatch
// happens in loop() on the main task.
// ---------------------------------------------------------------------------
static void ble_notice_callback(ble_notice_t notice, void *param) {
if (s_ble == nullptr || param == nullptr)
return;
if (notice != BLE_5_REPORT_ADV)
return;
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);
}
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_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);
report->rssi = rssi;
report->addr_type = addr_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);
// Cannot fail: the pool is sized to the queue capacity.
this->report_queue_.push(report);
}
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void BK72xxBLE::setup() {
s_ble = this;
// 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_();
if (this->enable_on_boot_) {
this->enable();
}
}
// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK
// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order).
float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
void BK72xxBLE::enable() {
if (this->state_ != BLEComponentState::STATE_OFF)
return;
this->state_ = BLEComponentState::ENABLING;
// One-time BLE stack init: register the notice callback, then bring up the
// BDK BLE stack. The BDK has no teardown path — init happens at most once.
ble_set_notice_cb(ble_notice_callback);
ble_entry();
delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time
// Re-read the BLE MAC now that the controller is up (common_default_bdaddr is
// populated by ble_entry()); resolve_mac_() may have fallen back earlier.
this->resolve_mac_();
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
// Liveness heuristic (BK7231N): a healthy ble_entry() populates
// common_default_bdaddr during init, so all-zero after the settle delay
// suggests the stack did not come up. The BDK entry point returns void — no
// return code exists — so warn rather than fail: scan starts against a dead
// stack already fail cleanly downstream (no idle activity handle).
bool bdaddr_live = false;
for (uint8_t b : common_default_bdaddr.addr) {
if (b != 0) {
bdaddr_live = true;
break;
}
}
if (!bdaddr_live)
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
#endif
this->state_ = BLEComponentState::ACTIVE;
ESP_LOGD(TAG, "BLE stack initialised");
}
void BK72xxBLE::loop() {
// 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();
if (report == nullptr)
return;
do {
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report);
#endif
this->report_pool_.release(report);
} while ((report = this->report_queue_.pop()) != nullptr);
// Log dropped reports — only reachable when reports were processed; drops can
// only occur while the queue is full, and only this loop drains it.
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0)
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i];
}
void BK72xxBLE::dump_config() {
// ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the
// MSB-first order Home Assistant shows.
ESP_LOGCONFIG(TAG,
"BK72xx BLE:\n"
" MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n"
" Active: %s",
this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1],
this->ble_mac_[0], YESNO(this->is_active()));
}
// ---------------------------------------------------------------------------
// MAC resolution
// ---------------------------------------------------------------------------
void BK72xxBLE::resolve_mac_() {
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
// BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention)
// during ble_entry(). It may still be zero before the stack is up; if so, fall
// through to the WiFi-derived MAC below.
bool nonzero = false;
for (uint8_t b : common_default_bdaddr.addr) {
if (b != 0) {
nonzero = true;
break;
}
}
if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
return;
}
#endif
// Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other
// BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the
// Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte
// incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next
// byte), OUI unchanged. This reproduces the address the controller advertises with
// (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];
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)};
// 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
// ---------------------------------------------------------------------------
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
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();
}
struct scan_param sp;
memset(&sp, 0, sizeof(sp));
sp.channel_map = 7; // advertising channels 37/38/39
sp.interval = interval;
sp.window = window;
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");
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;
return false;
}
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;
}
}
} // namespace esphome::bk72xx_ble
#endif // BK72XX_BLE_NO_SDK
#endif // USE_BK72XX_BLE
+106
View File
@@ -0,0 +1,106 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BK72XX_BLE
#include "esphome/core/component.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h"
#include <cstdint>
namespace esphome::bk72xx_ble {
enum class BLEComponentState : uint8_t {
STATE_OFF = 0,
ENABLING,
ACTIVE,
};
/// 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 addr_type;
uint8_t data_len; // bytes valid in data[]
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
// EventPool contract: nothing is heap-allocated inside a report.
void release() {}
};
/// Consumer interface for controller scan reports. on_scan_report() always runs
/// on the ESPHome main task: reports are queued from the BDK BLE task and
/// drained by the controller's loop(), so consumers never deal with cross-task
/// state (the esp32_ble event-queue pattern).
class BLEScanListener {
public:
virtual void on_scan_report(const BLEScanReport &report) = 0;
protected:
~BLEScanListener() = default; // deletion via this interface is not part of the contract
};
// Maximum reports buffered between the BLE task and loop().
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
class BK72xxBLE final : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
/// Bring up the BDK BLE stack (one-time; the BDK has no teardown path).
void enable();
bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; }
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;
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
/// Register a consumer for scan reports (delivered on the main task via loop()).
/// Storage is codegen-sized: the consumer's codegen requests a slot via
/// request_scan_listener_slot(), which emits BK72XX_BLE_SCAN_LISTENER_COUNT.
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).
void scan_stop();
/// 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);
protected:
void resolve_mac_();
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
// Codegen-sized: no heap allocation, no std::vector template instantiation —
// the same StaticVector pattern as the tracker's ble_device_base listeners.
StaticVector<BLEScanListener *, BK72XX_BLE_SCAN_LISTENER_COUNT> scan_listeners_;
#endif
// Report ring: the BDK notice callback (BLE task) allocates a report from the
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
esphome::LockFreeQueue<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE> report_queue_;
// Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so
// 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};
bool enable_on_boot_{false};
};
} // namespace esphome::bk72xx_ble
#endif // USE_BK72XX_BLE
@@ -0,0 +1,182 @@
"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable
LibreTiny Beken chips (beken-72xx family).
Builds on the bk72xx_ble controller component (stack bring-up, BLE address,
scan primitives) and implements the platform-neutral ble_device_base BLEHub
contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner,
bthome_mithermometer, xiaomi_*, ) bind to this tracker through
cv.use_id(BLEHub) with no BK-specific code.
Scan modes:
continuous: true scan runs forever; never stops automatically.
Use this when the radio is dedicated to BLE.
continuous: false a started scan runs for `duration` ms, then stops. The
FIRST start is external too: nothing in this component
starts a non-continuous scan on boot the radio stays
idle until bk72xx_ble_tracker.start_scan fires (e.g.
from an api client-connected automation), so the
single-core radio can service WiFi in between scans.
"""
from esphome import automation
import esphome.codegen as cg
from esphome.components import bk72xx_ble, ble_device_base, ota
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_CONTINUOUS,
CONF_DURATION,
CONF_ID,
CONF_INTERVAL,
CONF_MANUFACTURER_ID,
CONF_ON_BLE_ADVERTISE,
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
CONF_ON_BLE_SERVICE_DATA_ADVERTISE,
CONF_SERVICE_UUID,
)
from esphome.core import ID
from esphome.types import ConfigType
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
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
)
StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action)
StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action)
ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms
# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in
# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.)
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms")
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema(
ESPBTAdvertiseTrigger
),
cv.Optional(
CONF_ON_BLE_SERVICE_DATA_ADVERTISE
): ble_automation.uuid_trigger_schema(
BLEServiceDataAdvertiseTrigger,
{cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid},
),
cv.Optional(
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
): ble_automation.uuid_trigger_schema(
BLEManufacturerDataAdvertiseTrigger,
{cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid},
),
cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema(
BLEEndOfScanTrigger
),
}
).extend(cv.COMPONENT_SCHEMA)
@automation.register_action(
"bk72xx_ble_tracker.start_scan",
StartScanAction,
cv.Schema(
{
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
# Optional with no default, unlike esp32_ble_tracker: omitting it
# keeps whatever scan_parameters.continuous configured, instead of
# silently forcing one-shot.
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
}
),
synchronous=True,
)
async def start_scan_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: list,
) -> cg.MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
template_ = await cg.templatable(continuous, args, cg.bool_)
cg.add(var.set_continuous(template_))
return var
@automation.register_action(
"bk72xx_ble_tracker.stop_scan",
StopScanAction,
automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
}
)
),
synchronous=True,
)
async def stop_scan_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: list,
) -> cg.MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID])
cg.add(var.set_parent(parent))
# The tracker registers itself as a controller scan listener in setup();
# request the codegen-sized StaticVector slot for it.
bk72xx_ble.request_scan_listener_slot()
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
ota.request_ota_state_listeners()
scan = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
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]))
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
await ble_automation.advertise_trigger_to_code(conf, var)
for trigger_key, uuid_key, setter_prefix in (
(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"),
(
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
CONF_MANUFACTURER_ID,
"set_manufacturer_uuid",
),
):
for conf in config.get(trigger_key, []):
await ble_automation.uuid_trigger_to_code(
conf, var, uuid_key, setter_prefix
)
for conf in config.get(CONF_ON_SCAN_END, []):
await ble_automation.scan_end_trigger_to_code(conf, var)
@@ -0,0 +1,48 @@
// Automation triggers and actions for bk72xx_ble_tracker: triggers are the
// neutral ble_device_base classes; only the scan-control actions are
// platform-specific.
#pragma once
#ifdef USE_LIBRETINY
#include "bk72xx_ble_tracker.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
namespace esphome::bk72xx_ble_tracker {
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
public:
TEMPLATABLE_VALUE(bool, continuous)
void play(const Ts &...x) override {
// With continuous: set, the action wins. Without it, the configured value
// is used - stop_scan() clears the runtime flag permanently, so a bare
// stop_scan/start_scan pair would otherwise never resume continuous mode.
const bool want =
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
if (this->parent_->scan_running()) {
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
// the duration window here would let a repeated action keep a one-shot
// scan alive forever. A real mode switch re-anchors so a change to
// one-shot runs a full duration from now.
if (want != this->parent_->scan_continuous()) {
this->parent_->set_scan_continuous(want);
this->parent_->restart_scan_duration();
}
return;
}
this->parent_->set_scan_continuous(want);
this->parent_->start_scan();
}
};
template<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
public:
void play(const Ts &...x) override { this->parent_->stop_scan(); }
};
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
@@ -0,0 +1,277 @@
// bk72xx_ble_tracker.cpp
//
// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period
// timers and the rate-limited start retry. All controller access (stack
// bring-up, scan primitives, the BLE-task → main-task report queue) goes
// through the bk72xx_ble component — no SDK calls and no cross-task state here.
#ifdef USE_LIBRETINY
#include "bk72xx_ble_tracker.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble_tracker {
static const char *const TAG = "bk72xx_ble_tracker";
// Minimum interval between scan (re)start attempts, so a failing controller start
// cannot be retried every main-loop iteration (single-core CPU starvation). The
// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller
// that never comes up — the controller logs each failure at ERROR — settles into a
// slow, quiet poll instead of an error line every second for the rest of uptime;
// 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
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
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);
#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
}
#ifdef USE_OTA_STATE_LISTENER
void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_requested_before_ota_ = this->scan_requested_;
this->stop_scan();
} 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;
}
// 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;
}
}
}
#endif // USE_OTA_STATE_LISTENER
void BK72xxBLETracker::loop() {
const uint32_t now = millis();
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.
if (this->try_start_with_backoff_(now))
return;
}
// Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
// 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->scan_period_start_ = now;
}
return;
}
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
// Restart is driven externally (e.g. api: on_client_connected:).
//
// A requested start that failed (same controller failures the continuous branch
// absorbs) is retried with the same backoff — otherwise a failed one-shot start
// 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.
if (this->try_start_with_backoff_(now))
return;
}
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
this->stop_scan_();
}
}
bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) {
// Rate-limit (re)start attempts. The controller start can fail (no idle activity
// handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every
// main-loop iteration would spin the single-core CPU and starve WiFi (device
// becomes unresponsive). The interval backs off with consecutive failures so a
// controller that never comes up polls slowly and quietly.
//
// force bypasses the gate for an explicit user start (start_scan()) — but
// only while the failure streak is clean. Once the controller is failing,
// 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))
return false;
this->last_scan_start_attempt_ = now;
this->start_scan_();
if (!this->scan_running_ && 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"
" 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_));
}
// ---------------------------------------------------------------------------
// Scan report — delivered by the controller's loop() on the ESPHome main task
// (the controller queues reports from the BLE task), so publish_state() and
// 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);
}
#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;
}
}
// 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
}
// ---------------------------------------------------------------------------
// Public scan control
// ---------------------------------------------------------------------------
void BK72xxBLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning.
//
// Nothing to do while a scan is already running: latching here would leave
// scan_requested_ set after that scan ends and silently restart a one-shot
// scan nobody asked for.
if (this->scan_running_)
return;
// The request is latched: if this immediate attempt fails (controller busy,
// WiFi/BLE coexistence), loop() keeps retrying it with backoff even in
// non-continuous mode, so a one-shot start cannot fail silently.
//
// Routed through the backoff helper (forced: the user asked for an immediate
// attempt) so a failure here still counts toward the backoff escalation and
// its WARN. The force bypass only applies while the failure streak is clean —
// 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);
}
void BK72xxBLETracker::restart_scan_duration() {
if (!this->scan_running_)
return;
// Re-anchor only the one-shot duration clock. scan_period_start_ (the
// continuous-mode on_scan_end period) is deliberately left alone: a
// 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();
}
void BK72xxBLETracker::stop_scan() {
this->scan_continuous_ = false;
this->scan_requested_ = false; // also cancels a pending (not yet successful) start
this->stop_scan_();
}
// ---------------------------------------------------------------------------
// Internal scan start / stop
// ---------------------------------------------------------------------------
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_)))
return;
const uint32_t now = millis();
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->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);
// 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
// mode 10 minutes later, does not fire on_scan_end before an advertisement can
// arrive). scan_started_once_ purely gates the period timer.
this->scan_period_start_ = now;
this->scan_started_once_ = true;
}
void BK72xxBLETracker::stop_scan_() {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
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
}
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
@@ -0,0 +1,181 @@
// bk72xx_ble_tracker.h
//
// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family).
// Implements the platform-neutral ble_device_base::BLEHub contract on top of the
// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered
// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the
// hub's raw-advertisement callback.
//
// This component contains no Beken SDK calls and no cross-task state: the
// controller (stack bring-up, BLE address, scan primitives, and the BLE-task →
// main-task report queue) is owned by bk72xx_ble, which delivers every scan
// report on the ESPHome main task. The tracker owns scan policy — parameters,
// duration/period timers and the rate-limited start retry.
//
// YAML config (values shown are the defaults; interval/window are a 30 % duty
// cycle, the BK reference scan rate):
//
// bk72xx_ble_tracker:
// scan_parameters:
// interval: 100ms
// window: 30ms
// duration: 5min
// continuous: true
#pragma once
#ifdef USE_LIBRETINY
#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/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
namespace esphome::bk72xx_ble_tracker {
// ---------------------------------------------------------------------------
// BK72xxBLETracker
// ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
public:
// ---- ESPHome Component ----
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention);
// mirrors esp32_ble_tracker.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
// ---- YAML configuration setters ----
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.continuous); also the value
/// configured_continuous() reports and a bare start_scan action restores.
void set_configured_continuous(bool scan_continuous) {
this->scan_continuous_ = scan_continuous;
this->scan_continuous_configured_ = scan_continuous;
}
/// Runtime control (esp32_ble_tracker lambda parity): does not change the
/// configured value, so configured_continuous() still reports what YAML
/// asked for.
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
bool scan_continuous() const { return this->scan_continuous_; }
bool configured_continuous() const { return this->scan_continuous_configured_; }
/// Re-anchor the one-shot duration clock of a running scan to now — used
/// when an action changes the scan mode without stopping the radio. The
/// continuous-mode on_scan_end period is deliberately not touched.
void restart_scan_duration();
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
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 set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->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;
}
// 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];
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
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
// BLE-task → main-task handoff already happened in the controller's queue.
void on_scan_report(const bk72xx_ble::BLEScanReport &report) override;
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.
bool try_start_with_backoff_(uint32_t now, bool force = false);
bool scan_running_{false};
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
// 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
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
uint32_t scan_duration_{300000};
bool scan_continuous_{true};
bool scan_continuous_configured_{true}; // YAML value; stop_scan() 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()
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
};
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
+36
View File
@@ -9,6 +9,7 @@ from esphome.const import (
CONF_ID,
CONF_MAC_ADDRESS,
CONF_NAME,
CONF_NOTIFY,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_SERVICE_UUID,
@@ -16,11 +17,46 @@ from esphome.const import (
CONF_VALUE,
)
from esphome.core import ID
from esphome.types import ConfigType
AUTO_LOAD = ["esp32_ble_client"]
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
DEPENDENCIES = ["esp32_ble_tracker"]
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
def validate_descriptor_not_notify(config: ConfigType) -> ConfigType:
"""Reject descriptor_uuid combined with notify or on_notify.
BLE descriptors cannot send notifications; only characteristics can, and
ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify.
"""
if CONF_DESCRIPTOR_UUID in config and (
config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config
):
raise cv.Invalid(
f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or "
f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove "
f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or "
f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor"
)
return config
def notify_from_on_notify(config: ConfigType) -> ConfigType:
"""Enable notifications when an on_notify automation is configured.
The triggers have no registration path of their own; without notify the
automation would validate but never fire.
"""
if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]:
config = config.copy()
config[CONF_NOTIFY] = True
return config
ble_client_ns = cg.esphome_ns.namespace("ble_client")
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
BLEClientNode = ble_client_ns.class_("BLEClientNode")
+3 -1
View File
@@ -51,7 +51,9 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
if (!this->services_.empty() && this->all_nodes_established_()) {
// 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");
}
@@ -34,6 +34,11 @@ class BLEClientNode {
// 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_; }
@@ -14,13 +14,16 @@ from esphome.const import (
UNIT_DECIBEL_MILLIWATT,
)
from .. import ble_client_ns
from .. import (
CONF_DESCRIPTOR_UUID,
CONF_ON_NOTIFY,
ble_client_ns,
notify_from_on_notify,
validate_descriptor_not_notify,
)
DEPENDENCIES = ["ble_client"]
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
TYPE_CHARACTERISTIC = "characteristic"
TYPE_RSSI = "rssi"
@@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All(
},
lower=True,
),
validate_descriptor_not_notify,
notify_from_on_notify,
)
@@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
break;
}
this->handle = chr->handle;
if (this->descr_uuid_.get_uuid().len > 0) {
if (this->descr_uuid_.is_set()) {
auto *descr = chr->get_descriptor(this->descr_uuid_);
if (descr == nullptr) {
this->status_set_warning();
@@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
this->handle = descr->handle;
}
if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
this->parent()->get_remote_bda(), chr->handle);
auto status = this->parent()->register_for_notify(chr->handle);
if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
}
@@ -9,13 +9,16 @@ from esphome.const import (
CONF_TRIGGER_ID,
)
from .. import ble_client_ns
from .. import (
CONF_DESCRIPTOR_UUID,
CONF_ON_NOTIFY,
ble_client_ns,
notify_from_on_notify,
validate_descriptor_not_notify,
)
DEPENDENCIES = ["ble_client"]
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
adv_data_t = cg.std_vector.template(cg.uint8)
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const")
@@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.polling_component_schema("60s"))
.extend(ble_client.BLE_CLIENT_SCHEMA)
.extend(ble_client.BLE_CLIENT_SCHEMA),
validate_descriptor_not_notify,
notify_from_on_notify,
)
@@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
break;
}
this->handle = chr->handle;
if (this->descr_uuid_.get_uuid().len > 0) {
if (this->descr_uuid_.is_set()) {
auto *descr = chr->get_descriptor(this->descr_uuid_);
if (descr == nullptr) {
this->status_set_warning();
@@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->handle = descr->handle;
}
if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
this->parent()->get_remote_bda(), chr->handle);
auto status = this->parent()->register_for_notify(chr->handle);
if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
}
@@ -0,0 +1,343 @@
"""
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.
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
configuration declares via `cv.use_id(BLEHub)` ESPHome resolves any declared
subclass, so there is no platform table here and no dependency in either
direction. A sensor extends BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an
explicit ble_hub_id: is a declared key even on strict schemas) and calls
register_ble_device() in to_code; a tracker component subclasses BLEHub (C++
and codegen class) and MUST call register_hub_provider() at import time
without it _require_hub rejects configs that bind through the generated id
(an explicit ble_hub_id: bypasses the registry). Adding a new BLE chip
requires only a new in-tree tracker component; out-of-tree BLE hubs are
not supported.
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,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID, KEY_CORE
from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"]
CONF_BLE_HUB_ID = "ble_hub_id"
# Number of parsed-advertisement listeners registered in this build; read via
# cg.get_slot_count() by esp32_ble_tracker's feature coupling.
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.
BLEHub = ble_device_base_ns.class_("BLEHub")
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
# 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()
# 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:
"""Compile in resolve_irk()'s software-AES path. Called by sensors with an
irk: option so builds without IRK do not carry the resolution code."""
cg.add_define("USE_BLE_DEVICE_IRK")
# Number of GATT client connection slots in this build; sizes the platform
# backend's connection storage.
GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT"
_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
def request_gatt_client() -> None:
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
claim one connection slot. Called by bluetooth_proxy once per connection
it instantiates on a hub platform."""
cg.add_define("USE_BLE_GATT_CLIENT")
_request_gatt_connection_slot()
_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
"""Register `var` as a parsed-advertisement listener on the configured hub."""
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
cg.add(hub.register_listener(var))
_request_listener_slot()
return var
# ---- shared validation / codegen helpers (platform-neutral) ----
def to_ble_units(value: cv.TimePeriod) -> int:
"""Convert a scan time to the controller's 0.625 ms units.
Used by both validation and codegen so what is validated is exactly what is
programmed the truncation here is what makes the duty-cycle check below
meaningful.
"""
return value.total_microseconds // 625
def validate_scan_parameters(config: ConfigType) -> ConfigType:
"""Reject impossible window/interval/duration combinations at config time.
The controller cannot scan for longer than the interval, and a too-short
duration would end the scan period almost immediately. Catching it here
gives a clear error instead of a runtime controller failure and a retry
loop.
"""
duration = config[CONF_DURATION]
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
# values here instead of letting the unit conversion silently overflow.
for name, value in (("interval", interval), ("window", window)):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
)
# Validate what actually reaches the controller: both values are truncated to
# whole 0.625 ms units, so a window/interval pair that differs by less than one
# unit collapses to the same value — silently programming a 100 % duty cycle
# (radio permanently on) from a config that asked for less.
interval_units = to_ble_units(interval)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
if interval.total_microseconds * 3 > duration.total_microseconds:
raise cv.Invalid(
f"Scan duration ({duration}) must cover at least three scan intervals "
f"({interval}): the scanner listens on one of the three BLE advertising "
f"channels per interval, so a shorter duration can miss devices entirely."
)
return config
def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
supports_active: bool = False,
) -> 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).
"""
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,
}
if supports_active:
schema[cv.Optional(CONF_ACTIVE, default=True)] = cv.boolean
return cv.All(cv.Schema(schema), validate_scan_parameters)
BT_UUID16_FORMAT = "XXXX"
BT_UUID32_FORMAT = "XXXXXXXX"
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$")
_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$")
_BT_UUID128_RE = re.compile(
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
)
# Validator table keyed by input length: (compiled pattern, label used in errors).
_BT_UUID_FORMATS = {
len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"),
len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"),
len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"),
}
def bt_uuid(value: str) -> str:
in_value = cv.string_strict(value)
value = in_value.upper()
fmt = _BT_UUID_FORMATS.get(len(value))
if fmt is None:
raise cv.Invalid(
f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format"
)
pattern, label = fmt
if not pattern.match(value):
raise cv.Invalid(
f"Invalid hexadecimal value for {label} UUID format: '{in_value}'"
)
return value
def as_hex(value: str) -> cg.RawExpression:
return cg.RawExpression(f"0x{value}ULL")
def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression:
value = value.replace("-", "")
cpp_array = [
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
]
if reverse:
cpp_array.reverse()
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
def as_hex_array(value: str) -> cg.RawExpression:
return _hex_array_expression(value, reverse=False)
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}")
@@ -0,0 +1,117 @@
// 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.
#pragma once
#include "ble_device.h"
#include "ble_hub.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <initializer_list>
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); }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
bool parse_device(const ESPBTDevice &device) override {
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
device.address_uint64()) == this->addresses_.end()) {
return false;
}
this->trigger(device);
return true;
}
protected:
FixedVector<uint64_t> addresses_;
};
// on_ble_service_data_advertise: fires when an advertisement contains service
// 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); }
void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); }
void set_address(uint64_t address) {
this->address_ = address;
this->has_address_ = true;
}
bool parse_device(const ESPBTDevice &device) override {
if (this->has_address_ && device.address_uint64() != this->address_) {
return false;
}
for (const auto &sd : device.get_service_datas()) {
if (sd.uuid == this->uuid_) {
this->trigger(sd.data);
return true;
}
}
return false;
}
protected:
ESPBTUUID uuid_{};
uint64_t address_{0};
bool has_address_{false};
};
// on_ble_manufacturer_data_advertise: fires when an advertisement contains
// 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); }
void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); }
void set_address(uint64_t address) {
this->address_ = address;
this->has_address_ = true;
}
bool parse_device(const ESPBTDevice &device) override {
if (this->has_address_ && device.address_uint64() != this->address_) {
return false;
}
for (const auto &md : device.get_manufacturer_datas()) {
if (md.uuid == this->uuid_) {
this->trigger(md.data);
return true;
}
}
return false;
}
protected:
ESPBTUUID uuid_{};
uint64_t address_{0};
bool has_address_{false};
};
// on_scan_end: fires whenever a scan period ends (duration elapsed or stop
// requested). A listener whose on_scan_end() hook fires the trigger — never
// claims devices (parse_device always returns false).
class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener {
public:
explicit BLEEndOfScanTrigger(BLEHub *parent) { parent->register_listener(this); }
bool parse_device(const ESPBTDevice &device) override { return false; }
void on_scan_end() override { this->trigger(); }
};
} // namespace esphome::ble_device_base
@@ -0,0 +1,128 @@
"""Shared codegen for the neutral BLE advertisement triggers (automation.h)."""
from typing import Any
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID
from esphome.cpp_generator import MockObjClass
from esphome.types import ConfigType
from . import (
BT_UUID16_FORMAT,
BT_UUID32_FORMAT,
BT_UUID128_FORMAT,
LISTENER_COUNT_DEFINE,
as_hex,
as_reversed_hex_array,
ble_device_base_ns,
)
adv_data_t = cg.std_vector.template(cg.uint8)
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const")
ESPBTDeviceConstRef = (
ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const")
)
ESPBTAdvertiseTrigger = ble_device_base_ns.class_(
"ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef)
)
BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_(
"BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref)
)
BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_(
"BLEManufacturerDataAdvertiseTrigger",
automation.Trigger.template(adv_data_t_const_ref),
)
BLEEndOfScanTrigger = ble_device_base_ns.class_(
"BLEEndOfScanTrigger", automation.Trigger.template()
)
# UUID string length -> setter width. 16/32-bit go out as plain hex literals,
# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an
# impossible length fails as a KeyError instead of silently picking a width
# (bt_uuid validation upstream only ever produces these three).
_UUID_WIDTHS = {
len(BT_UUID16_FORMAT): "16",
len(BT_UUID32_FORMAT): "32",
len(BT_UUID128_FORMAT): "128",
}
def uuid_trigger_schema(
trigger_class: MockObjClass, extra: dict[Any, Any] | None = None
):
"""Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code().
`extra` carries the required UUID key (a cv marker, so a dict rather than
**kwargs); the optional single-mac filter is what uuid_trigger_to_code()
reads back.
"""
return automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
**(extra or {}),
}
)
def advertise_trigger_schema(trigger_class: MockObjClass):
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
uuid_trigger_schema() pairs with advertise_trigger_to_code()."""
return automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
}
)
def scan_end_trigger_schema(trigger_class: MockObjClass):
"""on_scan_end schema: id only — pairs with scan_end_trigger_to_code()."""
return automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)}
)
# Triggers register as ble_device_base listeners in their constructors; count
# them where they are created so no backend can undercount the StaticVector
# (push_back past capacity drops silently). Shares the define with
# register_ble_device() via the core slot-counter factory.
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
_count_listener()
async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
"""Build an on_scan_end trigger."""
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
_count_listener()
async def uuid_trigger_to_code(
conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str
) -> None:
"""Build a UUID-filtered advertise trigger.
The UUID width picks the setter: 16-/32-bit go out as a plain hex literal,
128-bit as a reversed byte array (BLE wire order).
"""
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
uuid = conf[key]
width = _UUID_WIDTHS[len(uuid)]
value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid)
cg.add(getattr(trigger, f"{setter_prefix}{width}")(value))
if (mac := conf.get(CONF_MAC_ADDRESS)) is not None:
cg.add(trigger.set_address(mac.as_hex))
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
_count_listener()
@@ -0,0 +1,202 @@
#include "ble_aes_ccm.h"
#include <algorithm>
#include <cstring>
namespace esphome::ble_device_base {
namespace {
// AES-128 forward cipher only — CCM uses the block cipher in the encrypt
// direction for both the CTR keystream and the CBC-MAC.
const uint8_t SBOX[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, //
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, //
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, //
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, //
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, //
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, //
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, //
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, //
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, //
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, //
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, //
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, //
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, //
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, //
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, //
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, //
};
const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36};
inline uint8_t xtime(uint8_t x) { return static_cast<uint8_t>((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); }
// AES-128 forward cipher with on-the-fly key schedule.
class Aes128 {
public:
explicit Aes128(const uint8_t key[16]) {
memcpy(this->rk_, key, 16);
for (size_t i = 16; i < 176; i += 4) {
uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]};
if (i % 16 == 0) {
const uint8_t tmp = t[0];
t[0] = static_cast<uint8_t>(SBOX[t[1]] ^ RCON[i / 16]);
t[1] = SBOX[t[2]];
t[2] = SBOX[t[3]];
t[3] = SBOX[tmp];
}
for (size_t j = 0; j < 4; j++)
this->rk_[i + j] = static_cast<uint8_t>(this->rk_[i - 16 + j] ^ t[j]);
}
}
void encrypt(const uint8_t in[16], uint8_t out[16]) const {
uint8_t s[16];
memcpy(s, in, 16);
for (size_t i = 0; i < 16; i++)
s[i] ^= this->rk_[i];
for (size_t round = 1; round < 10; round++) {
for (uint8_t &b : s)
b = SBOX[b];
shift_rows(s);
for (size_t c = 0; c < 4; c++) {
uint8_t *col = s + c * 4;
const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3];
const uint8_t h = static_cast<uint8_t>(a0 ^ a1 ^ a2 ^ a3);
col[0] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a0 ^ a1)));
col[1] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a1 ^ a2)));
col[2] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a2 ^ a3)));
col[3] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a3 ^ a0)));
}
for (size_t i = 0; i < 16; i++)
s[i] ^= this->rk_[round * 16 + i];
}
for (uint8_t &b : s)
b = SBOX[b];
shift_rows(s);
for (size_t i = 0; i < 16; i++)
s[i] ^= this->rk_[160 + i];
memcpy(out, s, 16);
}
protected:
static void shift_rows(uint8_t s[16]) {
uint8_t t = s[1];
s[1] = s[5];
s[5] = s[9];
s[9] = s[13];
s[13] = t;
t = s[2];
s[2] = s[10];
s[10] = t;
t = s[6];
s[6] = s[14];
s[14] = t;
t = s[3];
s[3] = s[15];
s[15] = s[11];
s[11] = s[7];
s[7] = t;
}
uint8_t rk_[176];
};
} // namespace
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) {
Aes128 aes(key);
aes.encrypt(in, out);
}
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
const uint8_t *tag, size_t tag_len) {
// CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte
// nonce L = 2; BTHome uses M = 4.
if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16)
return false;
const size_t l = 15 - nonce_len;
const size_t m = tag_len;
const Aes128 aes(key);
// Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian).
uint8_t a[16];
auto build_ctr = [&](uint32_t counter) {
a[0] = static_cast<uint8_t>(l - 1);
memcpy(a + 1, nonce, nonce_len);
memset(a + 1 + nonce_len, 0, l);
for (size_t i = 0; i < l; i++)
a[15 - i] = static_cast<uint8_t>((counter >> (8 * i)) & 0xff);
};
// S_0 = E(A_0); its first m bytes mask the transmitted tag.
uint8_t s0[16];
build_ctr(0);
aes.encrypt(a, s0);
// CTR-decrypt ciphertext into plaintext using S_1, S_2, ...
uint8_t ks[16];
for (size_t off = 0; off < ct_len; off += 16) {
build_ctr(static_cast<uint32_t>(off / 16) + 1);
aes.encrypt(a, ks);
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
for (size_t i = 0; i < n; i++)
plaintext[off + i] = static_cast<uint8_t>(ciphertext[off + i] ^ ks[i]);
}
// CBC-MAC over B_0 | (formatted AAD) | plaintext.
uint8_t x[16];
uint8_t b0[16];
const uint8_t flags = static_cast<uint8_t>((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1));
b0[0] = flags;
memcpy(b0 + 1, nonce, nonce_len);
memset(b0 + 1 + nonce_len, 0, l);
for (size_t i = 0; i < l; i++)
b0[15 - i] = static_cast<uint8_t>((ct_len >> (8 * i)) & 0xff);
aes.encrypt(b0, x); // X_1 = E(B_0)
if (aad_len > 0) {
// Only the < 2^16-2^8 encoding is needed for BLE-sized AAD.
uint8_t blk[16] = {0};
blk[0] = static_cast<uint8_t>((aad_len >> 8) & 0xff);
blk[1] = static_cast<uint8_t>(aad_len & 0xff);
size_t ai = 0;
size_t pos = 2;
while (pos < 16 && ai < aad_len)
blk[pos++] = aad[ai++];
for (size_t i = 0; i < 16; i++)
x[i] ^= blk[i];
aes.encrypt(x, x);
while (ai < aad_len) {
memset(blk, 0, 16);
const size_t n = std::min(static_cast<size_t>(16), aad_len - ai);
memcpy(blk, aad + ai, n);
ai += n;
for (size_t i = 0; i < 16; i++)
x[i] ^= blk[i];
aes.encrypt(x, x);
}
}
for (size_t off = 0; off < ct_len; off += 16) {
uint8_t blk[16] = {0};
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
memcpy(blk, plaintext + off, n);
for (size_t i = 0; i < 16; i++)
x[i] ^= blk[i];
aes.encrypt(x, x);
}
// Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag.
uint8_t diff = 0;
for (size_t i = 0; i < m; i++)
diff |= static_cast<uint8_t>((x[i] ^ s0[i]) ^ tag[i]);
return diff == 0;
}
} // namespace esphome::ble_device_base
@@ -0,0 +1,34 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace esphome::ble_device_base {
// Self-contained AES-128-CCM authenticated decryption (RFC 3610).
//
// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use
// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE
// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls
// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not
// put it on the application include path), so a sensor cannot rely on
// <mbedtls/ccm.h> being available. This software implementation makes
// encrypted-advertisement decryption work on every BLE platform without a
// per-chip crypto dependency. Decryption volume is tiny (one short block per
// matching advertisement), so software AES is not a meaningful cost.
//
// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted
// bytes to `plaintext` and returns true. Returns false when authentication fails
// (the caller must then discard `plaintext`). The CCM parameters follow the
// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be
// null when `aad_len` is 0.
/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by
/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching
/// works identically on every platform with no chip crypto dependency.
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
const uint8_t *tag, size_t tag_len);
} // namespace esphome::ble_device_base
@@ -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,53 @@
// 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;
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
@@ -0,0 +1,598 @@
// ble_device.cpp
//
// Platform-neutral implementation of the shared BLE advertisement types.
// Parses raw BLE advertisement data into ESPBTDevice.
#include "ble_device.h"
#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"
#include <cstring>
namespace esphome::ble_device_base {
static const char *const TAG = "ble_device_base";
// Longest advertisement payload worth hex-dumping at VERY_VERBOSE
// (legacy advertising: 31-byte adv + 31-byte scan response).
static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62;
// ---------------------------------------------------------------------------
// ESPBTUUID
// ---------------------------------------------------------------------------
ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) {
ESPBTUUID ret;
ret.type_ = Type::UUID16;
ret.uuid_.uuid16 = uuid;
return ret;
}
ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) {
ESPBTUUID ret;
ret.type_ = Type::UUID32;
ret.uuid_.uuid32 = uuid;
return ret;
}
ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) {
ESPBTUUID ret;
ret.type_ = Type::UUID128;
memcpy(ret.uuid_.uuid128, data, 16);
return ret;
}
ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) {
ESPBTUUID ret;
ret.type_ = Type::UUID128;
for (int i = 0; i < 16; i++)
ret.uuid_.uuid128[i] = data[15 - i];
return ret;
}
ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
// Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw.
ESPBTUUID ret;
if (length == 4) {
// 16-bit UUID as 4-character hex string
auto parsed = parse_hex<uint16_t>(data, length);
if (parsed.has_value()) {
ret.type_ = Type::UUID16;
ret.uuid_.uuid16 = parsed.value();
}
} else if (length == 8) {
// 32-bit UUID as 8-character hex string
auto parsed = parse_hex<uint32_t>(data, length);
if (parsed.has_value()) {
ret.type_ = Type::UUID32;
ret.uuid_.uuid32 = parsed.value();
}
} else if (length == 16) {
// 16 raw bytes (little-endian 128-bit UUID)
ret.type_ = Type::UUID128;
memcpy(ret.uuid_.uuid128, reinterpret_cast<const uint8_t *>(data), 16);
} else if (length == 36) {
// Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
ret.type_ = Type::UUID128;
int n = 0;
for (size_t i = 0; i < length; i += 2) {
if (data[i] == '-')
i++;
uint8_t msb = data[i];
uint8_t lsb = data[i + 1];
if (msb > '9')
msb -= 7;
if (lsb > '9')
lsb -= 7;
ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F);
}
} else {
ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data);
}
return ret;
}
#ifdef USE_ESP32
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
if (uuid.len == 0) // the unset sentinel get_uuid() emits
return {};
if (uuid.len == ESP_UUID_LEN_16)
return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
if (uuid.len == ESP_UUID_LEN_32)
return ESPBTUUID::from_uint32(uuid.uuid.uuid32);
return ESPBTUUID::from_raw(uuid.uuid.uuid128);
}
esp_bt_uuid_t ESPBTUUID::get_uuid() const {
esp_bt_uuid_t ret;
switch (this->type_) {
case Type::UNSET:
ret.len = 0;
memset(&ret.uuid, 0, sizeof(ret.uuid));
break;
case Type::UUID16:
ret.len = ESP_UUID_LEN_16;
ret.uuid.uuid16 = this->uuid_.uuid16;
break;
case Type::UUID32:
ret.len = ESP_UUID_LEN_32;
ret.uuid.uuid32 = this->uuid_.uuid32;
break;
default:
case Type::UUID128:
ret.len = ESP_UUID_LEN_128;
memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128);
break;
}
return ret;
}
void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
this->scan_result_ = &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];
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,
scan_result.adv_data_len + scan_result.scan_rsp_len);
}
#endif // USE_ESP32
ESPBTUUID ESPBTUUID::as_128bit() const {
// Widening an unset UUID stays unset; expanding it would produce a set 0x0000 base UUID.
if (this->type_ == Type::UNSET || this->type_ == Type::UUID128)
return *this;
uint8_t data[16];
this->to_128bit_(data);
return ESPBTUUID::from_raw(data);
}
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
switch (this->type_) {
case Type::UNSET:
return false;
case Type::UUID16:
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
case Type::UUID32:
for (uint8_t i = 0; i < 3; i++) {
bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1;
bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2;
if (a && b)
return true;
}
return false;
case Type::UUID128:
for (uint8_t i = 0; i < 15; i++) {
if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2)
return true;
}
return false;
}
return false;
}
const char *ESPBTUUID::to_str(char *buf) const {
// Identical output format to esp32_ble::ESPBTUUID::to_str.
char *pos = buf;
switch (this->type_) {
case Type::UNSET:
memcpy(buf, "None", 5);
return buf;
case Type::UUID16:
*pos++ = '0';
*pos++ = 'x';
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12);
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F);
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F);
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F);
*pos = 0; // NUL-terminate
return buf;
case Type::UUID32:
*pos++ = '0';
*pos++ = 'x';
for (int shift = 28; shift >= 0; shift -= 4)
*pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F);
*pos = 0; // NUL-terminate
return buf;
default:
case Type::UUID128:
// Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
for (int8_t i = 15; i >= 0; i--) {
uint8_t byte = this->uuid_.uuid128[i];
*pos++ = format_hex_pretty_char(byte >> 4);
*pos++ = format_hex_pretty_char(byte & 0x0F);
if (i == 12 || i == 10 || i == 8 || i == 6)
*pos++ = '-';
}
*pos = 0; // NUL-terminate
return buf;
}
}
void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
// Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit
// value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit().
// Callers screen out UNSET first (operator==, as_128bit); it would expand like 0x0000.
static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
if (this->type_ == Type::UUID128) {
memcpy(out, this->uuid_.uuid128, 16);
return;
}
memcpy(out, BASE, 16);
const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16;
const size_t len = (this->type_ == Type::UUID32) ? 4 : 2;
for (size_t i = 0; i < len; i++)
out[12 + i] = (value >> (i * 8)) & 0xFF;
}
bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
if (this->type_ == other.type_) {
switch (this->type_) {
case Type::UNSET:
return true;
case Type::UUID16:
return this->uuid_.uuid16 == other.uuid_.uuid16;
case Type::UUID32:
return this->uuid_.uuid32 == other.uuid_.uuid32;
case Type::UUID128:
return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0;
}
return false;
}
// Unset never equals a set UUID; 0x0000 is a valid value, distinct from "not configured".
if (this->type_ == Type::UNSET || other.type_ == Type::UNSET)
return false;
// Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a
// configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
uint8_t a[16];
uint8_t b[16];
this->to_128bit_(a);
other.to_128bit_(b);
return memcmp(a, b, 16) == 0;
}
// ---------------------------------------------------------------------------
// ESPBLEiBeacon
// ---------------------------------------------------------------------------
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_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
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());
}
// ---------------------------------------------------------------------------
// 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:
return "PUBLIC";
case BLE_ADDR_TYPE_RANDOM:
return "RANDOM";
case BLE_ADDR_TYPE_RPA_PUBLIC:
return "RPA_PUBLIC";
case BLE_ADDR_TYPE_RPA_RANDOM:
return "RPA_RANDOM";
default:
return "UNKNOWN";
}
}
void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
// Ingest is BLE controller order (LSB-first); store in printable (MSB-first)
// order so the raw address() accessor matches the historical esp32 layout.
for (uint8_t i = 0; i < 6; i++)
this->address_[i] = mac[5 - i];
this->address_type_ = addr_type;
this->rssi_ = rssi;
this->name_len_ = 0;
this->name_[0] = '\0';
this->service_uuids_.clear();
this->manufacturer_datas_.clear();
this->service_datas_.clear();
this->tx_powers_.clear();
this->appearance_.reset();
this->ad_flag_.reset();
this->parse_adv_(data, data_len);
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGVV(TAG,
"Parse Result:\n"
" Address: %s (%s)\n"
" RSSI: %d\n"
" Name: '%s'",
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);
}
if (this->appearance_.has_value()) {
ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_);
}
if (this->ad_flag_.has_value()) {
ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_);
}
char uuid_buf[UUID_STR_LEN];
for (auto &uuid : this->service_uuids_) {
ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf));
}
char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)];
for (auto &mfg_data : this->manufacturer_datas_) {
auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data);
if (ibeacon.has_value()) {
ESP_LOGVV(TAG,
" Manufacturer iBeacon:\n"
" UUID: %s\n"
" Major: %u\n"
" Minor: %u\n"
" TXPower: %d",
ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(),
ibeacon.value().get_signal_power());
} else {
ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf),
format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size()));
}
}
for (auto &svc_data : this->service_datas_) {
ESP_LOGVV(TAG,
" Service data:\n"
" UUID: %s\n"
" Data: %s",
svc_data.uuid.to_str(uuid_buf),
format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size()));
}
ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len));
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE
}
// Remove before 2027.2.0
std::string ESPBTDevice::address_str() const {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->address_str_to(buf));
}
const char *ESPBTDevice::address_str_to(char *buf) const {
// address_ is stored in printable (MSB-first) order.
format_mac_addr_upper(this->address_, buf);
return buf;
}
uint64_t ESPBTDevice::address_uint64() const {
// address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics).
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
addr |= static_cast<uint64_t>(this->address_[i]) << ((5 - i) * 8);
return addr;
}
bool ESPBTDevice::resolve_irk(const uint8_t *irk) const {
#ifdef USE_BLE_DEVICE_IRK
// Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits].
// The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes).
// Uses the portable software AES-128 shared with the CCM decryptor, so IRK
// matching behaves identically on every platform (volume is one block per
// advertisement from a matching RPA device — software AES is not a cost).
uint8_t ecb_plaintext[16] = {0};
uint8_t ecb_ciphertext[16];
const uint64_t addr64 = this->address_uint64();
ecb_plaintext[13] = (addr64 >> 40) & 0xff;
ecb_plaintext[14] = (addr64 >> 32) & 0xff;
ecb_plaintext[15] = (addr64 >> 24) & 0xff;
aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext);
return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) &&
ecb_ciphertext[13] == ((addr64 >> 16) & 0xff);
#else
// No sensor configured an irk: in this build; the AES core is compiled out.
(void) irk;
return false;
#endif
}
void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
// BLE AD structure TLV: [length][type][value...]
// length includes the type byte.
uint16_t offset = 0;
while (offset < len) {
uint8_t ad_len = payload[offset++];
if (ad_len == 0)
continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too)
if (offset + ad_len > len)
break;
uint8_t ad_type = payload[offset];
const uint8_t *ad_data = &payload[offset + 1];
uint8_t ad_data_len = ad_len - 1;
offset += ad_len;
switch (ad_type) {
case 0x01: // Flags
if (ad_data_len >= 1)
this->ad_flag_ = ad_data[0];
break;
case 0x08: // Shortened Local Name
case 0x09: // Complete Local Name
// 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_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
if (ad_data_len >= 1)
this->tx_powers_.push_back(static_cast<int8_t>(ad_data[0]));
break;
case 0x19: // Appearance
if (ad_data_len >= 2)
this->appearance_ = static_cast<uint16_t>(ad_data[0]) | (static_cast<uint16_t>(ad_data[1]) << 8);
break;
case 0x02: // Incomplete List of 16-bit Service UUIDs
case 0x03: // Complete List of 16-bit Service UUIDs
for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) {
uint16_t uuid = (static_cast<uint16_t>(ad_data[i + 1]) << 8) | ad_data[i];
this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid));
}
break;
case 0x04: // Incomplete List of 32-bit Service UUIDs
case 0x05: // Complete List of 32-bit Service UUIDs
for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) {
uint32_t uuid = (static_cast<uint32_t>(ad_data[i + 3]) << 24) |
(static_cast<uint32_t>(ad_data[i + 2]) << 16) | (static_cast<uint32_t>(ad_data[i + 1]) << 8) |
ad_data[i];
this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid));
}
break;
case 0x06: // Incomplete List of 128-bit Service UUIDs
case 0x07: // Complete List of 128-bit Service UUIDs
for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16)
this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i]));
break;
case 0xFF: // Manufacturer Specific Data
if (ad_data_len >= 2) {
uint16_t company_id = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
ServiceData sd;
sd.uuid = ESPBTUUID::from_uint16(company_id);
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
this->manufacturer_datas_.push_back(std::move(sd));
}
break;
case 0x16: // Service Data — 16-bit UUID
if (ad_data_len >= 2) {
uint16_t uuid = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
ServiceData sd;
sd.uuid = ESPBTUUID::from_uint16(uuid);
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
this->service_datas_.push_back(std::move(sd));
}
break;
case 0x20: // Service Data — 32-bit UUID
if (ad_data_len >= 4) {
uint32_t uuid = (static_cast<uint32_t>(ad_data[3]) << 24) | (static_cast<uint32_t>(ad_data[2]) << 16) |
(static_cast<uint32_t>(ad_data[1]) << 8) | ad_data[0];
ServiceData sd;
sd.uuid = ESPBTUUID::from_uint32(uuid);
sd.data.assign(ad_data + 4, ad_data + ad_data_len);
this->service_datas_.push_back(std::move(sd));
}
break;
case 0x21: // Service Data — 128-bit UUID
if (ad_data_len >= 16) {
ServiceData sd;
sd.uuid = ESPBTUUID::from_raw(ad_data);
sd.data.assign(ad_data + 16, ad_data + ad_data_len);
this->service_datas_.push_back(std::move(sd));
}
break;
default:
break;
}
}
}
// ---------------------------------------------------------------------------
// DiscoveredDeviceLog
// ---------------------------------------------------------------------------
void DiscoveredDeviceLog::log_device(const char *tag, const ESPBTDevice &device) {
#ifdef ESPHOME_LOG_HAS_DEBUG
// Everything here feeds ESP_LOGD: below DEBUG the whole body (including the
// dedup vector growth) would be pure overhead, so compile it out entirely.
const uint64_t address = device.address_uint64();
for (auto &disc : this->already_discovered_) {
if (disc == address)
return;
}
this->already_discovered_.push_back(address);
char addr_buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD(tag,
"Found device %s RSSI=%d\n"
" Address Type: %s",
device.address_str_to(addr_buf), device.get_rssi(), device.address_type_str());
if (!device.get_name().empty()) {
ESP_LOGD(tag, " Name: '%s'", device.get_name().c_str());
}
for (auto &tx_power : device.get_tx_powers()) {
ESP_LOGD(tag, " TX Power: %d", tx_power);
}
#endif // ESPHOME_LOG_HAS_DEBUG
}
} // namespace esphome::ble_device_base
@@ -0,0 +1,295 @@
// ble_device.h
//
// Platform-neutral BLE advertisement types — the generic base every BLE consumer
// (sensor components, bluetooth_proxy, automation triggers) builds against:
// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener
//
// These types are owned here on EVERY platform, with no chip-SDK types in their
// public surface. Platform trackers produce them:
// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and
// re-exports these names (esp32 only) for backward compatibility;
// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly.
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <string>
#include <vector>
#if defined(__cpp_lib_span)
#include <span>
#endif
#ifdef USE_ESP32
// Historical esp32_ble API surface (below, under the same define) uses the
// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32.
#include "esphome/components/esp32_ble/ble_scan_result.h"
#include <esp_bt_defs.h>
#endif
namespace esphome::ble_device_base {
using adv_data_t = std::vector<uint8_t>;
// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t).
static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0;
static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1;
static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2;
static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3;
/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0"
static constexpr size_t UUID_STR_LEN = 37;
// ---------------------------------------------------------------------------
// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type.
// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t
// conversions live in esp32_ble (esp32-only adapters), not here.
// ---------------------------------------------------------------------------
class ESPBTUUID {
public:
ESPBTUUID() = default;
static ESPBTUUID from_uint16(uint16_t uuid);
static ESPBTUUID from_uint32(uint32_t uuid);
/// Construct from raw 16-byte little-endian UUID.
static ESPBTUUID from_raw(const uint8_t *data);
/// Construct from raw 16-byte big-endian UUID (reversed on store).
static ESPBTUUID from_raw_reversed(const uint8_t *data);
/// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes,
/// or the 36-char dashed UUID form. Same semantics as esp32_ble historically.
static ESPBTUUID from_raw(const char *data, size_t length);
static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); }
static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); }
static ESPBTUUID from_raw(std::initializer_list<uint8_t> data) {
return from_raw(reinterpret_cast<const char *>(data.begin()), data.size());
}
#ifdef USE_ESP32
/// Source compatibility with the historical esp32_ble API (esp32 builds only).
static ESPBTUUID from_uuid(esp_bt_uuid_t uuid);
esp_bt_uuid_t get_uuid() const;
#endif
/// Expand to the 128-bit Bluetooth Base UUID form.
ESPBTUUID as_128bit() const;
/// True if the UUID value contains the adjacent byte pair (data1, data2).
bool contains(uint8_t data1, uint8_t data2) const;
bool operator==(const ESPBTUUID &other) const;
bool operator!=(const ESPBTUUID &other) const { return !(*this == other); }
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form, or "None" for an
/// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf.
const char *to_str(char *buf) const;
#if defined(__cpp_lib_span)
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
#endif
// UNSET is the default-constructed state; get_uuid() reports it as len 0 (the historical sentinel).
enum class Type : uint8_t { UNSET, UUID16, UUID32, UUID128 };
Type type() const { return this->type_; }
/// True if a UUID has been configured (not default-constructed).
bool is_set() const { return this->type_ != Type::UNSET; }
uint16_t uuid16() const { return this->uuid_.uuid16; }
uint32_t uuid32() const { return this->uuid_.uuid32; }
const uint8_t *uuid128() const { return this->uuid_.uuid128; }
protected:
// Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
void to_128bit_(uint8_t out[16]) const;
Type type_{Type::UNSET};
union {
uint16_t uuid16;
uint32_t uuid32;
uint8_t uuid128[16];
} uuid_{};
};
// ---------------------------------------------------------------------------
// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types)
// ---------------------------------------------------------------------------
struct ServiceData {
ESPBTUUID uuid;
adv_data_t data;
};
// ---------------------------------------------------------------------------
// ESPBLEiBeacon
// ---------------------------------------------------------------------------
class ESPBLEiBeacon {
public:
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
explicit ESPBLEiBeacon(const uint8_t *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); }
int8_t get_signal_power() const { return this->beacon_data_.signal_power; }
ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); }
protected:
struct PACKED BeaconData {
uint8_t sub_type;
uint8_t length;
uint8_t proximity_uuid[16];
uint16_t major;
uint16_t minor;
int8_t signal_power;
} beacon_data_;
};
/// 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.
inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
addr |= static_cast<uint64_t>(mac[i]) << (i * 8);
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
// ---------------------------------------------------------------------------
class ESPBTDevice {
public:
/// Populate from a raw scan result delivered by a BLE tracker backend.
/// mac is least-significant octet first (BLE controller convention).
void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
// Alias the core constant so the two cannot drift apart.
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE;
/// Return MAC as "XX:XX:XX:XX:XX:XX" string.
ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0")
std::string address_str() const;
/// Writes "XX:XX:XX:XX:XX:XX\0" into buf (>= MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes), returns buf.
const char *address_str_to(char *buf) const;
#if defined(__cpp_lib_span)
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
return this->address_str_to(buf.data());
}
#endif
/// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64).
uint64_t address_uint64() const;
/// Raw MAC bytes in printable (MSB-first) order — matches the historical
/// esp32 layout (ESP-IDF bda order).
const uint8_t *address() const { return address_; }
#ifdef USE_ESP32
// 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_; }
#else
uint8_t get_address_type() const { return this->address_type_; }
#endif
/// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or
/// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above.
const char *address_type_str() const;
int get_rssi() const { return rssi_; }
/// 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_; }
const std::vector<ServiceData> &get_service_datas() const { return service_datas_; }
const std::vector<int8_t> &get_tx_powers() const { return tx_powers_; }
const optional<uint16_t> &get_appearance() const { return appearance_; }
const optional<uint8_t> &get_ad_flag() const { return ad_flag_; }
/// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah"
/// function, AES-128). Uses the portable software AES shared with the CCM
/// decryptor; compiled only when a sensor configures irk: (request_irk_support).
bool resolve_irk(const uint8_t *irk) const;
optional<ESPBLEiBeacon> get_ibeacon() const;
protected:
void parse_adv_(const uint8_t *payload, uint16_t len);
// Max name bytes in a legacy advertisement AD element (31-byte PDU minus
// the 2-byte element header); every in-tree tracker scans legacy PDUs only.
static constexpr uint8_t MAX_ADV_NAME_LEN = 29;
uint8_t address_[6]{0};
uint8_t address_type_{0};
int rssi_{0};
// 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_{};
#ifdef USE_ESP32
const esp32_ble::BLEScanResult *scan_result_{nullptr};
#endif
std::vector<int8_t> tx_powers_{};
optional<uint16_t> appearance_{};
optional<uint8_t> ad_flag_{};
};
// ---------------------------------------------------------------------------
// DiscoveredDeviceLog — shared per-scan-period "Found device" DEBUG logger
// ---------------------------------------------------------------------------
/// Per-scan-period "Found device" DEBUG logger, deduplicated by MAC address.
/// Shared by all tracker backends so the output format and dedup behaviour stay
/// identical by construction (single implementation instead of per-chip copies).
class DiscoveredDeviceLog {
public:
/// Log the device at DEBUG the first time its MAC is seen this scan period.
void log_device(const char *tag, const ESPBTDevice &device);
/// Reset the per-period dedup list (call when a scan period ends).
void clear() { this->already_discovered_.clear(); }
protected:
std::vector<uint64_t> already_discovered_;
};
// ---------------------------------------------------------------------------
// ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers)
// ---------------------------------------------------------------------------
class ESPBTDeviceListener {
public:
virtual ~ESPBTDeviceListener() = default;
/// Called at the end of each scan duration period.
virtual void on_scan_end() {}
virtual bool parse_device(const ESPBTDevice &device) = 0;
};
} // namespace esphome::ble_device_base
@@ -0,0 +1,140 @@
// ble_gatt_client.h
//
// Platform-neutral GATT client connection contract.
//
// A platform's GATT client backend (bluetooth_connection/esp32,
// bluetooth_connection/rp2) implements BLEGattConnection; consumers
// (bluetooth_proxy) drive it through this interface and receive
// completions through GattClientEventListener. All listener callbacks are
// delivered on the ESPHome main loop; borrowed data pointers are valid only
// for the duration of the call.
//
// Error domain (plain int, forwarded to the API without translation):
// 0 success
// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree)
// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw
// ESP_FAIL from the stack shares this value; both read as a
// failed, unusable connection on the client side)
// GATT_ERR_NO_MEMORY (-2) backend storage exhausted
// anything else: platform stack error/status code, surfaced opaquely.
// Connection events carry HCI status/disconnect reason codes (same code
// space on every controller).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "ble_client_state.h"
#include "ble_device.h"
#include <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};
};
/// Completion/event sink for a GATT connection. Implemented by the consumer
/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop.
class GattClientEventListener {
public:
virtual ~GattClientEventListener() = default;
/// Connected (with negotiated MTU) or disconnected/connect-failed
/// (error = HCI status or disconnect reason).
virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0;
/// Service discovery finished; on success the service table is populated.
virtual void on_service_discovery_done(int error) = 0;
/// Characteristic or descriptor read finished. data/len valid during the call.
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0;
/// Characteristic write-with-response or descriptor write finished.
virtual void on_write_result(uint16_t handle, int error) = 0;
/// Notification/indication registration state changed.
virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0;
/// Notification/indication data from the peer. data/len valid during the call.
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
};
/// One GATT client connection slot. Operations return 0 when accepted
/// (completion arrives via the listener) or a synchronous error code
/// (busy, not connected, stack rejection). One operation may be outstanding
/// at a time; callers see a synchronous error otherwise.
class BLEGattConnection {
public:
virtual ~BLEGattConnection() = default;
void set_listener(GattClientEventListener *listener) { this->listener_ = listener; }
/// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant
/// (ble_device.h). Completion: on_connection_state().
virtual int connect(uint64_t address, uint8_t addr_type) = 0;
/// Disconnect (or cancel a connect in progress). Completion: on_connection_state().
virtual int disconnect() = 0;
/// Discover the peer's services/characteristics/descriptors into the
/// service table. Completion: on_service_discovery_done().
virtual int discover_services() = 0;
virtual int read_characteristic(uint16_t handle) = 0;
virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0;
virtual int read_descriptor(uint16_t handle) = 0;
virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
/// Enable/disable delivery of on_notify_data() for a characteristic value
/// handle. Local registration only — the CCCD write is the API client's
/// responsibility (it arrives as a plain write_descriptor).
virtual int notify_characteristic(uint16_t handle, bool enable) = 0;
virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) = 0;
/// Backend-owned service table (see GattServiceTable lifetime).
virtual GattServiceTable get_service_table() = 0;
/// Free the transient service table storage. Call after streaming.
virtual void release_services() = 0;
protected:
GattClientEventListener *listener_{nullptr};
};
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -0,0 +1,101 @@
// 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.
//
// Chip differences are expressed as data (HubCapabilities), never as
// platform conditionals in consumers.
#pragma once
#include "ble_device.h"
#include <cstdint>
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;
const uint8_t *data;
uint16_t data_len;
int8_t rssi; // signed dBm
uint8_t addr_type;
};
/// Subscriber slot for the raw-advertisement stream (the bluetooth_proxy
/// path). The hub delivers on the ESPHome main loop. Same shape as
/// logger.h's LogCallback: an instance pointer plus a plain function
/// pointer — no virtuals, no std::function.
///
/// Usage:
/// hub->set_raw_advertisement_callback({this, [](void *self, const RawAdvertisement &adv) {
/// static_cast<MyComponent *>(self)->on_raw_advertisement(adv);
/// }});
struct RawAdvertisementCallback {
void *instance{nullptr};
void (*fn)(void *instance, const RawAdvertisement &adv){nullptr};
/// A default-constructed slot is "no subscriber"; hubs must guard on this.
bool is_set() const { return this->fn != nullptr; }
void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); }
};
/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
struct HubCapabilities {
/// Controller can send scan requests (active scanning).
bool active_scan;
/// Controller (or tracker) delivers advertisement + scan response as one merged
/// 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: the platform has a
/// bluetooth_connection backend implementing ble_device_base::BLEGattConnection
/// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend.
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).
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; }
};
} // namespace esphome::ble_device_base
+5 -15
View File
@@ -5,6 +5,8 @@
#include <ctime>
#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/text_sensor/text_sensor.h"
@@ -18,22 +20,10 @@ class BLEScanner final : public text_sensor::TextSensor,
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override {
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Escape special characters in the device name for valid JSON
const char *name = device.get_name().c_str();
// 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];
size_t pos = 0;
for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) {
uint8_t c = static_cast<uint8_t>(*name);
if (c == '"' || c == '\\') {
escaped_name[pos++] = '\\';
escaped_name[pos++] = c;
} else if (c < 0x20) {
pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c);
} else {
escaped_name[pos++] = c;
}
}
escaped_name[pos] = '\0';
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\"}",
+261 -60
View File
@@ -1,14 +1,50 @@
import functools
import logging
import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32_ble import BTLoggers
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_ID
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"]
DEPENDENCIES = ["api", "esp32"]
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing
# those modules registers esp32-only automations (ble.enable, ble.disable, ...)
# as a side effect, and a module-scope import would leak them into every
# platform's registry the moment a config declares `bluetooth_proxy:` —
# degrading "Unable to find action" config errors into C++ compile failures.
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
"""Components to auto-load for the platform being compiled.
Callable with no argument so tooling that resolves AUTO_LOAD without a
target platform (the device-builder catalog sync does exactly this) gets
the union of every arm instead of an empty list which is what lets it
keep cross-referencing the esp32 BLE stack. A real build always has a
target platform set, so it takes one of the concrete branches.
"""
if CORE.is_esp32:
return ["esp32_ble_client", "esp32_ble_tracker"]
if CORE.target_platform in _HUB_PLATFORMS:
return ["ble_device_base"]
# No target platform, or one this component does not support: tooling
# resolving the manifest (including the host-pinned dependency resolver) —
# expose every arm so the closure keeps the esp32 BLE stack.
return ["ble_device_base", "esp32_ble_client", "esp32_ble_tracker"]
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT
# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
# proxy would be misdriven — bk72xx follows once the API carries a feature
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
DEPENDENCIES = ["api"]
CODEOWNERS = ["@jesserockz", "@bdraco"]
_LOGGER = logging.getLogger(__name__)
@@ -20,65 +56,209 @@ DEFAULT_CONNECTION_SLOTS = 3
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
BluetoothProxy = bluetooth_proxy_ns.class_(
"BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS as a literal so the statically walkable
# CONFIG_SCHEMA below can state the connection_slots range without importing the
# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together.
_IDF_MAX_CONNECTIONS = 9
def validate_connections(config):
if CONF_CONNECTIONS in config:
if not config[CONF_ACTIVE]:
raise cv.Invalid(
"Connections can only be used if the proxy is set to active"
)
elif config[CONF_ACTIVE]:
connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
@functools.cache
def _esp32_config_schema() -> cv.All:
"""Build the esp32 schema, importing the esp32 BLE stack only when used."""
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
return {
**config,
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS:
raise cv.Invalid(
f"bluetooth_proxy's connection-slot limit mirror "
f"({_IDF_MAX_CONNECTIONS}) is out of sync with "
f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); "
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
def validate_connections(config):
if CONF_CONNECTIONS in config:
if not config[CONF_ACTIVE]:
raise cv.Invalid(
"Connections can only be used if the proxy is set to active"
)
elif config[CONF_ACTIVE]:
connection_slots: int = config[CONF_CONNECTION_SLOTS]
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
config
)
return {
**config,
CONF_CONNECTIONS: [
CONNECTION_SCHEMA({}) for _ in range(connection_slots)
],
}
return config
return cv.All(
(
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
cv.Optional(
CONF_CONNECTION_SLOTS,
default=DEFAULT_CONNECTION_SLOTS,
): cv.All(
cv.positive_int,
cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
),
cv.Optional(CONF_CONNECTIONS): cv.All(
cv.ensure_list(CONNECTION_SCHEMA),
cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
),
validate_connections,
)
def _validate_no_active(config: ConfigType) -> ConfigType:
if config[CONF_ACTIVE]:
raise cv.Invalid(
"Active connections are not supported on this platform; the proxy "
"forwards advertisements only (set active: false)"
)
return config
CONFIG_SCHEMA = cv.All(
(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BluetoothProxy),
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
cv.Optional(
CONF_CONNECTION_SLOTS,
default=DEFAULT_CONNECTION_SLOTS,
): cv.All(
cv.positive_int,
cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
),
cv.Optional(CONF_CONNECTIONS): cv.All(
cv.ensure_list(CONNECTION_SCHEMA),
cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
),
}
)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
),
validate_connections,
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
# callback feeds the same API batching. GATT/active connections are excluded at
# compile time — only the esp32 build compiles the connection stack; nothing
# reads HubCapabilities::gatt at runtime for this today.
# Keys both platform schemas must declare identically; each arm spreads this
# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays
# per-arm: its default differs (esp32 True, hub arms False — no GATT).
_COMMON_SCHEMA_KEYS = {
cv.GenerateID(): cv.declare_id(BluetoothProxy),
}
_BLE_HUB_CONFIG_SCHEMA = cv.All(
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
# Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator
# after a strict schema rejects an explicit `ble_hub_id` before it
# runs, and that key is the documented way to disambiguate once a
# platform has two trackers.
cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id(
ble_device_base.BLEHub
),
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA),
_validate_no_active,
)
async def to_code(config):
@schema_extractor("schema")
def _validate_platform(config: ConfigType) -> ConfigType:
"""Apply the schema for the platform actually being compiled.
esp32 keeps the full GATT proxy; every other platform gets the
advertisement-only shape, which rejects the connection-oriented options
above because its schema does not define them.
"""
if config is SCHEMA_EXTRACT:
# The language-schema dumper runs without a platform. Expose the esp32
# shape so `connections`, the ids and every default stay in the
# generated schema the editor and dashboard consume.
return _esp32_config_schema()
if CORE.is_esp32:
return _esp32_config_schema()(config)
if CORE.target_platform not in _HUB_PLATFORMS:
# Fail here with the actual reason. Without this gate the error surfaces
# later as an unresolvable hub ID ("Are you missing a hub declaration?")
# on platforms where no hub component can be declared.
raise cv.Invalid(
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
"active-scan-capable BLE tracker hub is available for this "
"platform. It runs on esp32 (full proxy), and the ln882x and rp2 "
"families (advertisement-only)."
)
return _BLE_HUB_CONFIG_SCHEMA(config)
def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
"""Reject connection-oriented options by name on hub-only platforms.
Runs before the walkable schema below so the user gets "this option does
not exist here" instead of the option's esp32 value range (which would
imply a smaller number is accepted).
"""
if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None:
return config
if CORE.target_platform not in _HUB_PLATFORMS:
# No proxy of any kind exists here: fall through so _validate_platform
# reports "not supported on {platform}" instead of a key-level message
# implying an advertisement-only proxy is available.
return config
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
if key in config:
raise cv.Invalid(
f"'{key}' requires active connection support, which needs the "
"esp32 GATT stack; this platform runs the advertisement-only "
"proxy and has no such option",
path=[key],
)
return config
# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's
# field-range extractor among others) introspects it to discover options and
# their bounds, which a bare dispatch function would hide. It carries the scalar
# keys with no defaults; _validate_platform then runs the real per-platform
# schema, which applies the defaults and rejects options the platform does not
# support.
#
# It deliberately does NOT declare `connections`: this outer schema runs before
# the per-platform one, so any key it transforms is transformed twice. Running
# CONNECTION_SCHEMA twice re-validates an already-generated ID through
# declare_id(), which (unlike use_id) has no guard for an ID instance and
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
CONFIG_SCHEMA = cv.All(
_reject_connection_keys_off_esp32,
cv.Schema(
{
cv.Optional(CONF_ACTIVE): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
cv.Optional(CONF_CONNECTION_SLOTS): cv.All(
cv.positive_int,
cv.Range(min=1, max=_IDF_MAX_CONNECTIONS),
),
},
extra=cv.ALLOW_EXTRA,
),
_validate_platform,
)
async def _to_code_esp32(config: ConfigType) -> None:
from esphome.components import esp32_ble, esp32_ble_tracker
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32_ble import BTLoggers
# Register the loggers this component needs
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP)
@@ -87,17 +267,12 @@ async def to_code(config):
cg.add(var.set_active(config[CONF_ACTIVE]))
await esp32_ble_tracker.register_raw_ble_device(var, config)
await esp32_ble_tracker.register_scanner_state_listener(var, config)
# Define max connections for protobuf fixed array
connection_count = len(config.get(CONF_CONNECTIONS, []))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
# Define batch size for BLE advertisements
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
# This achieves ~97% WiFi MTU utilization while staying under the limit
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
for connection_conf in config.get(CONF_CONNECTIONS, []):
connection_var = cg.new_Pvariable(connection_conf[CONF_ID])
await cg.register_component(connection_var, connection_conf)
@@ -107,4 +282,30 @@ async def to_code(config):
if config.get(CONF_CACHE_SERVICES):
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
async def _to_code_ble_hub(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_active(config[CONF_ACTIVE]))
hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID])
cg.add(var.set_ble_hub(hub))
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
# this define whenever a proxy is present; no connections off-esp32.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0)
async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
await _to_code_esp32(config)
else:
await _to_code_ble_hub(config)
# Define batch size for BLE advertisements
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
# This achieves ~97% WiFi MTU utilization while staying under the limit
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("USE_BLUETOOTH_PROXY")
@@ -245,7 +245,9 @@ void BluetoothConnection::send_service_for_discovery_() {
service_resp.characteristics.init(total_char_count);
uint16_t char_offset = 0;
esp_gattc_char_elem_t char_result;
while (true) { // characteristics
// Bound by total_char_count: the vector is sized for it, and a malicious peripheral
// can make enumeration return more entries than the count query reported
while (char_offset < total_char_count) { // characteristics
uint16_t char_count = 1;
esp_gatt_status_t char_status =
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
@@ -287,7 +289,7 @@ void BluetoothConnection::send_service_for_discovery_() {
characteristic_resp.descriptors.init(total_desc_count);
uint16_t desc_offset = 0;
esp_gattc_descr_elem_t desc_result;
while (true) { // descriptors
while (desc_offset < total_desc_count) { // descriptors
uint16_t desc_count = 1;
esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr(
this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset);
@@ -1,6 +1,9 @@
#include "bluetooth_proxy.h"
#ifdef USE_BLUETOOTH_PROXY
#include "esphome/components/api/api_server.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
@@ -8,8 +11,6 @@
#include <cstring>
#include <limits>
#ifdef USE_ESP32
namespace esphome::bluetooth_proxy {
static const char *const TAG = "bluetooth_proxy";
@@ -23,14 +24,14 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
#ifdef USE_ESP32
void BluetoothProxy::setup() {
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->parent_->get_scan_active();
this->parent_->add_scanner_state_listener(this);
}
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
@@ -50,6 +51,62 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta
this->api_connection_->send_message(resp);
}
#else // !USE_ESP32
void BluetoothProxy::setup() {
this->connections_free_response_.limit = 0;
this->connections_free_response_.free = 0;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->hub_->scan_active();
this->last_scan_running_ = this->hub_->scan_running();
// The hub delivers raw advertisements on the ESPHome main loop:
// mac is least-significant octet first (BLE controller convention).
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv);
}});
}
void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) {
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
// raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends.
adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac);
adv.rssi = raw.rssi;
adv.address_type = raw.addr_type;
uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast<uint8_t>(raw.data_len);
adv.data_len = length;
std::memcpy(adv.data, raw.data, length);
this->response_.advertisements_len++;
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4],
raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
this->flush_pending_advertisements_();
}
}
void BluetoothProxy::send_bluetooth_scanner_state_() {
api::BluetoothScannerStateResponse resp;
resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING
: api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE;
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
this->api_connection_->send_message(resp);
}
#endif // USE_ESP32
#ifdef USE_ESP32
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) {
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
connection->address_str(), espbt::client_state_to_string(state));
@@ -58,6 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
}
#endif // USE_ESP32
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -69,6 +127,8 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl
this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED);
}
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// This method should never be called since bluetooth_proxy always uses raw advertisements
@@ -109,18 +169,38 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results,
return true;
}
#endif // USE_ESP32
void BluetoothProxy::log_advertisement_flush_() {
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
}
void BluetoothProxy::dump_config() {
#ifdef USE_ESP32
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
" Connections: %d",
YESNO(this->active_), this->connection_count_);
#else
// Advertisement-only: print configured facts. dump_config runs right after
// setup, before the radio is up, so live scan state would always read
// "stopped" here — the loop's BluetoothScannerStateResponse carries the
// changing value instead.
char mac_str[18];
this->get_bluetooth_mac_address_pretty(mac_str);
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Mode: advertisement-only (no GATT connections)\n"
" Configured scan: %s\n"
" Adapter MAC: %s",
this->configured_scan_active_ ? "active" : "passive",
mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)");
#endif
}
#ifdef USE_ESP32
void BluetoothProxy::loop() {
// Run advertisement flush / connection cleanup every 100ms
uint32_t now = App.get_loop_component_start_time();
@@ -247,20 +327,15 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_remove_bond_device(address);
this->send_device_pairing(msg.address, ret == ESP_OK, ret);
this->send_device_unpairing(msg.address, ret == ESP_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: {
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_gattc_cache_clean(address);
api::BluetoothDeviceClearCacheResponse call;
call.address = msg.address;
call.success = ret == ESP_OK;
call.error = ret;
this->api_connection_->send_message(call);
// Shares the sender with the neutral path, which also null-checks api_connection_.
this->send_device_clear_cache(msg.address, ret == ESP_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
@@ -378,6 +453,124 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp);
}
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->parent_->get_scan_active() == active) {
return;
}
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->parent_->set_scan_active(active);
this->parent_->stop_scan();
this->parent_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up.
}
#else // !USE_ESP32
// Advertisement-only proxy. GATT client connections are excluded at compile
// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults
// HubCapabilities at runtime today — so every connection-oriented request is
// answered with a clean error instead of silence, and Home Assistant treats
// the proxy as passive.
void BluetoothProxy::loop() {
// Run advertisement flush / scanner-state poll every 100ms
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_advertisement_flush_time_ < 100)
return;
this->last_advertisement_flush_time_ = now;
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
// The hub has no scanner-state listener interface; poll and report on change.
bool running = this->hub_->scan_running();
if (running != this->last_scan_running_) {
this->last_scan_running_ = running;
this->send_bluetooth_scanner_state_();
}
this->flush_pending_advertisements_();
}
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
switch (msg.request_type) {
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
ESP_LOGW(TAG, "Active connections are not supported on this platform");
this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
// Not an error: the device is already disconnected, which is the requested state.
this->send_device_connection(msg.address, false);
this->send_connections_free();
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR:
this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE:
this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
}
}
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
}
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
}
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
}
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
}
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
}
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
}
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothSetConnectionParamsResponse resp;
resp.address = msg.address;
resp.error = ESP_GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
}
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->hub_->scan_active() != active) {
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
if (!this->hub_->request_scan_mode(active)) {
// Passive-only controller asked for active scanning; the state report
// below carries the real, unchanged mode so the subscriber does not
// assume the change happened.
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
}
}
if (this->api_connection_ != nullptr) {
// Keep loop()'s change detector in step with the state sent here, so a
// failed restart (scan_running_ dropped by the tracker) is not reported
// twice — once now and again on the next tick.
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
}
}
#endif // USE_ESP32
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
// A previous subscriber still holds the slot. This is almost always a stale
@@ -392,9 +585,13 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
this->api_connection_->get_peername_to(old_peername));
}
this->api_connection_ = api_connection;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state());
#else
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
#endif
}
void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) {
@@ -403,10 +600,12 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
#endif
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) {
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
@@ -434,7 +633,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) {
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothGATTErrorResponse call;
@@ -444,7 +643,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) {
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDevicePairingResponse call;
@@ -455,7 +654,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) {
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceUnpairingResponse call;
@@ -466,19 +665,21 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e
this->api_connection_->send_message(call);
}
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->parent_->get_scan_active() == active) {
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
}
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->parent_->set_scan_active(active);
this->parent_->stop_scan();
this->parent_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up.
api::BluetoothDeviceClearCacheResponse call;
call.address = address;
call.success = success;
call.error = error;
this->api_connection_->send_message(call);
}
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
#endif // USE_BLUETOOTH_PROXY
@@ -1,6 +1,8 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY
#include <array>
#include <map>
@@ -8,11 +10,14 @@
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/components/ble_device_base/ble_client_state.h"
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "bluetooth_connection.h"
@@ -20,14 +25,31 @@
#include <esp_bt.h>
#endif
#include <esp_bt_device.h>
#else
#include "esphome/components/ble_device_base/ble_hub.h"
#endif // USE_ESP32
namespace esphome::bluetooth_proxy {
static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1;
// Proxy-owned error type for the API error fields, which are plain integers on
// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls);
// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps
// the header independent of how a hub platform's SDK spells its error type.
#ifdef USE_ESP32
using proxy_err_t = esp_err_t;
static constexpr proxy_err_t PROXY_OK = ESP_OK;
#else
using proxy_err_t = int;
static constexpr proxy_err_t PROXY_OK = 0;
#endif
static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED;
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
#ifdef USE_ESP32
using namespace esp32_ble_client;
#endif
// Legacy versions:
// Version 1: Initial version without active connections
@@ -53,21 +75,28 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef USE_ESP32
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
public esp32_ble_tracker::BLEScannerStateListener,
public Component {
friend class BluetoothConnection; // Allow connection to update connections_free_response_
#else
class BluetoothProxy final : public Component {
#endif
public:
BluetoothProxy();
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
#endif
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
#endif // USE_ESP32
void dump_config() override;
void setup() override;
void loop() override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
#ifdef USE_ESP32
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
@@ -78,6 +107,14 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
}
#endif
}
#else
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and
// the BLEHub contract does not promise those are settled any earlier than
// the hub's own setup().
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
#endif // USE_ESP32
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
@@ -92,17 +129,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
void unsubscribe_api_connection(api::APIConnection *api_connection);
api::APIConnection *get_api_connection() { return this->api_connection_; }
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK);
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK);
void send_connections_free();
void send_connections_free(api::APIConnection *api_connection);
void send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error);
void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK);
void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK);
void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK);
void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error);
void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK);
void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void bluetooth_scanner_set_mode(bool active);
#ifdef USE_ESP32
static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) {
bd_addr[0] = (address >> 40) & 0xff;
bd_addr[1] = (address >> 32) & 0xff;
@@ -111,12 +149,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
bd_addr[4] = (address >> 8) & 0xff;
bd_addr[5] = (address >> 0) & 0xff;
}
#endif
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
#ifdef USE_ESP32
/// BLEScannerStateListener interface
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
#endif
uint32_t get_legacy_version() const {
if (this->active_) {
@@ -129,7 +170,17 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
uint32_t flags = 0;
flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN;
flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS;
#ifdef USE_ESP32
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
#else
// Advertise mode switching only where the hub honors request_scan_mode();
// scan_mode_switch is the capability bit for exactly that (#18079) —
// active_scan alone is not enough, a hub may support active scanning yet
// refuse the runtime switch.
if (this->hub_->get_capabilities().scan_mode_switch) {
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
}
#endif
if (this->active_) {
flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS;
flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING;
@@ -142,16 +193,37 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
}
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
#ifdef USE_ESP32
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#else
uint8_t mac[6] = {};
this->hub_->get_adapter_mac(mac);
// Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs
// (rp2040's BTstack) only learn the address once the link layer is up, and
// report all-zero until then.
bool nonzero = false;
for (uint8_t b : mac)
nonzero |= b != 0;
if (nonzero) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#endif
}
protected:
#ifdef USE_ESP32
void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state);
#else
void send_bluetooth_scanner_state_();
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
#endif
/// Caller must ensure api_connection_ is non-null and API server is connected.
void flush_pending_advertisements_() {
@@ -165,9 +237,11 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
}
void log_advertisement_flush_();
#ifdef USE_ESP32
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state);
void log_connection_info_(BluetoothConnection *connection, const char *message);
#endif
void log_not_connected_gatt_(const char *action, const char *type);
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
@@ -175,8 +249,12 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
// Group 1: Pointers (4 bytes each, naturally aligned)
api::APIConnection *api_connection_{nullptr};
#ifdef USE_ESP32
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
#else
ble_device_base::BLEHub *hub_{nullptr};
#endif
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
@@ -191,11 +269,13 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
bool active_;
uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML
// 3 bytes used, 1 byte padding
#ifndef USE_ESP32
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};
extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
#endif // USE_BLUETOOTH_PROXY
@@ -25,6 +25,9 @@ static constexpr size_t BTHOME_NONCE_SIZE = 13;
static constexpr size_t BTHOME_MIC_SIZE = 4;
static constexpr size_t BTHOME_COUNTER_SIZE = 4;
// Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they
// compile away and an ungated helper trips -Wunused-function.
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
static const char *format_mac_address(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buffer, uint64_t address) {
std::array<uint8_t, MAC_ADDRESS_SIZE> mac{};
for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) {
@@ -34,6 +37,7 @@ static const char *format_mac_address(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_
format_mac_addr_upper(mac.data(), buffer.data());
return buffer.data();
}
#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG
static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) {
switch (obj_type) {
@@ -1,7 +1,7 @@
import logging
import esphome.codegen as cg
from esphome.components import web_server_base
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -101,6 +101,9 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID], paren)
await cg.register_component(var, config)
cg.add_define("USE_CAPTIVE_PORTAL")
# The portal reads wifi scan results from the web server task; this makes the
# wifi component guard them with a lock on multi-threaded platforms.
wifi.request_wifi_scan_results_lock()
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
+137 -136
View File
@@ -7,145 +7,146 @@ namespace esphome::captive_portal {
#ifdef USE_CAPTIVE_PORTAL_GZIP
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e,
0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36,
0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf,
0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a,
0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68,
0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5,
0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22,
0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52,
0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06,
0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a,
0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0,
0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84,
0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7,
0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05,
0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6,
0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0,
0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7,
0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b,
0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e,
0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34,
0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b,
0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1,
0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37,
0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac,
0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3,
0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68,
0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc,
0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c,
0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93,
0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c,
0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18,
0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06,
0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c,
0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef,
0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2,
0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9,
0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8,
0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc,
0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca,
0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f,
0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0,
0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f,
0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c,
0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d,
0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf,
0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d,
0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6,
0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5,
0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b,
0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3,
0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69,
0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95,
0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9,
0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e,
0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62,
0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7,
0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97,
0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee,
0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11,
0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b,
0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9,
0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93,
0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97,
0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19,
0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc,
0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2,
0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc,
0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e,
0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e,
0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9,
0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3,
0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5,
0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37,
0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f,
0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22,
0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68,
0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00};
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f,
0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69,
0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e,
0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0,
0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0,
0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1,
0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45,
0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6,
0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda,
0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9,
0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8,
0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65,
0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f,
0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25,
0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9,
0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43,
0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83,
0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b,
0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4,
0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d,
0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc,
0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e,
0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2,
0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66,
0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c,
0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d,
0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a,
0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32,
0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d,
0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf,
0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0,
0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d,
0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74,
0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30,
0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29,
0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd,
0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70,
0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd,
0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17,
0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd,
0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a,
0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88,
0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b,
0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f,
0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a,
0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79,
0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87,
0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1,
0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87,
0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7,
0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40,
0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67,
0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda,
0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81,
0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c,
0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64,
0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1,
0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2,
0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7,
0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16,
0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a,
0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96,
0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72,
0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb,
0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54,
0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4,
0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6,
0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a,
0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8,
0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79,
0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29,
0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35,
0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9,
0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde,
0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b,
0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14,
0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5,
0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00};
#else // Brotli (default, smaller)
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b,
0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48,
0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78,
0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1,
0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18,
0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82,
0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c,
0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72,
0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61,
0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69,
0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4,
0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe,
0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00,
0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d,
0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0,
0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71,
0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a,
0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01,
0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a,
0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7,
0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15,
0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55,
0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8,
0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1,
0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d,
0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f,
0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38,
0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71,
0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91,
0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18,
0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d,
0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50,
0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02,
0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e,
0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4,
0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4,
0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86,
0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd,
0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55,
0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f,
0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab,
0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3,
0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d,
0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8,
0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4,
0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51,
0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59,
0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee,
0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43,
0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65,
0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71,
0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca,
0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42,
0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a,
0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc,
0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8,
0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5,
0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45,
0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01};
0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89,
0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6,
0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28,
0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63,
0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2,
0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20,
0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21,
0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6,
0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa,
0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6,
0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6,
0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5,
0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe,
0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55,
0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6,
0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01,
0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28,
0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8,
0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1,
0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17,
0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81,
0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9,
0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1,
0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5,
0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12,
0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20,
0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5,
0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2,
0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7,
0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89,
0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b,
0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07,
0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8,
0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28,
0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d,
0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf,
0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb,
0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5,
0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c,
0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09,
0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab,
0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b,
0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46,
0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d,
0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d,
0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4,
0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29,
0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45,
0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5,
0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75,
0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95,
0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3,
0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d,
0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12,
0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c,
0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3,
0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96,
0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c,
0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28};
// Backwards compatibility alias
#define INDEX_GZ INDEX_BR
@@ -2,6 +2,8 @@
#ifdef USE_CAPTIVE_PORTAL
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/wifi_component.h"
#include "captive_index.h"
@@ -24,23 +26,30 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str());
#endif
for (auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
continue;
// An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most
// 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result.
char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1];
{
// Invariant: only bounded in-memory work under the lock; the network send
// happens later in request->send()
wifi::ScanResultsLock lock(wifi::global_wifi_component);
for (const auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
continue;
// Assumes no " in ssid, possible unicode isses?
json_escape_into_buffer(escaped_ssid, scan.get_ssid());
#ifdef USE_ESP8266
stream->print(ESPHOME_F(",{\"ssid\":\""));
stream->print(scan.get_ssid().c_str());
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(ESPHOME_F("}"));
stream->print(ESPHOME_F(",{\"ssid\":\""));
stream->print(escaped_ssid);
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(ESPHOME_F("}"));
#else
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(),
scan.get_with_auth());
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth());
#endif
}
}
stream->print(ESPHOME_F("]}"));
request->send(stream);
+77
View File
@@ -21,6 +21,7 @@ MULTI_CONF = True
ns = cg.esphome_ns.namespace("cc1101")
CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice)
CC1101Listener = ns.class_("CC1101Listener")
# Config keys
CONF_RX_ATTENUATION = "rx_attenuation"
@@ -48,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk"
CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook"
CONF_FREEZE = "freeze"
CONF_HYST_LEVEL = "hyst_level"
CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate"
CONF_FOC_LIMIT = "foc_limit"
CONF_FOC_PRE_K = "foc_pre_k"
CONF_FOC_POST_K = "foc_post_k"
CONF_BS_LIMIT = "bs_limit"
CONF_BS_PRE_KI = "bs_pre_ki"
CONF_BS_PRE_KP = "bs_pre_kp"
CONF_BS_POST_KI = "bs_post_ki"
CONF_BS_POST_KP = "bs_post_kp"
# Packet mode config keys
CONF_PACKET_MODE = "packet_mode"
@@ -161,6 +171,64 @@ HYST_LEVEL = {
"High": HystLevel.HYST_LEVEL_HIGH,
}
FocLimit = ns.enum("FocLimit", True)
FOC_LIMIT = {
"Disabled": FocLimit.FOC_LIMIT_DISABLED,
"BW/8": FocLimit.FOC_LIMIT_BW_8,
"BW/4": FocLimit.FOC_LIMIT_BW_4,
"BW/2": FocLimit.FOC_LIMIT_BW_2,
}
FocPreK = ns.enum("FocPreK", True)
FOC_PRE_K = {
"K": FocPreK.FOC_PRE_K_K,
"2K": FocPreK.FOC_PRE_K_2K,
"3K": FocPreK.FOC_PRE_K_3K,
"4K": FocPreK.FOC_PRE_K_4K,
}
FocPostK = ns.enum("FocPostK", True)
FOC_POST_K = {
"Same": FocPostK.FOC_POST_K_SAME,
"K/2": FocPostK.FOC_POST_K_K_2,
}
BsLimit = ns.enum("BsLimit", True)
BS_LIMIT = {
"Disabled": BsLimit.BS_LIMIT_DISABLED,
"3.125%": BsLimit.BS_LIMIT_3P125_PERCENT,
"6.25%": BsLimit.BS_LIMIT_6P25_PERCENT,
"12.5%": BsLimit.BS_LIMIT_12P5_PERCENT,
}
BsPreKi = ns.enum("BsPreKi", True)
BS_PRE_KI = {
"KI": BsPreKi.BS_PRE_KI_KI,
"2KI": BsPreKi.BS_PRE_KI_2KI,
"3KI": BsPreKi.BS_PRE_KI_3KI,
"4KI": BsPreKi.BS_PRE_KI_4KI,
}
BsPreKp = ns.enum("BsPreKp", True)
BS_PRE_KP = {
"KP": BsPreKp.BS_PRE_KP_KP,
"2KP": BsPreKp.BS_PRE_KP_2KP,
"3KP": BsPreKp.BS_PRE_KP_3KP,
"4KP": BsPreKp.BS_PRE_KP_4KP,
}
BsPostKi = ns.enum("BsPostKi", True)
BS_POST_KI = {
"Same": BsPostKi.BS_POST_KI_SAME,
"KI/2": BsPostKi.BS_POST_KI_KI_2,
}
BsPostKp = ns.enum("BsPostKp", True)
BS_POST_KP = {
"Same": BsPostKp.BS_POST_KP_SAME,
"KP": BsPostKp.BS_POST_KP_KP,
}
# Optional settings to generate setter calls for
CONFIG_MAP = {
cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0),
@@ -214,6 +282,15 @@ CONFIG_MAP = {
cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False),
cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False),
cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False),
cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean,
cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False),
cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False),
cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False),
cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False),
cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False),
cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False),
cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False),
cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False),
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,

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