Compare commits

..

238 Commits

Author SHA1 Message Date
J. Nick Koston
dc53831b27 Merge branch 'integration' into memory_api 2025-10-11 22:03:51 -10:00
J. Nick Koston
e8397704fb Merge branch 'wifi_missed_string_lit' into integration 2025-10-11 22:03:40 -10:00
J. Nick Koston
ddc7a15302 [wifi] Fix missed string literal in flash on ESP8266 2025-10-11 22:02:59 -10:00
J. Nick Koston
6a0bcdf4f6 Merge branch 'integration' into memory_api 2025-10-11 21:26:37 -10:00
J. Nick Koston
cc63edcf7a Merge branch 'flakey_ota_first_connect' into integration 2025-10-11 21:26:29 -10:00
J. Nick Koston
072662c395 timeout 2025-10-11 21:26:13 -10:00
J. Nick Koston
cebc8a3867 Merge branch 'integration' into memory_api 2025-10-11 21:23:51 -10:00
J. Nick Koston
2795d67787 Merge branch 'flakey_ota_first_connect' into integration 2025-10-11 21:23:37 -10:00
J. Nick Koston
66c8c045f2 [ota] Increase handshake timeout to 20s now that auth is non-blocking 2025-10-11 21:23:02 -10:00
J. Nick Koston
91dbdffea5 [mipi_rgb] Fix pin conflicts introduced by shared SPI bus in #11134 2025-10-11 19:56:05 -10:00
J. Nick Koston
2fc5afc79e Merge branch 'integration' into memory_api 2025-10-11 17:36:14 -10:00
J. Nick Koston
e0933e0094 Merge branch 'http_request_const' into integration 2025-10-11 17:36:02 -10:00
J. Nick Koston
0c0ed8c4fd Merge branch 'loop_fix_vec' into integration 2025-10-11 17:35:57 -10:00
J. Nick Koston
4c00861760 add comments for bot 2025-10-11 17:35:31 -10:00
J. Nick Koston
2ff3e7fb2b add comments for bot 2025-10-11 17:34:51 -10:00
J. Nick Koston
fdc9ea285d [http_request] Pass parameters by const reference to reduce flash usage 2025-10-11 17:30:30 -10:00
J. Nick Koston
34d891761a Merge branch 'integration' into memory_api 2025-10-11 17:00:46 -10:00
J. Nick Koston
e64111345c Merge branch 'loop_fix_vec' into integration 2025-10-11 17:00:34 -10:00
J. Nick Koston
d6239398ed Merge branch 'loop_fix_vec' into memory_api 2025-10-11 17:00:25 -10:00
J. Nick Koston
b0c20d7adb [core] Optimize looping_components_ with FixedVector to save flash 2025-10-11 16:54:40 -10:00
J. Nick Koston
d2a31b95c4 preen 2025-10-11 16:08:47 -10:00
J. Nick Koston
0d3489df3f Merge branch 'integration' into memory_api 2025-10-11 15:34:42 -10:00
J. Nick Koston
6b2ef78787 preen 2025-10-11 15:34:31 -10:00
J. Nick Koston
153f01ef77 preen 2025-10-11 15:34:15 -10:00
J. Nick Koston
e69013317d Update esphome/core/helpers.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 15:33:46 -10:00
J. Nick Koston
3f65f261ab Merge branch 'helper_for_name_suffix' of https://github.com/esphome/esphome into helper_for_name_suffix 2025-10-11 15:33:28 -10:00
J. Nick Koston
5fe319fcc5 preen 2025-10-11 15:33:22 -10:00
J. Nick Koston
21c2c6e782 Update esphome/config_validation.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 15:33:19 -10:00
J. Nick Koston
6ecdb395fd Merge branch 'dev' into helper_for_name_suffix 2025-10-11 15:27:41 -10:00
J. Nick Koston
3d328d7233 Merge remote-tracking branch 'origin/helper_for_name_suffix' into helper_for_name_suffix 2025-10-11 15:24:11 -10:00
J. Nick Koston
9f20c48a24 adjust 2025-10-11 15:23:51 -10:00
Jonathan Swoboda
2cc5e24b38 [esp32] Change Arduino dev & latest to 3.3.2 (#11169) 2025-10-11 20:44:44 -04:00
J. Nick Koston
069893abb9 Merge branch 'integration' into memory_api 2025-10-11 13:46:07 -10:00
J. Nick Koston
edd73ed192 Merge branch 'improv_cap_portal_fix' into integration 2025-10-11 13:45:59 -10:00
J. Nick Koston
10c231e872 Merge remote-tracking branch 'upstream/improv_cap_portal_fix' into improv_cap_portal_fix 2025-10-11 13:45:36 -10:00
J. Nick Koston
3758b4c801 preen 2025-10-11 13:45:22 -10:00
J. Nick Koston
5bd87906af Merge branch 'dev' into improv_cap_portal_fix 2025-10-11 13:42:41 -10:00
J. Nick Koston
c8b2a74a7e Merge branch 'dev' into helper_for_name_suffix 2025-10-11 13:37:43 -10:00
J. Nick Koston
3afa73b449 [ci] Filter out components without tests from CI test jobs (#11134 followup) (#11178) 2025-10-11 18:27:18 -05:00
J. Nick Koston
678a93cc56 fix 2025-10-11 13:08:10 -10:00
J. Nick Koston
5a0184cb35 [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal 2025-10-11 13:01:19 -10:00
J. Nick Koston
c63902781b [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal 2025-10-11 12:57:13 -10:00
J. Nick Koston
a193d5b40e [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal 2025-10-11 12:56:28 -10:00
J. Nick Koston
ff6191cfd4 [esp32_improv] Fix state not transitioning to PROVISIONED when WiFi configured via captive portal 2025-10-11 12:55:03 -10:00
J. Nick Koston
b7b2b296a0 Merge branch 'integration' into memory_api 2025-10-11 12:39:33 -10:00
J. Nick Koston
b032ba9bd4 Merge branch 'mdns_store' into integration 2025-10-11 12:39:27 -10:00
J. Nick Koston
0975dbfb01 cleanup 2025-10-11 12:38:12 -10:00
J. Nick Koston
0c8c99dbf8 [mdns] Conditionally store services to reduce RAM usage by 200-464 bytes 2025-10-11 12:27:39 -10:00
J. Nick Koston
c241258dfe Merge branch 'integration' into memory_api 2025-10-11 11:26:19 -10:00
J. Nick Koston
417f574cff Merge branch 'helper_for_name_suffix' into integration 2025-10-11 11:26:09 -10:00
J. Nick Koston
5e1848854e tweak for bot 2025-10-11 11:25:19 -10:00
J. Nick Koston
19c541f1e6 Merge branch 'integration' into memory_api 2025-10-11 11:11:46 -10:00
J. Nick Koston
4ad3f9d962 Merge branch 'helper_for_name_suffix' into integration 2025-10-11 11:11:39 -10:00
J. Nick Koston
81b7f41dd5 Merge branch 'fix_ci_only_test_comp_with_tests' into helper_for_name_suffix 2025-10-11 11:09:41 -10:00
J. Nick Koston
1acbb007dd [ci] Filter out components without tests from CI test jobs (#11134 followup) 2025-10-11 11:08:47 -10:00
J. Nick Koston
245ccb02fa Merge branch 'integration' into memory_api 2025-10-11 10:52:51 -10:00
J. Nick Koston
ce6b51e27d Merge branch 'helper_for_name_suffix' into integration 2025-10-11 10:52:36 -10:00
J. Nick Koston
6273380407 [core] Add make_name_with_suffix helper to optimize string concatenation 2025-10-11 10:51:17 -10:00
J. Nick Koston
69888af408 Merge branch 'integration' into memory_api 2025-10-11 09:32:00 -10:00
J. Nick Koston
2572157fc3 Merge remote-tracking branch 'upstream/integration' into integration 2025-10-11 09:31:48 -10:00
J. Nick Koston
a012557911 Merge branch 'integration' into memory_api 2025-10-11 09:31:08 -10:00
J. Nick Koston
3187e045d2 Merge remote-tracking branch 'upstream/dev' into integration 2025-10-11 09:30:48 -10:00
J. Nick Koston
dcf2697a2a Group component tests to reduce CI time (#11134) 2025-10-12 07:21:45 +13:00
J. Nick Koston
6a11700a6b [mdns] Restore mdns_txt_record() public API for external components (#11158) 2025-10-12 07:21:37 +13:00
J. Nick Koston
d63af64282 Merge branch 'integration' into memory_api 2025-10-11 06:27:06 -10:00
J. Nick Koston
4a7a0bbc93 Merge branch 'usb_host_keep_up' into integration 2025-10-11 06:26:59 -10:00
J. Nick Koston
fa69b74e6c tweak comments 2025-10-11 06:25:44 -10:00
J. Nick Koston
ec71669bff tweak comments 2025-10-11 06:24:35 -10:00
J. Nick Koston
2796cac972 compile tests 2025-10-11 06:17:36 -10:00
J. Nick Koston
442a60766d missing defines 2025-10-11 06:02:49 -10:00
J. Nick Koston
dd6085456a tweak 2025-10-11 06:00:57 -10:00
J. Nick Koston
460c41d9b8 [usb_host] Fix transfer slot exhaustion at high data rates and add configurable max_transfer_requests 2025-10-11 05:53:14 -10:00
J. Nick Koston
9bd9b043c8 [esp32_ble_tracker] Replace std::vector with StaticVector for listeners and clients (#11173) 2025-10-11 05:47:42 -10:00
J. Nick Koston
cb602c9b1a [esp32_ble] Partial revert of #10862 - Fix GATT client notifications (#11171) 2025-10-11 05:47:23 -10:00
J. Nick Koston
c524e6c2b3 Merge branch 'integration' into memory_api 2025-10-10 21:00:54 -10:00
J. Nick Koston
5d7731b39d Merge branch 'static_vector_esp32_ble_tracker' into integration 2025-10-10 21:00:48 -10:00
J. Nick Koston
dacead836f [esp32_ble_tracker] Replace std::vector with StaticVector for listeners and clients 2025-10-10 20:59:34 -10:00
J. Nick Koston
2184c1fde6 Merge branch 'integration' into memory_api 2025-10-10 20:04:45 -10:00
J. Nick Koston
1df2896796 Merge branch 'raw_tcp_mem' into integration 2025-10-10 20:04:39 -10:00
J. Nick Koston
3f49a61b03 tweak 2025-10-10 20:01:16 -10:00
J. Nick Koston
ec44856537 Merge branch 'integration' into memory_api 2025-10-10 19:44:02 -10:00
J. Nick Koston
a00cda32c7 Merge branch 'raw_tcp_mem' into integration 2025-10-10 19:43:53 -10:00
J. Nick Koston
8a4bd0f21c [socket] Split LWIP socket classes to reduce memory overhead on ESP8266/RP2040 2025-10-10 19:42:41 -10:00
J. Nick Koston
ee3af3904f Merge remote-tracking branch 'origin/memory_api' into memory_api 2025-10-10 17:39:56 -10:00
J. Nick Koston
02de8f9f80 merge 2025-10-10 17:39:37 -10:00
J. Nick Koston
9722c8eb60 Merge remote-tracking branch 'origin/integration' into memory_api 2025-10-10 17:39:14 -10:00
J. Nick Koston
29fb40a89f Merge branch 'integration' into memory_api 2025-10-10 17:39:01 -10:00
J. Nick Koston
1c7ff84e6a Merge branch 'partial_revert_10862' into integration 2025-10-10 17:38:53 -10:00
J. Nick Koston
632cd929ac adj 2025-10-10 17:38:26 -10:00
J. Nick Koston
3ea929eeb2 adj 2025-10-10 17:37:36 -10:00
J. Nick Koston
36ab68c1ea [esp32_ble] Partial revert of #10862 - Fix GATT client notifications 2025-10-10 17:31:13 -10:00
dependabot[bot]
b54beb357a Bump github/codeql-action from 4.30.7 to 4.30.8 (#11163)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-10 13:46:27 -10:00
J. Nick Koston
6abc2efd96 [json] Fix PSRAM allocator dangling pointer crash (#11165) 2025-10-10 21:18:57 +00:00
J. Nick Koston
e972767a11 Merge branch 'integration' into memory_api 2025-10-09 22:48:13 -10:00
J. Nick Koston
4890720c0e Merge branch 'mdns_back_compat' into integration 2025-10-09 22:48:05 -10:00
J. Nick Koston
cdc87a4445 [mdns] Restore mdns_txt_record() public API for external components 2025-10-09 22:46:45 -10:00
J. Nick Koston
be51093a7e [ci][tests] Remove all redundant ESP32-C3 Arduino tests (#11154) 2025-10-10 16:02:18 +13:00
J. Nick Koston
06a0ab6839 Merge branch 'integration' into memory_api 2025-10-09 16:04:24 -10:00
J. Nick Koston
6cc5b7c3af Merge remote-tracking branch 'upstream/dev' into integration 2025-10-09 16:04:16 -10:00
J. Nick Koston
52219c4dcc [datetime][ci][tests] Replace test.all.yaml with minimal platform cover (#11151) 2025-10-09 13:45:59 -10:00
J. Nick Koston
590cae13c0 [ci][tests] Remove redundant ESP32-C3 Arduino tests for non-variant-specific components (#11152) 2025-10-09 18:41:50 -05:00
J. Nick Koston
e15429b0f5 [opentherm][ci][tests] Remove redundant ESP32 Arduino tests and simplify conditionals (#11149) 2025-10-09 23:38:34 +00:00
J. Nick Koston
b5cc668a45 [ci][logger][tests] Remove redundant ESP32 Arduino test files (#11144) 2025-10-09 13:30:05 -10:00
Jonathan Swoboda
a1b0ae78e0 [stale] Increase operations-per-run (#11135)
CI passed, stuck on status
2025-10-09 19:10:09 -04:00
J. Nick Koston
88082911e9 [opentherm][ci][tests] Remove redundant ESP32 Arduino tests and fix conditionals 2025-10-09 12:24:37 -10:00
J. Nick Koston
fcc8a809e6 [ci][debug][tests] Remove redundant ESP32 variant Arduino test files (#11146) 2025-10-09 16:57:40 -05:00
J. Nick Koston
48474c0f8c [ci][time][tests] Remove redundant ESP32 Arduino test files (#11147) 2025-10-09 16:57:11 -05:00
J. Nick Koston
9f9c95dd09 [network][ci][tests] Remove redundant ESP32 Arduino test files (#11148) 2025-10-09 16:56:53 -05:00
J. Nick Koston
a74fcbc8b6 [esp32_ble_beacon, esp32_ble_tracker] Remove unused Arduino includes and redundant tests (#11140) 2025-10-09 11:42:25 -10:00
J. Nick Koston
c8b898f9c5 [ci][mdns][tests] Remove redundant ESP32 Arduino test files (#11143) 2025-10-09 11:40:47 -10:00
J. Nick Koston
81bf2688b4 [esp32] Update migration warning for Arduino-as-IDF-component transition (#11142) 2025-10-09 11:36:31 -10:00
Jonathan Swoboda
87d2c9868f [esp32] Update IDF 5.5 and Arduino 3.3 to use 55.03.31-1 (#11120) 2025-10-09 21:27:36 +00:00
J. Nick Koston
968d1e2647 Merge branch 'integration' into memory_api 2025-10-09 10:41:47 -10:00
J. Nick Koston
5a4f1dd2da Merge branch 'esp32_remove_esp32_ard_apis' into integration 2025-10-09 10:41:40 -10:00
J. Nick Koston
d8af6e0c75 fix 2025-10-09 10:40:02 -10:00
J. Nick Koston
36bcd8c204 fix 2025-10-09 10:39:51 -10:00
J. Nick Koston
5b146e1f12 fix 2025-10-09 10:39:41 -10:00
J. Nick Koston
de8a4ff6b0 Merge branch 'integration' into memory_api 2025-10-09 10:33:08 -10:00
J. Nick Koston
d837a001db Merge branch 'esp32_remove_esp32_ard_apis' into integration 2025-10-09 10:33:01 -10:00
J. Nick Koston
df71198a24 Merge remote-tracking branch 'upstream/dev' into integration 2025-10-09 10:32:58 -10:00
J. Nick Koston
5a5bebe71e Merge branch 'integration' of https://github.com/esphome/esphome into integration 2025-10-09 10:32:52 -10:00
J. Nick Koston
8853593a7b [esp32_ble*] Remove Arduino BLE wrapper dependencies 2025-10-09 10:32:04 -10:00
J. Nick Koston
5ca407e27c [mdns] Store TXT record values in flash to reduce heap usage (#11114) 2025-10-10 09:01:58 +13:00
dependabot[bot]
5bbc2ab482 Bump pyupgrade from 3.20.0 to 3.21.0 (#11139)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-09 19:40:40 +00:00
J. Nick Koston
309e8b4c92 [ci][improv_serial][tests] Remove redundant ESP32 Arduino test files (#11138) 2025-10-09 19:17:04 +00:00
Jesse Hills
eee2987c99 Merge branch 'beta' into dev 2025-10-10 07:53:53 +13:00
J. Nick Koston
061e55f8c5 [ci][ethernet][tests] Remove redundant Arduino tests for ethernet PHYs (#11137) 2025-10-09 08:45:45 -10:00
Jesse Hills
9ad462d8c6 Merge pull request #11115 from esphome/bump-2025.10.0b1
2025.10.0b1
2025-10-10 07:28:02 +13:00
J. Nick Koston
56334b7832 [ci][tests] Remove redundant ESP32 Arduino test files (#11136) 2025-10-10 07:26:41 +13:00
J. Nick Koston
a4b7e0c700 [canbus][mcp23xxx_base] Mark virtual methods as pure virtual to fix linker errors (#11133) 2025-10-09 07:41:49 -10:00
Jeff Brown
84ad7ee0e4 [esp32] Accept more framework URL schemes as sources (#11125) 2025-10-09 13:10:48 -04:00
dependabot[bot]
d006008539 Bump esphome-dashboard from 20250904.0 to 20251009.0 (#11123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-09 09:26:38 -04:00
J. Nick Koston
f1af9d978c [ci] Reduce component test group size to 10 to prevent runner disk exhaustion (#11122) 2025-10-09 10:36:13 +13:00
J. Nick Koston
6bb1e4c9c0 [ci] Reduce component test group size to 10 to prevent runner disk exhaustion (#11122) 2025-10-09 10:35:52 +13:00
J. Nick Koston
c756e132a7 Merge branch 'integration' into memory_api 2025-10-08 09:28:07 -10:00
J. Nick Koston
e5a0a1d143 Merge remote-tracking branch 'upstream/dev' into integration 2025-10-08 09:27:34 -10:00
J. Nick Koston
785df05631 [ci] Reduce component test group size to prevent runner disk exhaustion (#11121) 2025-10-09 07:53:49 +13:00
J. Nick Koston
82bdb08884 [ci] Reduce component test group size to prevent runner disk exhaustion (#11121) 2025-10-08 14:24:26 -04:00
J. Nick Koston
98e68c32ee Merge branch 'integration' into memory_api 2025-10-08 05:50:03 -10:00
J. Nick Koston
b33b68b885 Merge branch 'webserver_helpers' into integration 2025-10-08 05:49:55 -10:00
J. Nick Koston
9ac48b162b tweak 2025-10-08 05:48:56 -10:00
J. Nick Koston
41d07701ee tweak 2025-10-08 05:46:20 -10:00
J. Nick Koston
fed252d1d3 wip 2025-10-08 05:40:31 -10:00
J. Nick Koston
2b8fdfb6a6 [web_server] Reduce code duplication in JSON generation with helper functions 2025-10-08 05:22:15 -10:00
J. Nick Koston
2ea32635c9 Merge branch 'integration' into memory_api 2025-10-08 05:12:32 -10:00
J. Nick Koston
8c876ec07d Merge branch 'webserver_dupe_checks' into integration 2025-10-08 05:12:22 -10:00
J. Nick Koston
576cf8ed6d [web_server] Consolidate duplicate client connection checks (saves 288 bytes flash) 2025-10-08 05:11:34 -10:00
J. Nick Koston
48799517eb Merge branch 'integration' into memory_api 2025-10-07 22:55:15 -10:00
J. Nick Koston
3e8672f351 Merge branch 'mdns_value_flash' into integration 2025-10-07 22:55:08 -10:00
J. Nick Koston
16f7de29eb Merge remote-tracking branch 'upstream/dev' into mdns_value_flash 2025-10-07 22:51:06 -10:00
J. Nick Koston
b1e950e785 better cond 2025-10-07 22:45:54 -10:00
J. Nick Koston
a0d9098f41 Merge branch 'integration' into memory_api 2025-10-07 22:34:07 -10:00
J. Nick Koston
e1852bdd59 Merge branch 'mdns_value_flash' into integration 2025-10-07 22:34:00 -10:00
J. Nick Koston
6eef594110 Merge remote-tracking branch 'origin/mdns_value_flash' into mdns_value_flash 2025-10-07 22:33:48 -10:00
J. Nick Koston
b22e154284 just remove it 2025-10-07 22:33:37 -10:00
J. Nick Koston
a793690795 Merge branch 'dev' into mdns_value_flash 2025-10-07 22:23:27 -10:00
J. Nick Koston
fc0afa1793 Merge branch 'integration' into memory_api 2025-10-07 22:22:31 -10:00
J. Nick Koston
d80e7a5ab6 Merge branch 'mdns_value_flash' into integration 2025-10-07 22:22:26 -10:00
J. Nick Koston
f33d9a77f3 bot comments 2025-10-07 22:22:12 -10:00
Jesse Hills
b709ff84c3 Bump version to 2025.11.0-dev 2025-10-08 21:14:45 +13:00
Jesse Hills
93266ad08f Bump version to 2025.10.0b1 2025-10-08 21:14:44 +13:00
Jesse Hills
2fac813f18 [epaper_spi] New epaper component (#10462)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
Co-authored-by: Tudor Sandu <tm.sandu@gmail.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-10-08 21:11:06 +13:00
J. Nick Koston
734a0f3998 static analysis 2025-10-07 22:01:22 -10:00
J. Nick Koston
21d4e090bf Merge branch 'integration' into memory_api 2025-10-07 21:52:06 -10:00
J. Nick Koston
fe8af38f62 Merge branch 'mdns_value_flash' into integration 2025-10-07 21:51:56 -10:00
J. Nick Koston
d7964c4068 Merge branch 'dev' into integration 2025-10-07 21:51:53 -10:00
J. Nick Koston
72087bf6ba store mdns values in flash 2025-10-07 21:48:18 -10:00
Jesse Hills
a62c7a03dd [api] Add support for getting action responses from home-assistant (#10948)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-10-08 20:47:36 +13:00
J. Nick Koston
f5bb79cbc4 goodbye strdup 2025-10-07 21:46:08 -10:00
J. Nick Koston
d9c3213ef6 goodbye strdup 2025-10-07 21:43:35 -10:00
J. Nick Koston
328c1a8469 goodbye strdup 2025-10-07 21:39:04 -10:00
J. Nick Koston
6c0a0334a8 preen 2025-10-07 21:26:56 -10:00
J. Nick Koston
1476dcf5c8 preen 2025-10-07 21:24:10 -10:00
J. Nick Koston
ac7bd4137f preen 2025-10-07 21:22:34 -10:00
J. Nick Koston
52f2826d38 preen 2025-10-07 21:21:22 -10:00
J. Nick Koston
55888b9bee store mdns values in flash 2025-10-07 21:19:35 -10:00
J. Nick Koston
ec63247ae0 [mdns] Fix delete/malloc bug and store string constants in flash (#11105) 2025-10-08 04:19:29 +00:00
carlessolegrau
0fe6e7169c [modbus_controller] courtesy response (#10027)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2025-10-08 16:40:49 +13:00
dependabot[bot]
a0f4de1bfb Bump aioesphomeapi from 41.12.0 to 41.13.0 (#11113)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 03:35:17 +00:00
Jesse Hills
a541549d23 [core] Fix dynamic auto load priority (#11112) 2025-10-07 17:05:09 -10:00
Jonathan Swoboda
b74715fe14 [esp32] Fix issue when framework source is set (#11106) 2025-10-07 22:55:59 -04:00
J. Nick Koston
181f360176 Merge branch 'integration' into memory_api 2025-10-07 16:21:40 -10:00
J. Nick Koston
4acbf03f4e Merge branch 'jesserockz-2025-457' into integration 2025-10-07 16:21:32 -10:00
J. Nick Koston
5e16d84e0c Merge branch 'dev' into jesserockz-2025-457 2025-10-07 21:12:50 -05:00
J. Nick Koston
58796141e9 Merge branch 'integration' into memory_api 2025-10-07 16:12:17 -10:00
J. Nick Koston
a554d8b122 Merge remote-tracking branch 'upstream/dev' into integration 2025-10-07 16:11:56 -10:00
J. Nick Koston
5aff20a624 [api] Add message size limits to prevent memory exhaustion (#10936) 2025-10-08 00:47:31 +00:00
J. Nick Koston
76c8da03fe Merge branch 'integration' into memory_api 2025-10-07 14:10:20 -10:00
J. Nick Koston
b5ef87a1b8 Merge branch 'api_size_limits' into integration 2025-10-07 14:10:14 -10:00
J. Nick Koston
7f13080478 Merge branch 'mdns_esp32_cleanup' into integration 2025-10-07 14:10:10 -10:00
J. Nick Koston
2c408b7d78 Merge remote-tracking branch 'upstream/dev' into integration 2025-10-07 14:10:03 -10:00
J. Nick Koston
43c7ebcab4 missed python 2025-10-07 14:06:28 -10:00
J. Nick Koston
e3fadb1858 missed python 2025-10-07 14:05:22 -10:00
J. Nick Koston
a991768772 missed python 2025-10-07 14:02:39 -10:00
Kevin Ahrendt
7682b4e9a3 [audio] Update esp-audio-libs 2.0.1 to use new FLAC decoder (#10974)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2025-10-07 23:35:42 +00:00
J. Nick Koston
b9e2a30a38 Update test_oversized_payloads.py 2025-10-07 18:17:17 -05:00
J. Nick Koston
cb578c2198 Update test_oversized_payloads.py 2025-10-07 18:16:20 -05:00
J. Nick Koston
3b06b3386f Merge branch 'integration' into memory_api 2025-10-07 17:47:36 -05:00
J. Nick Koston
3a68268f39 Merge branch 'api_size_limits' into integration 2025-10-07 17:47:04 -05:00
J. Nick Koston
ef1c12c21f adjust 2025-10-07 17:37:50 -05:00
J. Nick Koston
6107802d69 Merge remote-tracking branch 'upstream/dev' into memory_api 2025-10-07 17:17:47 -05:00
J. Nick Koston
f59d2d5aca Merge branch 'integration' into memory_api 2025-10-07 17:16:24 -05:00
J. Nick Koston
453eecb240 Merge branch 'mdns_esp32_cleanup' into integration 2025-10-07 17:16:15 -05:00
J. Nick Koston
fa66b3235d tidy 2025-10-07 16:58:59 -05:00
J. Nick Koston
7446c87267 tidy 2025-10-07 16:58:19 -05:00
J. Nick Koston
57bd6ec68c tidy 2025-10-07 16:46:26 -05:00
J. Nick Koston
95ecacc5f7 tidy 2025-10-07 16:39:40 -05:00
J. Nick Koston
2e1d5662ea tidy 2025-10-07 16:34:51 -05:00
J. Nick Koston
87a1040285 keep all 8266 in flash 2025-10-07 16:29:10 -05:00
Jonathan Swoboda
6eabf709c6 [esp32] Hide build warnings (#11102) 2025-10-08 10:27:56 +13:00
J. Nick Koston
71765f01e6 Merge branch 'dev' into api_size_limits 2025-10-07 16:18:23 -05:00
J. Nick Koston
6209d4b493 [api] Optimize frame helpers to eliminate double-move overhead (#11092) 2025-10-08 10:16:44 +13:00
J. Nick Koston
1a6aaedbb7 preen 2025-10-07 16:16:36 -05:00
J. Nick Koston
b49f60569e tidy 2025-10-07 15:50:51 -05:00
J. Nick Koston
63a94df74f tidy 2025-10-07 15:47:19 -05:00
J. Nick Koston
15968cd8be Merge branch 'integration' into memory_api 2025-10-07 15:42:36 -05:00
J. Nick Koston
7693545d86 Merge branch 'mdns_esp32_cleanup' into integration 2025-10-07 15:42:10 -05:00
J. Nick Koston
f10c361454 [esp32_ble] Refactor ESPBTUUID comparison with direct returns and memcmp (#11074) 2025-10-08 09:34:08 +13:00
J. Nick Koston
f0a7c6b0bb simplify 2025-10-07 15:32:59 -05:00
J. Nick Koston
27456c1370 [esp32_ble] Refactor ESPBTUUID::from_raw to use parse_hex helpers (#11073) 2025-10-08 09:32:47 +13:00
J. Nick Koston
711532465e simplify 2025-10-07 15:27:49 -05:00
J. Nick Koston
2e4722104e simplify 2025-10-07 15:25:13 -05:00
J. Nick Koston
c9a709675a simplify 2025-10-07 15:25:13 -05:00
J. Nick Koston
65b8148f2e simplify 2025-10-07 15:24:48 -05:00
J. Nick Koston
93d493004c simplify 2025-10-07 15:24:42 -05:00
J. Nick Koston
1aeefbe547 [light] Reduce flash usage by eliminating duplicate validation code (#11030)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-08 09:23:57 +13:00
J. Nick Koston
94eab93110 Merge branch 'integration' into memory_api 2025-10-07 14:59:21 -05:00
J. Nick Koston
762c141d93 Merge branch 'mdns_esp32_cleanup' into integration 2025-10-07 14:59:15 -05:00
J. Nick Koston
cf1ba30e90 just store key in flash 2025-10-07 14:54:28 -05:00
J. Nick Koston
7bc1f23d6c Merge branch 'dev' into jesserockz-2025-457 2025-10-07 14:52:47 -05:00
J. Nick Koston
9cecbee33a revise 2025-10-07 14:44:38 -05:00
J. Nick Koston
03884d05b4 fix test 2025-10-07 14:34:03 -05:00
dependabot[bot]
3f3bce7ef4 Bump ruff from 0.13.3 to 0.14.0 (#11107)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-10-07 19:27:21 +00:00
J. Nick Koston
0fa47e3bf5 Merge branch 'dev' into jesserockz-2025-457 2025-10-07 14:25:18 -05:00
Jesse Hills
0acc58d5a1 [core] Update helpers for new auto load functionality (#11097) 2025-10-07 14:24:28 -05:00
dependabot[bot]
0b4ef0fea2 Bump github/codeql-action from 3.30.6 to 4.30.7 (#11109)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 14:22:36 -05:00
dependabot[bot]
a067bdb769 Bump aioesphomeapi from 41.11.0 to 41.12.0 (#11108)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 14:21:35 -05:00
J. Nick Koston
a159e4762a Merge branch 'dev' into api_size_limits 2025-10-05 22:29:00 -05:00
2961 changed files with 10487 additions and 11430 deletions

View File

@@ -186,6 +186,11 @@ This document provides essential context for AI models interacting with this pro
└── components/[component]/ # Component-specific tests
```
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
./script/test_component_grouping.py -e config --all
```
This tests all components in a single build to catch conflicts that might not appear when testing components individually. Use `-e config` for fast configuration validation, or `-e compile` for full compilation testing.
* **Debugging and Troubleshooting:**
* **Debug Tools:**
- `esphome config <file>.yaml` to validate configuration.

View File

@@ -1 +1 @@
ab49c22900dd39c004623e450a1076b111d6741f31967a637ab6e0e3dd2e753e
049d60eed541730efaa4c0dc5d337b4287bf29b6daa350b5dfc1f23915f1c52f

View File

@@ -177,6 +177,7 @@ jobs:
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
python-linters: ${{ steps.determine.outputs.python-linters }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
component-test-count: ${{ steps.determine.outputs.component-test-count }}
steps:
- name: Check out code from GitHub
@@ -204,6 +205,7 @@ jobs:
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
integration-tests:
@@ -367,12 +369,13 @@ jobs:
fail-fast: false
max-parallel: 2
matrix:
file: ${{ fromJson(needs.determine-jobs.outputs.changed-components) }}
file: ${{ fromJson(needs.determine-jobs.outputs.changed-components-with-tests) }}
steps:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install libsdl2-dev
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: libsdl2-dev
version: 1.0
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -381,17 +384,17 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: test_build_components -e config -c ${{ matrix.file }}
- name: Validate config for ${{ matrix.file }}
run: |
. venv/bin/activate
./script/test_build_components -e config -c ${{ matrix.file }}
- name: test_build_components -e compile -c ${{ matrix.file }}
python3 script/test_build_components.py -e config -c ${{ matrix.file }}
- name: Compile config for ${{ matrix.file }}
run: |
. venv/bin/activate
./script/test_build_components -e compile -c ${{ matrix.file }}
python3 script/test_build_components.py -e compile -c ${{ matrix.file }}
test-build-components-splitter:
name: Split components for testing into 20 groups maximum
name: Split components for intelligent grouping (40 weighted per batch)
runs-on: ubuntu-24.04
needs:
- common
@@ -402,14 +405,26 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Split components into 20 groups
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Split components intelligently based on bus configurations
id: split
run: |
components=$(echo '${{ needs.determine-jobs.outputs.changed-components }}' | jq -c '.[]' | shuf | jq -s -c '[_nwise(20) | join(" ")]')
echo "components=$components" >> $GITHUB_OUTPUT
. venv/bin/activate
# Use intelligent splitter that groups components with same bus configs
components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}'
echo "Splitting components intelligently..."
output=$(python3 script/split_components_for_ci.py --components "$components" --batch-size 40 --output github)
echo "$output" >> $GITHUB_OUTPUT
test-build-components-split:
name: Test split components
name: Test components batch (${{ matrix.components }})
runs-on: ubuntu-24.04
needs:
- common
@@ -418,17 +433,23 @@ jobs:
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100
strategy:
fail-fast: false
max-parallel: 4
max-parallel: 5
matrix:
components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }}
steps:
- name: Show disk space
run: |
echo "Available disk space:"
df -h
- name: List components
run: echo ${{ matrix.components }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install libsdl2-dev
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
with:
packages: libsdl2-dev
version: 1.0
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -437,20 +458,37 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Validate config
- name: Validate and compile components with intelligent grouping
run: |
. venv/bin/activate
for component in ${{ matrix.components }}; do
./script/test_build_components -e config -c $component
done
- name: Compile config
run: |
. venv/bin/activate
mkdir build_cache
export PLATFORMIO_BUILD_CACHE_DIR=$PWD/build_cache
for component in ${{ matrix.components }}; do
./script/test_build_components -e compile -c $component
done
# Use /mnt for build files (70GB available vs ~29GB on /)
# Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
sudo mkdir -p /mnt/platformio
sudo chown $USER:$USER /mnt/platformio
mkdir -p ~/.platformio
sudo mount --bind /mnt/platformio ~/.platformio
# Bind mount test build directory to /mnt
sudo mkdir -p /mnt/test_build_components_build
sudo chown $USER:$USER /mnt/test_build_components_build
mkdir -p tests/test_build_components/build
sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
# Convert space-separated components to comma-separated for Python script
components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',')
echo "Testing components: $components_csv"
echo ""
# Run config validation with grouping
python3 script/test_build_components.py -e config -c "$components_csv" -f
echo ""
echo "Config validation passed! Starting compilation..."
echo ""
# Run compilation with grouping
python3 script/test_build_components.py -e compile -c "$components_csv" -f
pre-commit-ci-lite:
name: pre-commit.ci lite

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
with:
category: "/language:${{matrix.language}}"

View File

@@ -23,7 +23,7 @@ jobs:
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
operations-per-run: 150
operations-per-run: 400
# The 90 day stale policy for PRs
# - PRs

View File

@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.13.3
rev: v0.14.0
hooks:
# Run the linter.
- id: ruff

View File

@@ -139,6 +139,7 @@ esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
esphome/components/ens160_spi/* @latonita
esphome/components/ens210/* @itn3rd77
esphome/components/epaper_spi/* @esphome/core
esphome/components/es7210/* @kahrendt
esphome/components/es7243e/* @kbx81
esphome/components/es8156/* @kbx81
@@ -429,6 +430,7 @@ esphome/components/speaker/media_player/* @kahrendt @synesthesiam
esphome/components/spi/* @clydebarrow @esphome/core
esphome/components/spi_device/* @clydebarrow
esphome/components/spi_led_strip/* @clydebarrow
esphome/components/split_buffer/* @jesserockz
esphome/components/sprinkler/* @kbx81
esphome/components/sps30/* @martgras
esphome/components/ssd1322_base/* @kbx81

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 = 2025.10.0-dev
PROJECT_NUMBER = 2025.11.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

View File

@@ -1009,6 +1009,12 @@ def parse_args(argv):
action="append",
default=[],
)
options_parser.add_argument(
"--testing-mode",
help="Enable testing mode (disables validation checks for grouped component testing)",
action="store_true",
default=False,
)
parser = argparse.ArgumentParser(
description=f"ESPHome {const.__version__}", parents=[options_parser]
@@ -1278,6 +1284,7 @@ def run_esphome(argv):
args = parse_args(argv)
CORE.dashboard = args.dashboard
CORE.testing_mode = args.testing_mode
# Create address cache from command-line arguments
CORE.address_cache = AddressCache.from_cli_args(

View File

@@ -19,13 +19,14 @@ namespace esphome::api {
//#define HELPER_LOG_PACKETS
// Maximum message size limits to prevent OOM on constrained devices
// Voice Assistant is our largest user at 1024 bytes per audio chunk
// Using 2048 + 256 bytes overhead = 2304 bytes total to support voice and future needs
// ESP8266 has very limited RAM and cannot support voice assistant
// Handshake messages are limited to a small size for security
static constexpr uint16_t MAX_HANDSHAKE_SIZE = 128;
// Data message limits vary by platform based on available memory
#ifdef USE_ESP8266
static constexpr uint16_t MAX_MESSAGE_SIZE = 512; // Keep small for memory constrained ESP8266
static constexpr uint16_t MAX_MESSAGE_SIZE = 8192; // 8 KiB for ESP8266
#else
static constexpr uint16_t MAX_MESSAGE_SIZE = 2304; // Support voice (1024) + headroom for larger messages
static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and other platforms
#endif
// Forward declaration

View File

@@ -133,9 +133,6 @@ APIError APINoiseFrameHelper::loop() {
}
/** Read a packet into the rx_buf_.
*
* On success, rx_buf_ contains the frame data and state variables are cleared for the next read.
* Caller is responsible for consuming rx_buf_ (e.g., via std::move).
*
* @return APIError::OK if a full packet is in rx_buf_
*
@@ -176,18 +173,12 @@ APIError APINoiseFrameHelper::try_read_frame_() {
// read body
uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2];
if (state_ != State::DATA && msg_size > 128) {
// for handshake message only permit up to 128 bytes
// Check against size limits to prevent OOM: MAX_HANDSHAKE_SIZE for handshake, MAX_MESSAGE_SIZE for data
uint16_t limit = (state_ == State::DATA) ? MAX_MESSAGE_SIZE : MAX_HANDSHAKE_SIZE;
if (msg_size > limit) {
state_ = State::FAILED;
HELPER_LOG("Bad packet len for handshake: %d", msg_size);
return APIError::BAD_HANDSHAKE_PACKET_LEN;
}
// Check against maximum message size to prevent OOM
if (msg_size > MAX_MESSAGE_SIZE) {
state_ = State::FAILED;
HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, MAX_MESSAGE_SIZE);
return APIError::BAD_DATA_PACKET;
HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, limit);
return (state_ == State::DATA) ? APIError::BAD_DATA_PACKET : APIError::BAD_HANDSHAKE_PACKET_LEN;
}
// Reserve space for body

View File

@@ -48,9 +48,6 @@ APIError APIPlaintextFrameHelper::loop() {
}
/** Read a packet into the rx_buf_.
*
* On success, rx_buf_ contains the frame data and state variables are cleared for the next read.
* Caller is responsible for consuming rx_buf_ (e.g., via std::move).
*
* @return See APIError
*

View File

@@ -165,4 +165,4 @@ def final_validate_audio_schema(
async def to_code(config):
cg.add_library("esphome/esp-audio-libs", "1.1.4")
cg.add_library("esphome/esp-audio-libs", "2.0.1")

View File

@@ -229,18 +229,18 @@ FileDecoderState AudioDecoder::decode_flac_() {
auto result = this->flac_decoder_->read_header(this->input_transfer_buffer_->get_buffer_start(),
this->input_transfer_buffer_->available());
if (result == esp_audio_libs::flac::FLAC_DECODER_HEADER_OUT_OF_DATA) {
return FileDecoderState::POTENTIALLY_FAILED;
}
if (result != esp_audio_libs::flac::FLAC_DECODER_SUCCESS) {
// Couldn't read FLAC header
if (result > esp_audio_libs::flac::FLAC_DECODER_HEADER_OUT_OF_DATA) {
// Serrious error reading FLAC header, there is no recovery
return FileDecoderState::FAILED;
}
size_t bytes_consumed = this->flac_decoder_->get_bytes_index();
this->input_transfer_buffer_->decrease_buffer_length(bytes_consumed);
if (result == esp_audio_libs::flac::FLAC_DECODER_HEADER_OUT_OF_DATA) {
return FileDecoderState::MORE_TO_PROCESS;
}
// Reallocate the output transfer buffer to the smallest necessary size
this->free_buffer_required_ = flac_decoder_->get_output_buffer_size_bytes();
if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
@@ -256,9 +256,9 @@ FileDecoderState AudioDecoder::decode_flac_() {
}
uint32_t output_samples = 0;
auto result = this->flac_decoder_->decode_frame(
this->input_transfer_buffer_->get_buffer_start(), this->input_transfer_buffer_->available(),
reinterpret_cast<int16_t *>(this->output_transfer_buffer_->get_buffer_end()), &output_samples);
auto result = this->flac_decoder_->decode_frame(this->input_transfer_buffer_->get_buffer_start(),
this->input_transfer_buffer_->available(),
this->output_transfer_buffer_->get_buffer_end(), &output_samples);
if (result == esp_audio_libs::flac::FLAC_DECODER_ERROR_OUT_OF_DATA) {
// Not an issue, just needs more data that we'll get next time.

View File

@@ -105,9 +105,9 @@ class Canbus : public Component {
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
callback_manager_{};
virtual bool setup_internal();
virtual Error send_message(struct CanFrame *frame);
virtual Error read_message(struct CanFrame *frame);
virtual bool setup_internal() = 0;
virtual Error send_message(struct CanFrame *frame) = 0;
virtual Error read_message(struct CanFrame *frame) = 0;
};
template<typename... Ts> class CanbusSendAction : public Action<Ts...>, public Parented<Canbus> {

View File

@@ -5,7 +5,7 @@ namespace dashboard_import {
static std::string g_package_import_url; // NOLINT
std::string get_package_import_url() { return g_package_import_url; }
const std::string &get_package_import_url() { return g_package_import_url; }
void set_package_import_url(std::string url) { g_package_import_url = std::move(url); }
} // namespace dashboard_import

View File

@@ -5,7 +5,7 @@
namespace esphome {
namespace dashboard_import {
std::string get_package_import_url();
const std::string &get_package_import_url();
void set_package_import_url(std::string url);
} // namespace dashboard_import

View File

@@ -0,0 +1 @@
CODEOWNERS = ["@esphome/core"]

View File

@@ -0,0 +1,80 @@
from esphome import core, pins
import esphome.codegen as cg
from esphome.components import display, spi
import esphome.config_validation as cv
from esphome.const import (
CONF_BUSY_PIN,
CONF_DC_PIN,
CONF_ID,
CONF_LAMBDA,
CONF_MODEL,
CONF_PAGES,
CONF_RESET_DURATION,
CONF_RESET_PIN,
)
AUTO_LOAD = ["split_buffer"]
DEPENDENCIES = ["spi"]
epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi")
EPaperBase = epaper_spi_ns.class_(
"EPaperBase", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer
)
EPaperSpectraE6 = epaper_spi_ns.class_("EPaperSpectraE6", EPaperBase)
EPaper7p3InSpectraE6 = epaper_spi_ns.class_("EPaper7p3InSpectraE6", EPaperSpectraE6)
MODELS = {
"7.3in-spectra-e6": EPaper7p3InSpectraE6,
}
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(EPaperBase),
cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema,
cv.Required(CONF_MODEL): cv.one_of(*MODELS, lower=True, space="-"),
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_BUSY_PIN): pins.gpio_input_pin_schema,
cv.Optional(CONF_RESET_DURATION): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=core.TimePeriod(milliseconds=500)),
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(spi.spi_device_schema()),
cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA),
)
FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
"epaper_spi", require_miso=False, require_mosi=True
)
async def to_code(config):
model = MODELS[config[CONF_MODEL]]
rhs = model.new()
var = cg.Pvariable(config[CONF_ID], rhs, model)
await display.register_display(var, config)
await spi.register_spi_device(var, config)
dc = await cg.gpio_pin_expression(config[CONF_DC_PIN])
cg.add(var.set_dc_pin(dc))
if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda(
config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))
if CONF_RESET_PIN in config:
reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN])
cg.add(var.set_reset_pin(reset))
if CONF_BUSY_PIN in config:
busy = await cg.gpio_pin_expression(config[CONF_BUSY_PIN])
cg.add(var.set_busy_pin(busy))
if CONF_RESET_DURATION in config:
cg.add(var.set_reset_duration(config[CONF_RESET_DURATION]))

View File

@@ -0,0 +1,227 @@
#include "epaper_spi.h"
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static const char *const TAG = "epaper_spi";
static const LogString *epaper_state_to_string(EPaperState state) {
switch (state) {
case EPaperState::IDLE:
return LOG_STR("IDLE");
case EPaperState::UPDATE:
return LOG_STR("UPDATE");
case EPaperState::RESET:
return LOG_STR("RESET");
case EPaperState::INITIALISE:
return LOG_STR("INITIALISE");
case EPaperState::TRANSFER_DATA:
return LOG_STR("TRANSFER_DATA");
case EPaperState::POWER_ON:
return LOG_STR("POWER_ON");
case EPaperState::REFRESH_SCREEN:
return LOG_STR("REFRESH_SCREEN");
case EPaperState::POWER_OFF:
return LOG_STR("POWER_OFF");
case EPaperState::DEEP_SLEEP:
return LOG_STR("DEEP_SLEEP");
default:
return LOG_STR("UNKNOWN");
}
}
void EPaperBase::setup() {
if (!this->init_buffer_(this->get_buffer_length())) {
this->mark_failed("Failed to initialise buffer");
return;
}
this->setup_pins_();
this->spi_setup();
}
bool EPaperBase::init_buffer_(size_t buffer_length) {
if (!this->buffer_.init(buffer_length)) {
return false;
}
this->clear();
return true;
}
void EPaperBase::setup_pins_() {
this->dc_pin_->setup(); // OUTPUT
this->dc_pin_->digital_write(false);
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup(); // OUTPUT
this->reset_pin_->digital_write(true);
}
if (this->busy_pin_ != nullptr) {
this->busy_pin_->setup(); // INPUT
}
}
float EPaperBase::get_setup_priority() const { return setup_priority::PROCESSOR; }
void EPaperBase::command(uint8_t value) {
this->start_command_();
this->write_byte(value);
this->end_command_();
}
void EPaperBase::data(uint8_t value) {
this->start_data_();
this->write_byte(value);
this->end_data_();
}
// write a command followed by zero or more bytes of data.
// The command is the first byte, length is the length of data only in the second byte, followed by the data.
// [COMMAND, LENGTH, DATA...]
void EPaperBase::cmd_data(const uint8_t *data) {
const uint8_t command = data[0];
const uint8_t length = data[1];
const uint8_t *ptr = data + 2;
ESP_LOGVV(TAG, "Command: 0x%02X, Length: %d, Data: %s", command, length,
format_hex_pretty(ptr, length, '.', false).c_str());
this->dc_pin_->digital_write(false);
this->enable();
this->write_byte(command);
if (length > 0) {
this->dc_pin_->digital_write(true);
this->write_array(ptr, length);
}
this->disable();
}
bool EPaperBase::is_idle_() {
if (this->busy_pin_ == nullptr) {
return true;
}
return !this->busy_pin_->digital_read();
}
void EPaperBase::reset() {
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
this->disable_loop();
this->set_timeout(this->reset_duration_, [this] {
this->reset_pin_->digital_write(true);
this->set_timeout(20, [this] { this->enable_loop(); });
});
}
}
void EPaperBase::update() {
if (!this->state_queue_.empty()) {
ESP_LOGE(TAG, "Display update already in progress - %s",
LOG_STR_ARG(epaper_state_to_string(this->state_queue_.front())));
return;
}
this->state_queue_.push(EPaperState::UPDATE);
this->state_queue_.push(EPaperState::RESET);
this->state_queue_.push(EPaperState::INITIALISE);
this->state_queue_.push(EPaperState::TRANSFER_DATA);
this->state_queue_.push(EPaperState::POWER_ON);
this->state_queue_.push(EPaperState::REFRESH_SCREEN);
this->state_queue_.push(EPaperState::POWER_OFF);
this->state_queue_.push(EPaperState::DEEP_SLEEP);
this->state_queue_.push(EPaperState::IDLE);
this->enable_loop();
}
void EPaperBase::loop() {
if (this->waiting_for_idle_) {
if (this->is_idle_()) {
this->waiting_for_idle_ = false;
} else {
if (App.get_loop_component_start_time() - this->waiting_for_idle_last_print_ >= 1000) {
ESP_LOGV(TAG, "Waiting for idle");
this->waiting_for_idle_last_print_ = App.get_loop_component_start_time();
}
return;
}
}
auto state = this->state_queue_.front();
switch (state) {
case EPaperState::IDLE:
this->disable_loop();
break;
case EPaperState::UPDATE:
this->do_update_(); // Calls ESPHome (current page) lambda
break;
case EPaperState::RESET:
this->reset();
break;
case EPaperState::INITIALISE:
this->initialise_();
break;
case EPaperState::TRANSFER_DATA:
if (!this->transfer_data()) {
return; // Not done yet, come back next loop
}
break;
case EPaperState::POWER_ON:
this->power_on();
break;
case EPaperState::REFRESH_SCREEN:
this->refresh_screen();
break;
case EPaperState::POWER_OFF:
this->power_off();
break;
case EPaperState::DEEP_SLEEP:
this->deep_sleep();
break;
}
this->state_queue_.pop();
}
void EPaperBase::start_command_() {
this->dc_pin_->digital_write(false);
this->enable();
}
void EPaperBase::end_command_() { this->disable(); }
void EPaperBase::start_data_() {
this->dc_pin_->digital_write(true);
this->enable();
}
void EPaperBase::end_data_() { this->disable(); }
void EPaperBase::on_safe_shutdown() { this->deep_sleep(); }
void EPaperBase::initialise_() {
size_t index = 0;
const auto &sequence = this->init_sequence_;
const size_t sequence_size = this->init_sequence_length_;
while (index != sequence_size) {
if (sequence_size - index < 2) {
this->mark_failed("Malformed init sequence");
return;
}
const auto *ptr = sequence + index;
const uint8_t length = ptr[1];
if (sequence_size - index < length + 2) {
this->mark_failed("Malformed init sequence");
return;
}
this->cmd_data(ptr);
index += length + 2;
}
this->power_on();
}
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,93 @@
#pragma once
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/spi/spi.h"
#include "esphome/components/split_buffer/split_buffer.h"
#include "esphome/core/component.h"
#include <queue>
namespace esphome::epaper_spi {
enum class EPaperState : uint8_t {
IDLE,
UPDATE,
RESET,
INITIALISE,
TRANSFER_DATA,
POWER_ON,
REFRESH_SCREEN,
POWER_OFF,
DEEP_SLEEP,
};
static const uint8_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run
class EPaperBase : public display::DisplayBuffer,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_2MHZ> {
public:
EPaperBase(const uint8_t *init_sequence, const size_t init_sequence_length)
: init_sequence_length_(init_sequence_length), init_sequence_(init_sequence) {}
void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; }
float get_setup_priority() const override;
void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; }
void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; }
void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; }
void command(uint8_t value);
void data(uint8_t value);
void cmd_data(const uint8_t *data);
void update() override;
void loop() override;
void setup() override;
void on_safe_shutdown() override;
protected:
bool is_idle_();
void setup_pins_();
virtual void reset();
void initialise_();
bool init_buffer_(size_t buffer_length);
virtual int get_width_controller() { return this->get_width_internal(); };
virtual void deep_sleep() = 0;
/**
* Send data to the device via SPI
* @return true if done, false if should be called next loop
*/
virtual bool transfer_data() = 0;
virtual void refresh_screen() = 0;
virtual void power_on() = 0;
virtual void power_off() = 0;
virtual uint32_t get_buffer_length() = 0;
void start_command_();
void end_command_();
void start_data_();
void end_data_();
const size_t init_sequence_length_{0};
size_t current_data_index_{0};
uint32_t reset_duration_{200};
uint32_t waiting_for_idle_last_print_{0};
GPIOPin *dc_pin_;
GPIOPin *busy_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
const uint8_t *init_sequence_{nullptr};
bool waiting_for_idle_{false};
split_buffer::SplitBuffer buffer_;
std::queue<EPaperState> state_queue_{{EPaperState::IDLE}};
};
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,42 @@
#include "epaper_spi_model_7p3in_spectra_e6.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.7.3in-spectra-e6";
void EPaper7p3InSpectraE6::power_on() {
ESP_LOGI(TAG, "Power on");
this->command(0x04);
this->waiting_for_idle_ = true;
}
void EPaper7p3InSpectraE6::power_off() {
ESP_LOGI(TAG, "Power off");
this->command(0x02);
this->data(0x00);
this->waiting_for_idle_ = true;
}
void EPaper7p3InSpectraE6::refresh_screen() {
ESP_LOGI(TAG, "Refresh");
this->command(0x12);
this->data(0x00);
this->waiting_for_idle_ = true;
}
void EPaper7p3InSpectraE6::deep_sleep() {
ESP_LOGI(TAG, "Deep sleep");
this->command(0x07);
this->data(0xA5);
}
void EPaper7p3InSpectraE6::dump_config() {
LOG_DISPLAY("", "E-Paper SPI", this);
ESP_LOGCONFIG(TAG, " Model: 7.3in Spectra E6");
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_PIN(" DC Pin: ", this->dc_pin_);
LOG_PIN(" Busy Pin: ", this->busy_pin_);
LOG_UPDATE_INTERVAL(this);
}
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,45 @@
#pragma once
#include "epaper_spi_spectra_e6.h"
namespace esphome::epaper_spi {
class EPaper7p3InSpectraE6 : public EPaperSpectraE6 {
static constexpr const uint16_t WIDTH = 800;
static constexpr const uint16_t HEIGHT = 480;
// clang-format off
// Command, data length, data
static constexpr uint8_t INIT_SEQUENCE[] = {
0xAA, 6, 0x49, 0x55, 0x20, 0x08, 0x09, 0x18,
0x01, 1, 0x3F,
0x00, 2, 0x5F, 0x69,
0x03, 4, 0x00, 0x54, 0x00, 0x44,
0x05, 4, 0x40, 0x1F, 0x1F, 0x2C,
0x06, 4, 0x6F, 0x1F, 0x17, 0x49,
0x08, 4, 0x6F, 0x1F, 0x1F, 0x22,
0x30, 1, 0x03,
0x50, 1, 0x3F,
0x60, 2, 0x02, 0x00,
0x61, 4, WIDTH / 256, WIDTH % 256, HEIGHT / 256, HEIGHT % 256,
0x84, 1, 0x01,
0xE3, 1, 0x2F,
};
// clang-format on
public:
EPaper7p3InSpectraE6() : EPaperSpectraE6(INIT_SEQUENCE, sizeof(INIT_SEQUENCE)) {}
void dump_config() override;
protected:
int get_width_internal() override { return WIDTH; };
int get_height_internal() override { return HEIGHT; };
void refresh_screen() override;
void power_on() override;
void power_off() override;
void deep_sleep() override;
};
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,135 @@
#include "epaper_spi_spectra_e6.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.6c";
static inline uint8_t color_to_hex(Color color) {
if (color.red > 127) {
if (color.green > 170) {
if (color.blue > 127) {
return 0x1; // White
} else {
return 0x2; // Yellow
}
} else {
return 0x3; // Red (or Magenta)
}
} else {
if (color.green > 127) {
if (color.blue > 127) {
return 0x5; // Cyan -> Blue
} else {
return 0x6; // Green
}
} else {
if (color.blue > 127) {
return 0x5; // Blue
} else {
return 0x0; // Black
}
}
}
}
void EPaperSpectraE6::fill(Color color) {
uint8_t pixel_color;
if (color.is_on()) {
pixel_color = color_to_hex(color);
} else {
pixel_color = 0x1;
}
// We store 8 bitset<3> in 3 bytes
// | byte 1 | byte 2 | byte 3 |
// |aaabbbaa|abbbaaab|bbaaabbb|
uint8_t byte_1 = pixel_color << 5 | pixel_color << 2 | pixel_color >> 1;
uint8_t byte_2 = pixel_color << 7 | pixel_color << 4 | pixel_color << 1 | pixel_color >> 2;
uint8_t byte_3 = pixel_color << 6 | pixel_color << 3 | pixel_color << 0;
const size_t buffer_length = this->get_buffer_length();
for (size_t i = 0; i < buffer_length; i += 3) {
this->buffer_[i + 0] = byte_1;
this->buffer_[i + 1] = byte_2;
this->buffer_[i + 2] = byte_3;
}
}
uint32_t EPaperSpectraE6::get_buffer_length() {
// 6 colors buffer, 1 pixel = 3 bits, we will store 8 pixels in 24 bits = 3 bytes
return this->get_width_controller() * this->get_height_internal() / 8u * 3u;
}
void HOT EPaperSpectraE6::draw_absolute_pixel_internal(int x, int y, Color color) {
if (x >= this->get_width_internal() || y >= this->get_height_internal() || x < 0 || y < 0)
return;
uint8_t pixel_bits = color_to_hex(color);
uint32_t pixel_position = x + y * this->get_width_controller();
uint32_t first_bit_position = pixel_position * 3;
uint32_t byte_position = first_bit_position / 8u;
uint32_t byte_subposition = first_bit_position % 8u;
if (byte_subposition <= 5) {
this->buffer_[byte_position] = (this->buffer_[byte_position] & (0xFF ^ (0b111 << (5 - byte_subposition)))) |
(pixel_bits << (5 - byte_subposition));
} else {
this->buffer_[byte_position] = (this->buffer_[byte_position] & (0xFF ^ (0b111 >> (byte_subposition - 5)))) |
(pixel_bits >> (byte_subposition - 5));
this->buffer_[byte_position + 1] =
(this->buffer_[byte_position + 1] & (0xFF ^ (0xFF & (0b111 << (13 - byte_subposition))))) |
(pixel_bits << (13 - byte_subposition));
}
}
bool HOT EPaperSpectraE6::transfer_data() {
const uint32_t start_time = App.get_loop_component_start_time();
if (this->current_data_index_ == 0) {
ESP_LOGV(TAG, "Sending data");
this->command(0x10);
}
uint8_t bytes_to_send[4]{0};
const size_t buffer_length = this->get_buffer_length();
for (size_t i = this->current_data_index_; i < buffer_length; i += 3) {
const uint32_t triplet = encode_uint24(this->buffer_[i + 0], this->buffer_[i + 1], this->buffer_[i + 2]);
// 8 pixels are stored in 3 bytes
// |aaabbbaa|abbbaaab|bbaaabbb|
// | byte 1 | byte 2 | byte 3 |
bytes_to_send[0] = ((triplet >> 17) & 0b01110000) | ((triplet >> 18) & 0b00000111);
bytes_to_send[1] = ((triplet >> 11) & 0b01110000) | ((triplet >> 12) & 0b00000111);
bytes_to_send[2] = ((triplet >> 5) & 0b01110000) | ((triplet >> 6) & 0b00000111);
bytes_to_send[3] = ((triplet << 1) & 0b01110000) | ((triplet << 0) & 0b00000111);
this->start_data_();
this->write_array(bytes_to_send, sizeof(bytes_to_send));
this->end_data_();
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->current_data_index_ = i + 3;
return false;
}
}
// Finished the entire dataset
this->current_data_index_ = 0;
return true;
}
void EPaperSpectraE6::reset() {
if (this->reset_pin_ != nullptr) {
this->disable_loop();
this->reset_pin_->digital_write(true);
this->set_timeout(20, [this] {
this->reset_pin_->digital_write(false);
delay(2);
this->reset_pin_->digital_write(true);
this->set_timeout(20, [this] { this->enable_loop(); });
});
}
}
} // namespace esphome::epaper_spi

View File

@@ -0,0 +1,23 @@
#pragma once
#include "epaper_spi.h"
namespace esphome::epaper_spi {
class EPaperSpectraE6 : public EPaperBase {
public:
EPaperSpectraE6(const uint8_t *init_sequence, const size_t init_sequence_length)
: EPaperBase(init_sequence, init_sequence_length) {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
void fill(Color color) override;
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override;
uint32_t get_buffer_length() override;
bool transfer_data() override;
void reset() override;
};
} // namespace esphome::epaper_spi

View File

@@ -304,6 +304,17 @@ def _format_framework_espidf_version(ver: cv.Version, release: str) -> str:
return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}/esp-idf-v{str(ver)}.zip"
def _is_framework_url(source: str) -> str:
# platformio accepts many URL schemes for framework repositories and archives including http, https, git, file, and symlink
import urllib.parse
try:
parsed = urllib.parse.urlparse(source)
except ValueError:
return False
return bool(parsed.scheme)
# NOTE: Keep this in mind when updating the recommended version:
# * New framework historically have had some regressions, especially for WiFi.
# The new version needs to be thoroughly validated before changing the
@@ -314,11 +325,12 @@ def _format_framework_espidf_version(ver: cv.Version, release: str) -> str:
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(3, 2, 1),
"latest": cv.Version(3, 3, 1),
"dev": cv.Version(3, 3, 1),
"latest": cv.Version(3, 3, 2),
"dev": cv.Version(3, 3, 2),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(3, 3, 1): cv.Version(55, 3, 31),
cv.Version(3, 3, 2): cv.Version(55, 3, 31, "1"),
cv.Version(3, 3, 1): cv.Version(55, 3, 31, "1"),
cv.Version(3, 3, 0): cv.Version(55, 3, 30, "2"),
cv.Version(3, 2, 1): cv.Version(54, 3, 21, "2"),
cv.Version(3, 2, 0): cv.Version(54, 3, 20),
@@ -336,8 +348,8 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"dev": cv.Version(5, 5, 1),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(5, 5, 1): cv.Version(55, 3, 31),
cv.Version(5, 5, 0): cv.Version(55, 3, 31),
cv.Version(5, 5, 1): cv.Version(55, 3, 31, "1"),
cv.Version(5, 5, 0): cv.Version(55, 3, 31, "1"),
cv.Version(5, 4, 2): cv.Version(54, 3, 21, "2"),
cv.Version(5, 4, 1): cv.Version(54, 3, 21, "2"),
cv.Version(5, 4, 0): cv.Version(54, 3, 21, "2"),
@@ -352,8 +364,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
# - https://github.com/pioarduino/platform-espressif32/releases
PLATFORM_VERSION_LOOKUP = {
"recommended": cv.Version(54, 3, 21, "2"),
"latest": cv.Version(55, 3, 31),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
"latest": cv.Version(55, 3, 31, "1"),
"dev": cv.Version(55, 3, 31, "1"),
}
@@ -386,6 +398,10 @@ def _check_versions(value):
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
)
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = (
f"pioarduino/framework-arduinoespressif32@{value[CONF_SOURCE]}"
)
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
@@ -395,6 +411,8 @@ def _check_versions(value):
CONF_SOURCE,
_format_framework_espidf_version(version, value.get(CONF_RELEASE, None)),
)
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"pioarduino/framework-espidf@{value[CONF_SOURCE]}"
if CONF_PLATFORM_VERSION not in value:
if platform_lookup is None:
@@ -639,6 +657,7 @@ def _show_framework_migration_message(name: str, variant: str) -> None:
+ "Why change? ESP-IDF offers:\n"
+ color(AnsiFore.GREEN, " ✨ Up to 40% smaller binaries\n")
+ color(AnsiFore.GREEN, " 🚀 Better performance and optimization\n")
+ color(AnsiFore.GREEN, " ⚡ 2-3x faster compile times\n")
+ color(AnsiFore.GREEN, " 📦 Custom-built firmware for your exact needs\n")
+ color(
AnsiFore.GREEN,
@@ -646,7 +665,6 @@ def _show_framework_migration_message(name: str, variant: str) -> None:
)
+ "\n"
+ "Trade-offs:\n"
+ color(AnsiFore.YELLOW, " ⏱️ Compile times are ~25% longer\n")
+ color(AnsiFore.YELLOW, " 🔄 Some components need migration\n")
+ "\n"
+ "What should I do?\n"

View File

@@ -285,6 +285,10 @@ def consume_connection_slots(
def validate_connection_slots(max_connections: int) -> None:
"""Validate that BLE connection slots don't exceed the configured maximum."""
# Skip validation in testing mode to allow component grouping
if CORE.testing_mode:
return
ble_data = CORE.data.get(KEY_ESP32_BLE, {})
used_slots = ble_data.get(KEY_USED_CONNECTION_SLOTS, [])
num_used = len(used_slots)
@@ -332,12 +336,16 @@ def final_validation(config):
# Check if BLE Server is needed
has_ble_server = "esp32_ble_server" in full_config
add_idf_sdkconfig_option("CONFIG_BT_GATTS_ENABLE", has_ble_server)
# Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client)
has_ble_client = (
"esp32_ble_tracker" in full_config or "esp32_ble_client" in full_config
)
# ESP-IDF BLE stack requires GATT Server to be enabled when GATT Client is enabled
# This is an internal dependency in the Bluedroid stack (tested ESP-IDF 5.4.2-5.5.1)
# See: https://github.com/espressif/esp-idf/issues/17724
add_idf_sdkconfig_option("CONFIG_BT_GATTS_ENABLE", has_ble_server or has_ble_client)
add_idf_sdkconfig_option("CONFIG_BT_GATTC_ENABLE", has_ble_client)
# Handle max_connections: check for deprecated location in esp32_ble_tracker

View File

@@ -217,8 +217,11 @@ bool ESP32BLE::ble_setup_() {
if (this->name_.has_value()) {
name = this->name_.value();
if (App.is_name_add_mac_suffix_enabled()) {
name += "-";
name += get_mac_address().substr(6);
// MAC address suffix length (last 6 characters of 12-char MAC address string)
constexpr size_t mac_address_suffix_len = 6;
const std::string mac_addr = get_mac_address();
const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len;
name = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len);
}
} else {
name = App.get_name();

View File

@@ -15,10 +15,6 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#ifdef USE_ARDUINO
#include <esp32-hal-bt.h>
#endif
namespace esphome {
namespace esp32_ble_beacon {

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from esphome import automation
@@ -52,9 +53,19 @@ class BLEFeatures(StrEnum):
ESP_BT_DEVICE = "ESP_BT_DEVICE"
# Dataclass for registration counts
@dataclass
class RegistrationCounts:
listeners: int = 0
clients: int = 0
# Set to track which features are needed by components
_required_features: set[BLEFeatures] = set()
# Track registration counts for StaticVector sizing
_registration_counts = RegistrationCounts()
def register_ble_features(features: set[BLEFeatures]) -> None:
"""Register BLE features that a component needs.
@@ -257,12 +268,14 @@ async def to_code(config):
register_ble_features({BLEFeatures.ESP_BT_DEVICE})
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
_registration_counts.listeners += 1
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if CONF_MAC_ADDRESS in conf:
addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]]
cg.add(trigger.set_addresses(addr_list))
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []):
_registration_counts.listeners += 1
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format):
cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID])))
@@ -275,6 +288,7 @@ async def to_code(config):
cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex))
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []):
_registration_counts.listeners += 1
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format):
cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID])))
@@ -287,6 +301,7 @@ async def to_code(config):
cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex))
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
for conf in config.get(CONF_ON_SCAN_END, []):
_registration_counts.listeners += 1
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
@@ -320,6 +335,17 @@ async def _add_ble_features():
cg.add_define("USE_ESP32_BLE_DEVICE")
cg.add_define("USE_ESP32_BLE_UUID")
# Add defines for StaticVector sizing based on registration counts
# Only define if count > 0 to avoid allocating unnecessary memory
if _registration_counts.listeners > 0:
cg.add_define(
"ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", _registration_counts.listeners
)
if _registration_counts.clients > 0:
cg.add_define(
"ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", _registration_counts.clients
)
ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema(
{
@@ -369,6 +395,7 @@ async def register_ble_device(
var: cg.SafeExpType, config: ConfigType
) -> cg.SafeExpType:
register_ble_features({BLEFeatures.ESP_BT_DEVICE})
_registration_counts.listeners += 1
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_listener(var))
return var
@@ -376,6 +403,7 @@ async def register_ble_device(
async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType:
register_ble_features({BLEFeatures.ESP_BT_DEVICE})
_registration_counts.clients += 1
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_client(var))
return var
@@ -389,6 +417,7 @@ async def register_raw_ble_device(
This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice
will not be compiled in if this is the only registration method used.
"""
_registration_counts.listeners += 1
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_listener(var))
return var
@@ -402,6 +431,7 @@ async def register_raw_client(
This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice
will not be compiled in if this is the only registration method used.
"""
_registration_counts.clients += 1
paren = await cg.get_variable(config[CONF_ESP32_BLE_ID])
cg.add(paren.register_client(var))
return var

View File

@@ -25,10 +25,6 @@
#include <esp_coexist.h>
#endif
#ifdef USE_ARDUINO
#include <esp32-hal-bt.h>
#endif
#define MBEDTLS_AES_ALT
#include <aes_alt.h>
@@ -78,9 +74,11 @@ void ESP32BLETracker::setup() {
[this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
this->stop_scan();
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->disconnect();
}
#endif
}
});
#endif
@@ -210,8 +208,10 @@ void ESP32BLETracker::start_scan_(bool first) {
this->set_scanner_state_(ScannerState::STARTING);
ESP_LOGD(TAG, "Starting scan, set scanner state to STARTING.");
if (!first) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
#endif
}
#ifdef USE_ESP32_BLE_DEVICE
this->already_discovered_.clear();
@@ -240,20 +240,25 @@ void ESP32BLETracker::start_scan_(bool first) {
}
void ESP32BLETracker::register_client(ESPBTClient *client) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
client->app_id = ++this->app_id_;
this->clients_.push_back(client);
this->recalculate_advertisement_parser_types();
#endif
}
void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
listener->set_parent(this);
this->listeners_.push_back(listener);
this->recalculate_advertisement_parser_types();
#endif
}
void ESP32BLETracker::recalculate_advertisement_parser_types() {
this->raw_advertisements_ = false;
this->parse_advertisements_ = false;
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
this->parse_advertisements_ = true;
@@ -261,6 +266,8 @@ void ESP32BLETracker::recalculate_advertisement_parser_types() {
this->raw_advertisements_ = true;
}
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) {
this->parse_advertisements_ = true;
@@ -268,6 +275,7 @@ void ESP32BLETracker::recalculate_advertisement_parser_types() {
this->raw_advertisements_ = true;
}
}
#endif
}
void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
@@ -286,10 +294,12 @@ void ESP32BLETracker::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_ga
default:
break;
}
// Forward all events to clients (scan results are handled separately via gap_scan_event_handler)
// Forward all events to clients (scan results are handled separately via gap_scan_event_handler)
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->gap_event_handler(event, param);
}
#endif
}
void ESP32BLETracker::gap_scan_event_handler(const BLEScanResult &scan_result) {
@@ -352,9 +362,11 @@ void ESP32BLETracker::gap_scan_stop_complete_(const esp_ble_gap_cb_param_t::ble_
void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->gattc_event_handler(event, gattc_if, param);
}
#endif
}
void ESP32BLETracker::set_scanner_state_(ScannerState state) {
@@ -708,12 +720,16 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const {
void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
// Process raw advertisements
if (this->raw_advertisements_) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
listener->parse_devices(&scan_result, 1);
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->parse_devices(&scan_result, 1);
}
#endif
}
// Process parsed advertisements
@@ -723,16 +739,20 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) {
device.parse_scan_rst(scan_result);
bool found = false;
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_) {
if (listener->parse_device(device))
found = true;
}
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
if (client->parse_device(device)) {
found = true;
}
}
#endif
if (!found && !this->scan_continuous_) {
this->print_bt_device_info(device);
@@ -749,8 +769,10 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
// Reset timeout state machine instead of cancelling scheduler timeout
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
#endif
this->set_scanner_state_(ScannerState::IDLE);
}
@@ -774,6 +796,7 @@ void ESP32BLETracker::handle_scanner_failure_() {
void ESP32BLETracker::try_promote_discovered_clients_() {
// Only promote the first discovered client to avoid multiple simultaneous connections
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
if (client->state() != ClientState::DISCOVERED) {
continue;
@@ -795,6 +818,7 @@ void ESP32BLETracker::try_promote_discovered_clients_() {
client->connect();
break;
}
#endif
}
const char *ESP32BLETracker::scanner_state_to_string_(ScannerState state) const {

View File

@@ -302,6 +302,7 @@ class ESP32BLETracker : public Component,
/// Count clients in each state
ClientStateCounts count_client_states_() const {
ClientStateCounts counts;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
switch (client->state()) {
case ClientState::DISCONNECTING:
@@ -317,12 +318,17 @@ class ESP32BLETracker : public Component,
break;
}
}
#endif
return counts;
}
// Group 1: Large objects (12+ bytes) - vectors and callback manager
std::vector<ESPBTDeviceListener *> listeners_;
std::vector<ESPBTClient *> clients_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
StaticVector<ESPBTDeviceListener *, ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT> listeners_;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
StaticVector<ESPBTClient *, ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT> clients_;
#endif
CallbackManager<void(ScannerState)> scanner_state_callbacks_;
#ifdef USE_ESP32_BLE_DEVICE
/// Vector of addresses that have already been printed in print_bt_device_info

View File

@@ -143,6 +143,7 @@ void ESP32ImprovComponent::loop() {
#else
this->set_state_(improv::STATE_AUTHORIZED);
#endif
this->check_wifi_connection_();
break;
}
case improv::STATE_AUTHORIZED: {
@@ -156,31 +157,12 @@ void ESP32ImprovComponent::loop() {
if (!this->check_identify_()) {
this->set_status_indicator_state_((now % 1000) < 500);
}
this->check_wifi_connection_();
break;
}
case improv::STATE_PROVISIONING: {
this->set_status_indicator_state_((now % 200) < 100);
if (wifi::global_wifi_component->is_connected()) {
wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(),
this->connecting_sta_.get_password());
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
this->set_state_(improv::STATE_PROVISIONED);
std::vector<std::string> urls = {ESPHOME_MY_LINK};
#ifdef USE_WEBSERVER
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
if (ip.is_ip4()) {
std::string webserver_url = "http://" + ip.str() + ":" + to_string(USE_WEBSERVER_PORT);
urls.push_back(webserver_url);
break;
}
}
#endif
std::vector<uint8_t> data = improv::build_rpc_response(improv::WIFI_SETTINGS, urls);
this->send_response_(data);
this->stop();
}
this->check_wifi_connection_();
break;
}
case improv::STATE_PROVISIONED: {
@@ -392,6 +374,36 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() {
wifi::global_wifi_component->clear_sta();
}
void ESP32ImprovComponent::check_wifi_connection_() {
if (!wifi::global_wifi_component->is_connected()) {
return;
}
if (this->state_ == improv::STATE_PROVISIONING) {
wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password());
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
std::vector<std::string> urls = {ESPHOME_MY_LINK};
#ifdef USE_WEBSERVER
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
if (ip.is_ip4()) {
std::string webserver_url = "http://" + ip.str() + ":" + to_string(USE_WEBSERVER_PORT);
urls.push_back(webserver_url);
break;
}
}
#endif
std::vector<uint8_t> data = improv::build_rpc_response(improv::WIFI_SETTINGS, urls);
this->send_response_(data);
} else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
ESP_LOGD(TAG, "WiFi provisioned externally");
}
this->set_state_(improv::STATE_PROVISIONED);
this->stop();
}
void ESP32ImprovComponent::advertise_service_data_() {
uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {};
service_data[0] = IMPROV_PROTOCOL_ID_1; // PR

View File

@@ -111,6 +111,7 @@ class ESP32ImprovComponent : public Component {
void send_response_(std::vector<uint8_t> &response);
void process_incoming_data_();
void on_wifi_connect_timeout_();
void check_wifi_connection_();
bool check_identify_();
void advertise_service_data_();
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG

View File

@@ -29,7 +29,7 @@ namespace esphome {
static const char *const TAG = "esphome.ota";
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 10000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
#ifdef USE_OTA_PASSWORD

View File

@@ -691,7 +691,9 @@ void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_
std::string EthernetComponent::get_use_address() const {
if (this->use_address_.empty()) {
return App.get_name() + ".local";
// ".local" suffix length for mDNS hostnames
constexpr size_t mdns_local_suffix_len = 5;
return make_name_with_suffix(App.get_name(), '.', "local", mdns_local_suffix_len);
}
return this->use_address_;
}

View File

@@ -167,8 +167,8 @@ class HttpRequestComponent : public Component {
}
protected:
virtual std::shared_ptr<HttpContainer> perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
virtual std::shared_ptr<HttpContainer> perform(const std::string &url, const std::string &method,
const std::string &body, const std::list<Header> &request_headers,
std::set<std::string> collect_headers) = 0;
const char *useragent_{nullptr};
bool follow_redirects_{};

View File

@@ -14,8 +14,9 @@ namespace http_request {
static const char *const TAG = "http_request.arduino";
std::shared_ptr<HttpContainer> HttpRequestArduino::perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &url, const std::string &method,
const std::string &body,
const std::list<Header> &request_headers,
std::set<std::string> collect_headers) {
if (!network::is_connected()) {
this->status_momentary_error("failed", 1000);

View File

@@ -31,8 +31,8 @@ class HttpContainerArduino : public HttpContainer {
class HttpRequestArduino : public HttpRequestComponent {
protected:
std::shared_ptr<HttpContainer> perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
std::shared_ptr<HttpContainer> perform(const std::string &url, const std::string &method, const std::string &body,
const std::list<Header> &request_headers,
std::set<std::string> collect_headers) override;
};

View File

@@ -17,8 +17,9 @@ namespace http_request {
static const char *const TAG = "http_request.host";
std::shared_ptr<HttpContainer> HttpRequestHost::perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
std::shared_ptr<HttpContainer> HttpRequestHost::perform(const std::string &url, const std::string &method,
const std::string &body,
const std::list<Header> &request_headers,
std::set<std::string> response_headers) {
if (!network::is_connected()) {
this->status_momentary_error("failed", 1000);

View File

@@ -18,8 +18,8 @@ class HttpContainerHost : public HttpContainer {
class HttpRequestHost : public HttpRequestComponent {
public:
std::shared_ptr<HttpContainer> perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
std::shared_ptr<HttpContainer> perform(const std::string &url, const std::string &method, const std::string &body,
const std::list<Header> &request_headers,
std::set<std::string> response_headers) override;
void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; }

View File

@@ -52,8 +52,9 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
return ESP_OK;
}
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, const std::string &method,
const std::string &body,
const std::list<Header> &request_headers,
std::set<std::string> collect_headers) {
if (!network::is_connected()) {
this->status_momentary_error("failed", 1000);

View File

@@ -37,8 +37,8 @@ class HttpRequestIDF : public HttpRequestComponent {
void set_buffer_size_tx(uint16_t buffer_size_tx) { this->buffer_size_tx_ = buffer_size_tx; }
protected:
std::shared_ptr<HttpContainer> perform(std::string url, std::string method, std::string body,
std::list<Header> request_headers,
std::shared_ptr<HttpContainer> perform(const std::string &url, const std::string &method, const std::string &body,
const std::list<Header> &request_headers,
std::set<std::string> collect_headers) override;
// if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE
uint16_t buffer_size_rx_{};

View File

@@ -8,6 +8,13 @@ namespace json {
static const char *const TAG = "json";
#ifdef USE_PSRAM
// Global allocator that outlives all JsonDocuments returned by parse_json()
// This prevents dangling pointer issues when JsonDocuments are returned from functions
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - Must be mutable for ArduinoJson::Allocator
static SpiRamAllocator global_json_allocator;
#endif
std::string build_json(const json_build_t &f) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
JsonBuilder builder;
@@ -33,8 +40,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) {
return JsonObject(); // return unbound object
}
#ifdef USE_PSRAM
auto doc_allocator = SpiRamAllocator();
JsonDocument json_document(&doc_allocator);
JsonDocument json_document(&global_json_allocator);
#else
JsonDocument json_document;
#endif

View File

@@ -21,11 +21,11 @@ template<uint8_t N> class MCP23XXXBase : public Component, public gpio_expander:
protected:
// read a given register
virtual bool read_reg(uint8_t reg, uint8_t *value);
virtual bool read_reg(uint8_t reg, uint8_t *value) = 0;
// write a value to a given register
virtual bool write_reg(uint8_t reg, uint8_t value);
virtual bool write_reg(uint8_t reg, uint8_t value) = 0;
// update registers with given pin value.
virtual void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a);
virtual void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) = 0;
bool open_drain_ints_;
};

View File

@@ -1,6 +1,6 @@
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component
from esphome.config_helpers import filter_source_files_from_platform
from esphome.config_helpers import filter_source_files_from_platform, get_logger_level
import esphome.config_validation as cv
from esphome.const import (
CONF_DISABLED,
@@ -11,7 +11,7 @@ from esphome.const import (
CONF_SERVICES,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.core import CORE, Lambda, coroutine_with_priority
from esphome.coroutine import CoroPriority
CODEOWNERS = ["@esphome/core"]
@@ -58,26 +58,84 @@ CONFIG_SCHEMA = cv.All(
)
def mdns_txt_record(key: str, value: str):
return cg.StructInitializer(
MDNSTXTRecord,
("key", key),
("value", value),
def mdns_txt_record(key: str, value: str) -> cg.RawExpression:
"""Create a mDNS TXT record.
Public API for external components. Do not remove.
Args:
key: The TXT record key
value: The TXT record value (static string only)
Returns:
A RawExpression representing a MDNSTXTRecord struct
"""
return cg.RawExpression(
f"{{MDNS_STR({cg.safe_exp(key)}), MDNS_STR({cg.safe_exp(value)})}}"
)
async def _mdns_txt_record_templated(
mdns_comp: cg.Pvariable, key: str, value: Lambda | str
) -> cg.RawExpression:
"""Create a mDNS TXT record with support for templated values.
Internal helper function.
Args:
mdns_comp: The MDNSComponent instance (from cg.get_variable())
key: The TXT record key
value: The TXT record value (can be a static string or a lambda template)
Returns:
A RawExpression representing a MDNSTXTRecord struct
"""
if not cg.is_template(value):
# It's a static string - use directly in flash, no need to store in vector
return mdns_txt_record(key, value)
# It's a lambda - evaluate and store using helper
templated_value = await cg.templatable(value, [], cg.std_string)
safe_key = cg.safe_exp(key)
dynamic_call = f"{mdns_comp}->add_dynamic_txt_value(({templated_value})())"
return cg.RawExpression(f"{{MDNS_STR({safe_key}), MDNS_STR({dynamic_call})}}")
def mdns_service(
service: str, proto: str, port: int, txt_records: list[dict[str, str]]
):
service: str, proto: str, port: int, txt_records: list[cg.RawExpression]
) -> cg.StructInitializer:
"""Create a mDNS service.
Public API for external components. Do not remove.
Args:
service: Service name (e.g., "_http")
proto: Protocol (e.g., "_tcp" or "_udp")
port: Port number
txt_records: List of MDNSTXTRecord expressions
Returns:
A StructInitializer representing a MDNSService struct
"""
return cg.StructInitializer(
MDNSService,
("service_type", service),
("proto", proto),
("service_type", cg.RawExpression(f"MDNS_STR({cg.safe_exp(service)})")),
("proto", cg.RawExpression(f"MDNS_STR({cg.safe_exp(proto)})")),
("port", port),
("txt_records", txt_records),
)
def enable_mdns_storage():
"""Enable persistent storage of mDNS services in the MDNSComponent.
Called by external components (like OpenThread) that need access to
services after setup() completes via get_services().
Public API for external components. Do not remove.
"""
cg.add_define("USE_MDNS_STORE_SERVICES")
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
async def to_code(config):
if config[CONF_DISABLED] is True:
@@ -103,27 +161,47 @@ async def to_code(config):
if config[CONF_SERVICES]:
cg.add_define("USE_MDNS_EXTRA_SERVICES")
# Extra services need to be stored persistently
enable_mdns_storage()
# Ensure at least 1 service (fallback service)
cg.add_define("MDNS_SERVICE_COUNT", max(1, service_count))
# Calculate compile-time dynamic TXT value count
# Dynamic values are those that cannot be stored in flash at compile time
dynamic_txt_count = 0
if "api" in CORE.config:
# Always: get_mac_address()
dynamic_txt_count += 1
# User-provided templatable TXT values (only lambdas, not static strings)
dynamic_txt_count += sum(
1
for service in config[CONF_SERVICES]
for txt_value in service[CONF_TXT].values()
if cg.is_template(txt_value)
)
# Ensure at least 1 to avoid zero-size array
cg.add_define("MDNS_DYNAMIC_TXT_COUNT", max(1, dynamic_txt_count))
# Enable storage if verbose logging is enabled (for dump_config)
if get_logger_level() in ("VERBOSE", "VERY_VERBOSE"):
enable_mdns_storage()
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for service in config[CONF_SERVICES]:
txt = [
cg.StructInitializer(
MDNSTXTRecord,
("key", txt_key),
("value", await cg.templatable(txt_value, [], cg.std_string)),
)
txt_records = [
await _mdns_txt_record_templated(var, txt_key, txt_value)
for txt_key, txt_value in service[CONF_TXT].items()
]
exp = mdns_service(
service[CONF_SERVICE],
service[CONF_PROTOCOL],
await cg.templatable(service[CONF_PORT], [], cg.uint16),
txt,
txt_records,
)
cg.add(var.add_extra_service(exp))

View File

@@ -9,24 +9,9 @@
#include <pgmspace.h>
// Macro to define strings in PROGMEM on ESP8266, regular memory on other platforms
#define MDNS_STATIC_CONST_CHAR(name, value) static const char name[] PROGMEM = value
// Helper to get string from PROGMEM - returns a temporary std::string
// Only define this function if we have services that will use it
#if defined(USE_API) || defined(USE_PROMETHEUS) || defined(USE_WEBSERVER) || defined(USE_MDNS_EXTRA_SERVICES)
static std::string mdns_string_p(const char *src) {
char buf[64];
strncpy_P(buf, src, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
return std::string(buf);
}
#define MDNS_STR(name) mdns_string_p(name)
#else
// If no services are configured, we still need the fallback service but it uses string literals
#define MDNS_STR(name) std::string(name)
#endif
#else
// On non-ESP8266 platforms, use regular const char*
#define MDNS_STATIC_CONST_CHAR(name, value) static constexpr const char *name = value
#define MDNS_STR(name) name
#define MDNS_STATIC_CONST_CHAR(name, value) static constexpr const char name[] = value
#endif
#ifdef USE_API
@@ -46,40 +31,29 @@ static const char *const TAG = "mdns";
#endif
// Define all constant strings using the macro
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp");
MDNS_STATIC_CONST_CHAR(SERVICE_PROMETHEUS, "_prometheus-http");
MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http");
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network");
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption");
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported");
MDNS_STATIC_CONST_CHAR(TXT_PROJECT_NAME, "project_name");
MDNS_STATIC_CONST_CHAR(TXT_PROJECT_VERSION, "project_version");
MDNS_STATIC_CONST_CHAR(TXT_PACKAGE_IMPORT_URL, "package_import_url");
// Wrap build-time defines into flash storage
MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION);
MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266");
MDNS_STATIC_CONST_CHAR(PLATFORM_ESP32, "ESP32");
MDNS_STATIC_CONST_CHAR(PLATFORM_RP2040, "RP2040");
MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi");
MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet");
MDNS_STATIC_CONST_CHAR(NETWORK_THREAD, "thread");
void MDNSComponent::compile_records_() {
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services) {
this->hostname_ = App.get_name();
// IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES
// in mdns/__init__.py. If you add a new service here, update both locations.
#ifdef USE_API
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network");
MDNS_STATIC_CONST_CHAR(VALUE_BOARD, ESPHOME_BOARD);
if (api::global_api_server != nullptr) {
auto &service = this->services_.emplace_next();
auto &service = services.emplace_next();
service.service_type = MDNS_STR(SERVICE_ESPHOMELIB);
service.proto = MDNS_STR(SERVICE_TCP);
service.port = api::global_api_server->get_port();
@@ -112,73 +86,97 @@ void MDNSComponent::compile_records_() {
txt_records.reserve(txt_count);
if (!friendly_name_empty) {
txt_records.push_back({MDNS_STR(TXT_FRIENDLY_NAME), friendly_name});
txt_records.push_back({MDNS_STR(TXT_FRIENDLY_NAME), MDNS_STR(friendly_name.c_str())});
}
txt_records.push_back({MDNS_STR(TXT_VERSION), ESPHOME_VERSION});
txt_records.push_back({MDNS_STR(TXT_MAC), get_mac_address()});
txt_records.push_back({MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)});
txt_records.push_back({MDNS_STR(TXT_MAC), MDNS_STR(this->add_dynamic_txt_value(get_mac_address()))});
#ifdef USE_ESP8266
MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266");
txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_ESP8266)});
#elif defined(USE_ESP32)
MDNS_STATIC_CONST_CHAR(PLATFORM_ESP32, "ESP32");
txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_ESP32)});
#elif defined(USE_RP2040)
MDNS_STATIC_CONST_CHAR(PLATFORM_RP2040, "RP2040");
txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(PLATFORM_RP2040)});
#elif defined(USE_LIBRETINY)
txt_records.emplace_back(MDNSTXTRecord{"platform", lt_cpu_get_model_name()});
txt_records.push_back({MDNS_STR(TXT_PLATFORM), MDNS_STR(lt_cpu_get_model_name())});
#endif
txt_records.push_back({MDNS_STR(TXT_BOARD), ESPHOME_BOARD});
txt_records.push_back({MDNS_STR(TXT_BOARD), MDNS_STR(VALUE_BOARD)});
#if defined(USE_WIFI)
MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi");
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)});
#elif defined(USE_ETHERNET)
MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet");
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_ETHERNET)});
#elif defined(USE_OPENTHREAD)
MDNS_STATIC_CONST_CHAR(NETWORK_THREAD, "thread");
txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_THREAD)});
#endif
#ifdef USE_API_NOISE
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption");
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported");
MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256");
if (api::global_api_server->get_noise_ctx()->has_psk()) {
txt_records.push_back({MDNS_STR(TXT_API_ENCRYPTION), MDNS_STR(NOISE_ENCRYPTION)});
} else {
txt_records.push_back({MDNS_STR(TXT_API_ENCRYPTION_SUPPORTED), MDNS_STR(NOISE_ENCRYPTION)});
}
bool has_psk = api::global_api_server->get_noise_ctx()->has_psk();
const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)});
#endif
#ifdef ESPHOME_PROJECT_NAME
txt_records.push_back({MDNS_STR(TXT_PROJECT_NAME), ESPHOME_PROJECT_NAME});
txt_records.push_back({MDNS_STR(TXT_PROJECT_VERSION), ESPHOME_PROJECT_VERSION});
MDNS_STATIC_CONST_CHAR(TXT_PROJECT_NAME, "project_name");
MDNS_STATIC_CONST_CHAR(TXT_PROJECT_VERSION, "project_version");
MDNS_STATIC_CONST_CHAR(VALUE_PROJECT_NAME, ESPHOME_PROJECT_NAME);
MDNS_STATIC_CONST_CHAR(VALUE_PROJECT_VERSION, ESPHOME_PROJECT_VERSION);
txt_records.push_back({MDNS_STR(TXT_PROJECT_NAME), MDNS_STR(VALUE_PROJECT_NAME)});
txt_records.push_back({MDNS_STR(TXT_PROJECT_VERSION), MDNS_STR(VALUE_PROJECT_VERSION)});
#endif // ESPHOME_PROJECT_NAME
#ifdef USE_DASHBOARD_IMPORT
txt_records.push_back({MDNS_STR(TXT_PACKAGE_IMPORT_URL), dashboard_import::get_package_import_url()});
MDNS_STATIC_CONST_CHAR(TXT_PACKAGE_IMPORT_URL, "package_import_url");
txt_records.push_back(
{MDNS_STR(TXT_PACKAGE_IMPORT_URL), MDNS_STR(dashboard_import::get_package_import_url().c_str())});
#endif
}
#endif // USE_API
#ifdef USE_PROMETHEUS
auto &prom_service = this->services_.emplace_next();
MDNS_STATIC_CONST_CHAR(SERVICE_PROMETHEUS, "_prometheus-http");
auto &prom_service = services.emplace_next();
prom_service.service_type = MDNS_STR(SERVICE_PROMETHEUS);
prom_service.proto = MDNS_STR(SERVICE_TCP);
prom_service.port = USE_WEBSERVER_PORT;
#endif
#ifdef USE_WEBSERVER
auto &web_service = this->services_.emplace_next();
MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http");
auto &web_service = services.emplace_next();
web_service.service_type = MDNS_STR(SERVICE_HTTP);
web_service.proto = MDNS_STR(SERVICE_TCP);
web_service.port = USE_WEBSERVER_PORT;
#endif
#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_WEBSERVER) && !defined(USE_MDNS_EXTRA_SERVICES)
MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
// Publish "http" service if not using native API or any other services
// This is just to have *some* mDNS service so that .local resolution works
auto &fallback_service = this->services_.emplace_next();
fallback_service.service_type = "_http";
fallback_service.proto = "_tcp";
auto &fallback_service = services.emplace_next();
fallback_service.service_type = MDNS_STR(SERVICE_HTTP);
fallback_service.proto = MDNS_STR(SERVICE_TCP);
fallback_service.port = USE_WEBSERVER_PORT;
fallback_service.txt_records.emplace_back(MDNSTXTRecord{"version", ESPHOME_VERSION});
fallback_service.txt_records.push_back({MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)});
#endif
#ifdef USE_MDNS_STORE_SERVICES
// Copy to member variable if storage is enabled (verbose logging, OpenThread, or extra services)
this->services_ = services;
#endif
}
@@ -187,14 +185,13 @@ void MDNSComponent::dump_config() {
"mDNS:\n"
" Hostname: %s",
this->hostname_.c_str());
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
#ifdef USE_MDNS_STORE_SERVICES
ESP_LOGV(TAG, " Services:");
for (const auto &service : this->services_) {
ESP_LOGV(TAG, " - %s, %s, %d", service.service_type.c_str(), service.proto.c_str(),
ESP_LOGV(TAG, " - %s, %s, %d", MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto),
const_cast<TemplatableValue<uint16_t> &>(service.port).value());
for (const auto &record : service.txt_records) {
ESP_LOGV(TAG, " TXT: %s = %s", record.key.c_str(),
const_cast<TemplatableValue<std::string> &>(record.value).value().c_str());
ESP_LOGV(TAG, " TXT: %s = %s", MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value));
}
}
#endif

View File

@@ -9,21 +9,34 @@
namespace esphome {
namespace mdns {
// Helper struct that identifies strings that may be stored in flash storage (similar to LogString)
struct MDNSString;
// Macro to cast string literals to MDNSString* (works on all platforms)
#define MDNS_STR(name) (reinterpret_cast<const esphome::mdns::MDNSString *>(name))
#ifdef USE_ESP8266
#include <pgmspace.h>
#define MDNS_STR_ARG(s) ((PGM_P) (s))
#else
#define MDNS_STR_ARG(s) (reinterpret_cast<const char *>(s))
#endif
// Service count is calculated at compile time by Python codegen
// MDNS_SERVICE_COUNT will always be defined
struct MDNSTXTRecord {
std::string key;
TemplatableValue<std::string> value;
const MDNSString *key;
const MDNSString *value;
};
struct MDNSService {
// service name _including_ underscore character prefix
// as defined in RFC6763 Section 7
std::string service_type;
const MDNSString *service_type;
// second label indicating protocol _including_ underscore character prefix
// as defined in RFC6763 Section 7, like "_tcp" or "_udp"
std::string proto;
const MDNSString *proto;
TemplatableValue<uint16_t> port;
std::vector<MDNSTXTRecord> txt_records;
};
@@ -42,14 +55,29 @@ class MDNSComponent : public Component {
void add_extra_service(MDNSService service) { this->services_.emplace_next() = std::move(service); }
#endif
#ifdef USE_MDNS_STORE_SERVICES
const StaticVector<MDNSService, MDNS_SERVICE_COUNT> &get_services() const { return this->services_; }
#endif
void on_shutdown() override;
/// Add a dynamic TXT value and return pointer to it for use in MDNSTXTRecord
const char *add_dynamic_txt_value(const std::string &value) {
this->dynamic_txt_values_.push_back(value);
return this->dynamic_txt_values_[this->dynamic_txt_values_.size() - 1].c_str();
}
/// Storage for runtime-generated TXT values (MAC address, user lambdas)
/// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations.
/// Static/compile-time values (version, board, etc.) are stored directly in flash and don't use this.
StaticVector<std::string, MDNS_DYNAMIC_TXT_COUNT> dynamic_txt_values_;
protected:
#ifdef USE_MDNS_STORE_SERVICES
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_{};
#endif
std::string hostname_;
void compile_records_();
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services);
};
} // namespace mdns

View File

@@ -2,7 +2,6 @@
#if defined(USE_ESP32) && defined(USE_MDNS)
#include <mdns.h>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "mdns_component.h"
@@ -13,7 +12,8 @@ namespace mdns {
static const char *const TAG = "mdns";
void MDNSComponent::setup() {
this->compile_records_();
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services;
this->compile_records_(services);
esp_err_t err = mdns_init();
if (err != ESP_OK) {
@@ -25,19 +25,22 @@ void MDNSComponent::setup() {
mdns_hostname_set(this->hostname_.c_str());
mdns_instance_name_set(this->hostname_.c_str());
for (const auto &service : this->services_) {
std::vector<mdns_txt_item_t> txt_records(service.txt_records.size());
for (size_t i = 0; i < service.txt_records.size(); i++) {
// mdns_service_add copies the strings internally, no need to strdup
txt_records[i].key = service.txt_records[i].key.c_str();
txt_records[i].value = const_cast<TemplatableValue<std::string> &>(service.txt_records[i].value).value().c_str();
for (const auto &service : services) {
std::vector<mdns_txt_item_t> txt_records;
for (const auto &record : service.txt_records) {
mdns_txt_item_t it{};
// key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_
// Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies
it.key = MDNS_STR_ARG(record.key);
it.value = MDNS_STR_ARG(record.value);
txt_records.push_back(it);
}
uint16_t port = const_cast<TemplatableValue<uint16_t> &>(service.port).value();
err = mdns_service_add(nullptr, service.service_type.c_str(), service.proto.c_str(), port, txt_records.data(),
txt_records.size());
err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port,
txt_records.data(), txt_records.size());
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to register service %s: %s", service.service_type.c_str(), esp_err_to_name(err));
ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err));
}
}
}

View File

@@ -12,28 +12,29 @@ namespace esphome {
namespace mdns {
void MDNSComponent::setup() {
this->compile_records_();
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services;
this->compile_records_(services);
MDNS.begin(this->hostname_.c_str());
for (const auto &service : this->services_) {
for (const auto &service : services) {
// Strip the leading underscore from the proto and service_type. While it is
// part of the wire protocol to have an underscore, and for example ESP-IDF
// expects the underscore to be there, the ESP8266 implementation always adds
// the underscore itself.
auto *proto = service.proto.c_str();
while (*proto == '_') {
auto *proto = MDNS_STR_ARG(service.proto);
while (progmem_read_byte((const uint8_t *) proto) == '_') {
proto++;
}
auto *service_type = service.service_type.c_str();
while (*service_type == '_') {
auto *service_type = MDNS_STR_ARG(service.service_type);
while (progmem_read_byte((const uint8_t *) service_type) == '_') {
service_type++;
}
uint16_t port = const_cast<TemplatableValue<uint16_t> &>(service.port).value();
MDNS.addService(service_type, proto, port);
MDNS.addService(FPSTR(service_type), FPSTR(proto), port);
for (const auto &record : service.txt_records) {
MDNS.addServiceTxt(service_type, proto, record.key.c_str(),
const_cast<TemplatableValue<std::string> &>(record.value).value().c_str());
MDNS.addServiceTxt(FPSTR(service_type), FPSTR(proto), FPSTR(MDNS_STR_ARG(record.key)),
FPSTR(MDNS_STR_ARG(record.value)));
}
}
}

View File

@@ -9,7 +9,9 @@
namespace esphome {
namespace mdns {
void MDNSComponent::setup() { this->compile_records_(); }
void MDNSComponent::setup() {
// Host platform doesn't have actual mDNS implementation
}
void MDNSComponent::on_shutdown() {}

View File

@@ -12,28 +12,28 @@ namespace esphome {
namespace mdns {
void MDNSComponent::setup() {
this->compile_records_();
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services;
this->compile_records_(services);
MDNS.begin(this->hostname_.c_str());
for (const auto &service : this->services_) {
for (const auto &service : services) {
// Strip the leading underscore from the proto and service_type. While it is
// part of the wire protocol to have an underscore, and for example ESP-IDF
// expects the underscore to be there, the ESP8266 implementation always adds
// the underscore itself.
auto *proto = service.proto.c_str();
auto *proto = MDNS_STR_ARG(service.proto);
while (*proto == '_') {
proto++;
}
auto *service_type = service.service_type.c_str();
auto *service_type = MDNS_STR_ARG(service.service_type);
while (*service_type == '_') {
service_type++;
}
uint16_t port_ = const_cast<TemplatableValue<uint16_t> &>(service.port).value();
MDNS.addService(service_type, proto, port_);
for (const auto &record : service.txt_records) {
MDNS.addServiceTxt(service_type, proto, record.key.c_str(),
const_cast<TemplatableValue<std::string> &>(record.value).value().c_str());
MDNS.addServiceTxt(service_type, proto, MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value));
}
}
}

View File

@@ -12,28 +12,28 @@ namespace esphome {
namespace mdns {
void MDNSComponent::setup() {
this->compile_records_();
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services;
this->compile_records_(services);
MDNS.begin(this->hostname_.c_str());
for (const auto &service : this->services_) {
for (const auto &service : services) {
// Strip the leading underscore from the proto and service_type. While it is
// part of the wire protocol to have an underscore, and for example ESP-IDF
// expects the underscore to be there, the ESP8266 implementation always adds
// the underscore itself.
auto *proto = service.proto.c_str();
auto *proto = MDNS_STR_ARG(service.proto);
while (*proto == '_') {
proto++;
}
auto *service_type = service.service_type.c_str();
auto *service_type = MDNS_STR_ARG(service.service_type);
while (*service_type == '_') {
service_type++;
}
uint16_t port = const_cast<TemplatableValue<uint16_t> &>(service.port).value();
MDNS.addService(service_type, proto, port);
for (const auto &record : service.txt_records) {
MDNS.addServiceTxt(service_type, proto, record.key.c_str(),
const_cast<TemplatableValue<std::string> &>(record.value).value().c_str());
MDNS.addServiceTxt(service_type, proto, MDNS_STR_ARG(record.key), MDNS_STR_ARG(record.value));
}
}
}

View File

@@ -66,7 +66,10 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
uint8_t data_offset = 3;
// Per https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf Ch 5 User-Defined function codes
if (((function_code >= 65) && (function_code <= 72)) || ((function_code >= 100) && (function_code <= 110))) {
if (((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT) &&
(function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_1_END)) ||
((function_code >= FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT) &&
(function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END))) {
// Handle user-defined function, since we don't know how big this ought to be,
// ideally we should delegate the entire length detection to whatever handler is
// installed, but wait, there is the CRC, and if we get a hit there is a good
@@ -91,10 +94,14 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
} else {
// data starts at 2 and length is 4 for read registers commands
if (this->role == ModbusRole::SERVER) {
if (function_code == 0x1 || function_code == 0x3 || function_code == 0x4 || function_code == 0x6) {
if (function_code == ModbusFunctionCode::READ_COILS ||
function_code == ModbusFunctionCode::READ_DISCRETE_INPUTS ||
function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS ||
function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
data_offset = 2;
data_len = 4;
} else if (function_code == 0x10) {
} else if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
if (at < 6) {
return true;
}
@@ -104,7 +111,10 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
}
} else {
// the response for write command mirrors the requests and data starts at offset 2 instead of 3 for read commands
if (function_code == 0x5 || function_code == 0x06 || function_code == 0xF || function_code == 0x10) {
if (function_code == ModbusFunctionCode::WRITE_SINGLE_COIL ||
function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
data_offset = 2;
data_len = 4;
}
@@ -112,7 +122,7 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
// Error ( msb indicates error )
// response format: Byte[0] = device address, Byte[1] function code | 0x80 , Byte[2] exception code, Byte[3-4] crc
if ((function_code & 0x80) == 0x80) {
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
data_offset = 2;
data_len = 1;
}
@@ -143,10 +153,10 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
if (device->address_ == address) {
found = true;
// Is it an error response?
if ((function_code & 0x80) == 0x80) {
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
ESP_LOGD(TAG, "Modbus error function code: 0x%X exception: %d", function_code, raw[2]);
if (waiting_for_response != 0) {
device->on_modbus_error(function_code & 0x7F, raw[2]);
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, raw[2]);
} else {
// Ignore modbus exception not related to a pending command
ESP_LOGD(TAG, "Ignoring Modbus error - not expecting a response");
@@ -154,12 +164,14 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
continue;
}
if (this->role == ModbusRole::SERVER) {
if (function_code == 0x3 || function_code == 0x4) {
if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) {
device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8),
uint16_t(data[3]) | (uint16_t(data[2]) << 8));
continue;
}
if (function_code == 0x6 || function_code == 0x10) {
if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
device->on_modbus_write_registers(function_code, data);
continue;
}
@@ -199,7 +211,7 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address
// Only check max number of registers for standard function codes
// Some devices use non standard codes like 0x43
if (number_of_entities > MAX_VALUES && function_code <= 0x10) {
if (number_of_entities > MAX_VALUES && function_code <= ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
ESP_LOGE(TAG, "send too many values %d max=%zu", number_of_entities, MAX_VALUES);
return;
}
@@ -210,15 +222,17 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address
if (this->role == ModbusRole::CLIENT) {
data.push_back(start_address >> 8);
data.push_back(start_address >> 0);
if (function_code != 0x5 && function_code != 0x6) {
if (function_code != ModbusFunctionCode::WRITE_SINGLE_COIL &&
function_code != ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
data.push_back(number_of_entities >> 8);
data.push_back(number_of_entities >> 0);
}
}
if (payload != nullptr) {
if (this->role == ModbusRole::SERVER || function_code == 0xF || function_code == 0x10) { // Write multiple
data.push_back(payload_len); // Byte count is required for write
if (this->role == ModbusRole::SERVER || function_code == ModbusFunctionCode::WRITE_MULTIPLE_COILS ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { // Write multiple
data.push_back(payload_len); // Byte count is required for write
} else {
payload_len = 2; // Write single register or coil
}

View File

@@ -3,6 +3,8 @@
#include "esphome/core/component.h"
#include "esphome/components/uart/uart.h"
#include "esphome/components/modbus/modbus_definitions.h"
#include <vector>
namespace esphome {
@@ -65,12 +67,12 @@ class ModbusDevice {
this->parent_->send(this->address_, function, start_address, number_of_entities, payload_len, payload);
}
void send_raw(const std::vector<uint8_t> &payload) { this->parent_->send_raw(payload); }
void send_error(uint8_t function_code, uint8_t exception_code) {
void send_error(uint8_t function_code, ModbusExceptionCode exception_code) {
std::vector<uint8_t> error_response;
error_response.reserve(3);
error_response.push_back(this->address_);
error_response.push_back(function_code | 0x80);
error_response.push_back(exception_code);
error_response.push_back(function_code | FUNCTION_CODE_EXCEPTION_MASK);
error_response.push_back(static_cast<uint8_t>(exception_code));
this->send_raw(error_response);
}
// If more than one device is connected block sending a new command before a response is received

View File

@@ -0,0 +1,86 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome {
namespace modbus {
/// Modbus definitions from specs:
/// https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
// 5 Function Code Categories
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_1_INIT = 65; // 0x41
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_1_END = 72; // 0x48
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_INIT = 100; // 0x64
const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E
enum class ModbusFunctionCode : uint8_t {
CUSTOM = 0x00,
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_HOLDING_REGISTERS = 0x03,
READ_INPUT_REGISTERS = 0x04,
WRITE_SINGLE_COIL = 0x05,
WRITE_SINGLE_REGISTER = 0x06,
READ_EXCEPTION_STATUS = 0x07, // not implemented
DIAGNOSTICS = 0x08, // not implemented
GET_COMM_EVENT_COUNTER = 0x0B, // not implemented
GET_COMM_EVENT_LOG = 0x0C, // not implemented
WRITE_MULTIPLE_COILS = 0x0F,
WRITE_MULTIPLE_REGISTERS = 0x10,
REPORT_SERVER_ID = 0x11, // not implemented
READ_FILE_RECORD = 0x14, // not implemented
WRITE_FILE_RECORD = 0x15, // not implemented
MASK_WRITE_REGISTER = 0x16, // not implemented
READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented
READ_FIFO_QUEUE = 0x18, // not implemented
};
/*Allow comparison operators between ModbusFunctionCode and uint8_t*/
inline bool operator==(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) == rhs; }
inline bool operator==(uint8_t lhs, ModbusFunctionCode rhs) { return lhs == static_cast<uint8_t>(rhs); }
inline bool operator!=(ModbusFunctionCode lhs, uint8_t rhs) { return !(static_cast<uint8_t>(lhs) == rhs); }
inline bool operator!=(uint8_t lhs, ModbusFunctionCode rhs) { return !(lhs == static_cast<uint8_t>(rhs)); }
inline bool operator<(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) < rhs; }
inline bool operator<(uint8_t lhs, ModbusFunctionCode rhs) { return lhs < static_cast<uint8_t>(rhs); }
inline bool operator<=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) <= rhs; }
inline bool operator<=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs <= static_cast<uint8_t>(rhs); }
inline bool operator>(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) > rhs; }
inline bool operator>(uint8_t lhs, ModbusFunctionCode rhs) { return lhs > static_cast<uint8_t>(rhs); }
inline bool operator>=(ModbusFunctionCode lhs, uint8_t rhs) { return static_cast<uint8_t>(lhs) >= rhs; }
inline bool operator>=(uint8_t lhs, ModbusFunctionCode rhs) { return lhs >= static_cast<uint8_t>(rhs); }
// 4.3 MODBUS Data model
enum class ModbusRegisterType : uint8_t {
CUSTOM = 0x00,
COIL = 0x01,
DISCRETE_INPUT = 0x02,
HOLDING = 0x03,
READ = 0x04,
};
// 7 MODBUS Exception Responses:
const uint8_t FUNCTION_CODE_MASK = 0x7F;
const uint8_t FUNCTION_CODE_EXCEPTION_MASK = 0x80;
enum class ModbusExceptionCode : uint8_t {
ILLEGAL_FUNCTION = 0x01,
ILLEGAL_DATA_ADDRESS = 0x02,
ILLEGAL_DATA_VALUE = 0x03,
SERVICE_DEVICE_FAILURE = 0x04,
ACKNOWLEDGE = 0x05,
SERVER_DEVICE_BUSY = 0x06,
MEMORY_PARITY_ERROR = 0x08,
GATEWAY_PATH_UNAVAILABLE = 0x0A,
GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND = 0x0B,
};
// 6.12 16 (0x10) Write Multiple registers:
const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B
// 6.3 03 (0x03) Read Holding Registers
// 6.4 04 (0x04) Read Input Registers
const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
/// End of Modbus definitions
} // namespace modbus
} // namespace esphome

View File

@@ -20,6 +20,7 @@ from .const import (
CONF_BYTE_OFFSET,
CONF_COMMAND_THROTTLE,
CONF_CUSTOM_COMMAND,
CONF_ENABLED,
CONF_FORCE_NEW_RANGE,
CONF_MAX_CMD_RETRIES,
CONF_MODBUS_CONTROLLER_ID,
@@ -28,8 +29,11 @@ from .const import (
CONF_ON_OFFLINE,
CONF_ON_ONLINE,
CONF_REGISTER_COUNT,
CONF_REGISTER_LAST_ADDRESS,
CONF_REGISTER_TYPE,
CONF_REGISTER_VALUE,
CONF_RESPONSE_SIZE,
CONF_SERVER_COURTESY_RESPONSE,
CONF_SKIP_UPDATES,
CONF_VALUE_TYPE,
)
@@ -49,6 +53,7 @@ ModbusController = modbus_controller_ns.class_(
)
SensorItem = modbus_controller_ns.struct("SensorItem")
ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse")
ServerRegister = modbus_controller_ns.struct("ServerRegister")
ModbusFunctionCode_ns = modbus_controller_ns.namespace("ModbusFunctionCode")
@@ -143,6 +148,14 @@ ModbusOfflineTrigger = modbus_controller_ns.class_(
_LOGGER = logging.getLogger(__name__)
SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_ENABLED, default=False): cv.boolean,
cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t,
cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t,
}
)
ModbusServerRegisterSchema = cv.Schema(
{
cv.GenerateID(): cv.declare_id(ServerRegister),
@@ -162,6 +175,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_COMMAND_THROTTLE, default="0ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_SERVER_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA,
cv.Optional(CONF_MAX_CMD_RETRIES, default=4): cv.positive_int,
cv.Optional(CONF_OFFLINE_SKIP_UPDATES, default=0): cv.positive_int,
cv.Optional(
@@ -232,7 +246,7 @@ def validate_modbus_register(config):
def _final_validate(config):
if CONF_SERVER_REGISTERS in config:
if CONF_SERVER_COURTESY_RESPONSE in config or CONF_SERVER_REGISTERS in config:
return modbus.final_validate_modbus_device("modbus_controller", role="server")(
config
)
@@ -299,6 +313,20 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS]))
cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE]))
if server_courtesy_response := config.get(CONF_SERVER_COURTESY_RESPONSE):
cg.add(
var.set_server_courtesy_response(
cg.StructInitializer(
ServerCourtesyResponse,
("enabled", server_courtesy_response[CONF_ENABLED]),
(
"register_last_address",
server_courtesy_response[CONF_REGISTER_LAST_ADDRESS],
),
("register_value", server_courtesy_response[CONF_REGISTER_VALUE]),
)
)
)
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
if CONF_SERVER_REGISTERS in config:

View File

@@ -2,6 +2,7 @@ CONF_ALLOW_DUPLICATE_COMMANDS = "allow_duplicate_commands"
CONF_BITMASK = "bitmask"
CONF_BYTE_OFFSET = "byte_offset"
CONF_COMMAND_THROTTLE = "command_throttle"
CONF_ENABLED = "enabled"
CONF_OFFLINE_SKIP_UPDATES = "offline_skip_updates"
CONF_CUSTOM_COMMAND = "custom_command"
CONF_FORCE_NEW_RANGE = "force_new_range"
@@ -13,8 +14,11 @@ CONF_ON_ONLINE = "on_online"
CONF_ON_OFFLINE = "on_offline"
CONF_RAW_ENCODE = "raw_encode"
CONF_REGISTER_COUNT = "register_count"
CONF_REGISTER_LAST_ADDRESS = "register_last_address"
CONF_REGISTER_TYPE = "register_type"
CONF_REGISTER_VALUE = "register_value"
CONF_RESPONSE_SIZE = "response_size"
CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response"
CONF_SKIP_UPDATES = "skip_updates"
CONF_USE_WRITE_MULTIPLE = "use_write_multiple"
CONF_VALUE_TYPE = "value_type"

View File

@@ -112,6 +112,12 @@ void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t
"0x%X.",
this->address_, function_code, start_address, number_of_registers);
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
return;
}
std::vector<uint16_t> sixteen_bit_response;
for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) {
bool found = false;
@@ -136,9 +142,21 @@ void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t
}
if (!found) {
ESP_LOGW(TAG, "Could not match any register to address %02X. Sending exception response.", current_address);
send_error(function_code, 0x02);
return;
if (this->server_courtesy_response_.enabled &&
(current_address <= this->server_courtesy_response_.register_last_address)) {
ESP_LOGD(TAG,
"Could not match any register to address 0x%02X, but default allowed. "
"Returning default value: %d.",
current_address, this->server_courtesy_response_.register_value);
sixteen_bit_response.push_back(this->server_courtesy_response_.register_value);
current_address += 1; // Just increment by 1, as the default response is a single register
} else {
ESP_LOGW(TAG,
"Could not match any register to address 0x%02X and default not allowed. Sending exception response.",
current_address);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
return;
}
}
}
@@ -156,27 +174,27 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st
uint16_t number_of_registers;
uint16_t payload_offset;
if (function_code == 0x10) {
if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8);
if (number_of_registers == 0 || number_of_registers > 0x7B) {
if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) {
ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers);
send_error(function_code, 3);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
uint16_t payload_size = data[4];
if (payload_size != number_of_registers * 2) {
ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.",
payload_size, number_of_registers);
send_error(function_code, 3);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE);
return;
}
payload_offset = 5;
} else if (function_code == 0x06) {
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) {
number_of_registers = 1;
payload_offset = 2;
} else {
ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code);
send_error(function_code, 1);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION);
return;
}
@@ -211,7 +229,7 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st
if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool {
return server_register->write_lambda != nullptr;
})) {
send_error(function_code, 1);
this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION);
return;
}
@@ -220,7 +238,7 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st
int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF);
return server_register->write_lambda(number);
})) {
send_error(function_code, 4);
this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
return;
}
@@ -431,8 +449,15 @@ void ModbusController::dump_config() {
"ModbusController:\n"
" Address: 0x%02X\n"
" Max Command Retries: %d\n"
" Offline Skip Updates: %d",
this->address_, this->max_cmd_retries_, this->offline_skip_updates_);
" Offline Skip Updates: %d\n"
" Server Courtesy Response:\n"
" Enabled: %s\n"
" Register Last Address: 0x%02X\n"
" Register Value: %d",
this->address_, this->max_cmd_retries_, this->offline_skip_updates_,
this->server_courtesy_response_.enabled ? "true" : "false",
this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGCONFIG(TAG, "sensormap");
for (auto &it : this->sensorset_) {

View File

@@ -16,35 +16,9 @@ namespace modbus_controller {
class ModbusController;
enum class ModbusFunctionCode {
CUSTOM = 0x00,
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_HOLDING_REGISTERS = 0x03,
READ_INPUT_REGISTERS = 0x04,
WRITE_SINGLE_COIL = 0x05,
WRITE_SINGLE_REGISTER = 0x06,
READ_EXCEPTION_STATUS = 0x07, // not implemented
DIAGNOSTICS = 0x08, // not implemented
GET_COMM_EVENT_COUNTER = 0x0B, // not implemented
GET_COMM_EVENT_LOG = 0x0C, // not implemented
WRITE_MULTIPLE_COILS = 0x0F,
WRITE_MULTIPLE_REGISTERS = 0x10,
REPORT_SERVER_ID = 0x11, // not implemented
READ_FILE_RECORD = 0x14, // not implemented
WRITE_FILE_RECORD = 0x15, // not implemented
MASK_WRITE_REGISTER = 0x16, // not implemented
READ_WRITE_MULTIPLE_REGISTERS = 0x17, // not implemented
READ_FIFO_QUEUE = 0x18, // not implemented
};
enum class ModbusRegisterType : uint8_t {
CUSTOM = 0x0,
COIL = 0x01,
DISCRETE_INPUT = 0x02,
HOLDING = 0x03,
READ = 0x04,
};
using modbus::ModbusFunctionCode;
using modbus::ModbusRegisterType;
using modbus::ModbusExceptionCode;
enum class SensorValueType : uint8_t {
RAW = 0x00, // variable length
@@ -256,6 +230,12 @@ class SensorItem {
bool force_new_range{false};
};
struct ServerCourtesyResponse {
bool enabled{false};
uint16_t register_last_address{0xFFFF};
uint16_t register_value{0};
};
class ServerRegister {
using ReadLambda = std::function<int64_t()>;
using WriteLambda = std::function<bool(int64_t value)>;
@@ -530,6 +510,12 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice {
void set_max_cmd_retries(uint8_t max_cmd_retries) { this->max_cmd_retries_ = max_cmd_retries; }
/// get how many times a command will be (re)sent if no response is received
uint8_t get_max_cmd_retries() { return this->max_cmd_retries_; }
/// Called by esphome generated code to set the server courtesy response object
void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) {
this->server_courtesy_response_ = server_courtesy_response;
}
/// Get the server courtesy response object
ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; }
protected:
/// parse sensormap_ and create range of sequential addresses
@@ -572,6 +558,9 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice {
CallbackManager<void(int, int)> online_callback_{};
/// Server offline callback
CallbackManager<void(int, int)> offline_callback_{};
/// Server courtesy response
ServerCourtesyResponse server_courtesy_response_{
.enabled = false, .register_last_address = 0xFFFF, .register_value = 0};
};
/** Convert vector<uint8_t> response payload to float.

View File

@@ -29,7 +29,8 @@ static const char *const TAG = "mqtt";
MQTTClientComponent::MQTTClientComponent() {
global_mqtt_client = this;
this->credentials_.client_id = App.get_name() + "-" + get_mac_address();
const std::string mac_addr = get_mac_address();
this->credentials_.client_id = make_name_with_suffix(App.get_name(), '-', mac_addr.c_str(), mac_addr.size());
}
// Connection

View File

@@ -7,7 +7,7 @@
#include "opentherm.h"
#include "esphome/core/helpers.h"
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
#include "driver/timer.h"
#include "esp_err.h"
#endif
@@ -31,7 +31,7 @@ OpenTherm *OpenTherm::instance = nullptr;
OpenTherm::OpenTherm(InternalGPIOPin *in_pin, InternalGPIOPin *out_pin, int32_t device_timeout)
: in_pin_(in_pin),
out_pin_(out_pin),
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
timer_group_(TIMER_GROUP_0),
timer_idx_(TIMER_0),
#endif
@@ -57,7 +57,7 @@ bool OpenTherm::initialize() {
this->out_pin_->setup();
this->out_pin_->digital_write(true);
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
return this->init_esp32_timer_();
#else
return true;
@@ -238,7 +238,7 @@ void IRAM_ATTR OpenTherm::write_bit_(uint8_t high, uint8_t clock) {
}
}
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
bool OpenTherm::init_esp32_timer_() {
// Search for a free timer. Maybe unstable, we'll see.
@@ -365,7 +365,7 @@ void IRAM_ATTR OpenTherm::stop_timer_() {
}
}
#endif // END ESP32
#endif // USE_ESP32
#ifdef ESP8266
// 5 kHz timer_

View File

@@ -12,7 +12,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
#include "driver/timer.h"
#endif
@@ -356,7 +356,7 @@ class OpenTherm {
ISRInternalGPIOPin isr_in_pin_;
ISRInternalGPIOPin isr_out_pin_;
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
timer_group_t timer_group_;
timer_idx_t timer_idx_;
#endif
@@ -370,7 +370,7 @@ class OpenTherm {
int32_t timeout_counter_; // <0 no timeout
int32_t device_timeout_;
#if defined(ESP32) || defined(USE_ESP_IDF)
#ifdef USE_ESP32
esp_err_t timer_error_ = ESP_OK;
TimerErrorType timer_error_type_ = TimerErrorType::NO_TIMER_ERROR;

View File

@@ -5,7 +5,7 @@ from esphome.components.esp32 import (
add_idf_sdkconfig_option,
only_on_variant,
)
from esphome.components.mdns import MDNSComponent
from esphome.components.mdns import MDNSComponent, enable_mdns_storage
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_ENABLE_IPV6, CONF_ID
import esphome.final_validate as fv
@@ -141,6 +141,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
cg.add_define("USE_OPENTHREAD")
# OpenThread SRP needs access to mDNS services after setup
enable_mdns_storage()
ot = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(ot, config)

View File

@@ -155,7 +155,7 @@ void OpenThreadSrpComponent::setup() {
// Set service name
char *string = otSrpClientBuffersGetServiceEntryServiceNameString(entry, &size);
std::string full_service = service.service_type + "." + service.proto;
std::string full_service = std::string(MDNS_STR_ARG(service.service_type)) + "." + MDNS_STR_ARG(service.proto);
if (full_service.size() > size) {
ESP_LOGW(TAG, "Service name too long: %s", full_service.c_str());
continue;
@@ -180,10 +180,12 @@ void OpenThreadSrpComponent::setup() {
entry->mService.mNumTxtEntries = service.txt_records.size();
for (size_t i = 0; i < service.txt_records.size(); i++) {
const auto &txt = service.txt_records[i];
auto value = const_cast<TemplatableValue<std::string> &>(txt.value).value();
txt_entries[i].mKey = strdup(txt.key.c_str());
txt_entries[i].mValue = reinterpret_cast<const uint8_t *>(strdup(value.c_str()));
txt_entries[i].mValueLength = value.size();
// Value is either a compile-time string literal in flash or a pointer to dynamic_txt_values_
// OpenThread SRP client expects the data to persist, so we strdup it
const char *value_str = MDNS_STR_ARG(txt.value);
txt_entries[i].mKey = MDNS_STR_ARG(txt.key);
txt_entries[i].mValue = reinterpret_cast<const uint8_t *>(strdup(value_str));
txt_entries[i].mValueLength = strlen(value_str);
}
entry->mService.mTxtEntries = txt_entries;
entry->mService.mNumTxtEntries = service.txt_records.size();

View File

@@ -40,33 +40,14 @@ class LWIPRawImpl : public Socket {
void init() {
LWIP_LOG("init(%p)", pcb_);
tcp_arg(pcb_, this);
tcp_accept(pcb_, LWIPRawImpl::s_accept_fn);
tcp_recv(pcb_, LWIPRawImpl::s_recv_fn);
tcp_err(pcb_, LWIPRawImpl::s_err_fn);
}
std::unique_ptr<Socket> accept(struct sockaddr *addr, socklen_t *addrlen) override {
if (pcb_ == nullptr) {
errno = EBADF;
return nullptr;
}
if (this->accepted_socket_count_ == 0) {
errno = EWOULDBLOCK;
return nullptr;
}
// Take from front for FIFO ordering
std::unique_ptr<LWIPRawImpl> sock = std::move(this->accepted_sockets_[0]);
// Shift remaining sockets forward
for (uint8_t i = 1; i < this->accepted_socket_count_; i++) {
this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]);
}
this->accepted_socket_count_--;
LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_);
if (addr != nullptr) {
sock->getpeername(addr, addrlen);
}
LWIP_LOG("accept(%p)", sock.get());
return std::unique_ptr<Socket>(std::move(sock));
// Non-listening sockets return error
errno = EINVAL;
return nullptr;
}
int bind(const struct sockaddr *name, socklen_t addrlen) override {
if (pcb_ == nullptr) {
@@ -292,25 +273,10 @@ class LWIPRawImpl : public Socket {
return -1;
}
int listen(int backlog) override {
if (pcb_ == nullptr) {
errno = EBADF;
return -1;
}
LWIP_LOG("tcp_listen_with_backlog(%p backlog=%d)", pcb_, backlog);
struct tcp_pcb *listen_pcb = tcp_listen_with_backlog(pcb_, backlog);
if (listen_pcb == nullptr) {
tcp_abort(pcb_);
pcb_ = nullptr;
errno = EOPNOTSUPP;
return -1;
}
// tcp_listen reallocates the pcb, replace ours
pcb_ = listen_pcb;
// set callbacks on new pcb
LWIP_LOG("tcp_arg(%p)", pcb_);
tcp_arg(pcb_, this);
tcp_accept(pcb_, LWIPRawImpl::s_accept_fn);
return 0;
// Regular sockets can't be converted to listening - this shouldn't happen
// as listen() should only be called on sockets created for listening
errno = EOPNOTSUPP;
return -1;
}
ssize_t read(void *buf, size_t len) override {
if (pcb_ == nullptr) {
@@ -491,29 +457,6 @@ class LWIPRawImpl : public Socket {
return 0;
}
err_t accept_fn(struct tcp_pcb *newpcb, err_t err) {
LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err);
if (err != ERR_OK || newpcb == nullptr) {
// "An error code if there has been an error accepting. Only return ERR_ABRT if you have
// called tcp_abort from within the callback function!"
// https://www.nongnu.org/lwip/2_1_x/tcp_8h.html#a00517abce6856d6c82f0efebdafb734d
// nothing to do here, we just don't push it to the queue
return ERR_OK;
}
// Check if we've reached the maximum accept queue size
if (this->accepted_socket_count_ >= MAX_ACCEPTED_SOCKETS) {
LWIP_LOG("Rejecting connection, queue full (%d)", this->accepted_socket_count_);
// Abort the connection when queue is full
tcp_abort(newpcb);
// Must return ERR_ABRT since we called tcp_abort()
return ERR_ABRT;
}
auto sock = make_unique<LWIPRawImpl>(family_, newpcb);
sock->init();
this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock);
LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_);
return ERR_OK;
}
void err_fn(err_t err) {
LWIP_LOG("err(err=%d)", err);
// "If a connection is aborted because of an error, the application is alerted of this event by
@@ -545,11 +488,6 @@ class LWIPRawImpl : public Socket {
return ERR_OK;
}
static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) {
LWIPRawImpl *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
return arg_this->accept_fn(newpcb, err);
}
static void s_err_fn(void *arg, err_t err) {
LWIPRawImpl *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
arg_this->err_fn(err);
@@ -601,7 +539,107 @@ class LWIPRawImpl : public Socket {
return -1;
}
// Member ordering optimized to minimize padding on 32-bit systems
// Largest members first (4 bytes), then smaller members (1 byte each)
struct tcp_pcb *pcb_;
pbuf *rx_buf_ = nullptr;
size_t rx_buf_offset_ = 0;
bool rx_closed_ = false;
// don't use lwip nodelay flag, it sometimes causes reconnect
// instead use it for determining whether to call lwip_output
bool nodelay_ = false;
sa_family_t family_ = 0;
};
// Listening socket class - only allocates accept queue when needed (for bind+listen sockets)
// This saves 16 bytes (12 bytes array + 1 byte count + 3 bytes padding) for regular connected sockets on ESP8266/RP2040
class LWIPRawListenImpl : public LWIPRawImpl {
public:
LWIPRawListenImpl(sa_family_t family, struct tcp_pcb *pcb) : LWIPRawImpl(family, pcb) {}
void init() {
LWIP_LOG("init(%p)", pcb_);
tcp_arg(pcb_, this);
tcp_accept(pcb_, LWIPRawListenImpl::s_accept_fn);
tcp_err(pcb_, LWIPRawImpl::s_err_fn); // Use base class error handler
}
std::unique_ptr<Socket> accept(struct sockaddr *addr, socklen_t *addrlen) override {
if (pcb_ == nullptr) {
errno = EBADF;
return nullptr;
}
if (accepted_socket_count_ == 0) {
errno = EWOULDBLOCK;
return nullptr;
}
// Take from front for FIFO ordering
std::unique_ptr<LWIPRawImpl> sock = std::move(accepted_sockets_[0]);
// Shift remaining sockets forward
for (uint8_t i = 1; i < accepted_socket_count_; i++) {
accepted_sockets_[i - 1] = std::move(accepted_sockets_[i]);
}
accepted_socket_count_--;
LWIP_LOG("Connection accepted by application, queue size: %d", accepted_socket_count_);
if (addr != nullptr) {
sock->getpeername(addr, addrlen);
}
LWIP_LOG("accept(%p)", sock.get());
return std::unique_ptr<Socket>(std::move(sock));
}
int listen(int backlog) override {
if (pcb_ == nullptr) {
errno = EBADF;
return -1;
}
LWIP_LOG("tcp_listen_with_backlog(%p backlog=%d)", pcb_, backlog);
struct tcp_pcb *listen_pcb = tcp_listen_with_backlog(pcb_, backlog);
if (listen_pcb == nullptr) {
tcp_abort(pcb_);
pcb_ = nullptr;
errno = EOPNOTSUPP;
return -1;
}
// tcp_listen reallocates the pcb, replace ours
pcb_ = listen_pcb;
// set callbacks on new pcb
LWIP_LOG("tcp_arg(%p)", pcb_);
tcp_arg(pcb_, this);
tcp_accept(pcb_, LWIPRawListenImpl::s_accept_fn);
return 0;
}
private:
err_t accept_fn(struct tcp_pcb *newpcb, err_t err) {
LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err);
if (err != ERR_OK || newpcb == nullptr) {
// "An error code if there has been an error accepting. Only return ERR_ABRT if you have
// called tcp_abort from within the callback function!"
// https://www.nongnu.org/lwip/2_1_x/tcp_8h.html#a00517abce6856d6c82f0efebdafb734d
// nothing to do here, we just don't push it to the queue
return ERR_OK;
}
// Check if we've reached the maximum accept queue size
if (accepted_socket_count_ >= MAX_ACCEPTED_SOCKETS) {
LWIP_LOG("Rejecting connection, queue full (%d)", accepted_socket_count_);
// Abort the connection when queue is full
tcp_abort(newpcb);
// Must return ERR_ABRT since we called tcp_abort()
return ERR_ABRT;
}
auto sock = make_unique<LWIPRawImpl>(family_, newpcb);
sock->init();
accepted_sockets_[accepted_socket_count_++] = std::move(sock);
LWIP_LOG("Accepted connection, queue size: %d", accepted_socket_count_);
return ERR_OK;
}
static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) {
LWIPRawListenImpl *arg_this = reinterpret_cast<LWIPRawListenImpl *>(arg);
return arg_this->accept_fn(newpcb, err);
}
// Accept queue - holds incoming connections briefly until the event loop calls accept()
// This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop
// 3 slots is plenty since connections are pulled out quickly by the event loop
@@ -613,23 +651,21 @@ class LWIPRawImpl : public Socket {
// - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes)
// Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations
// Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation)
//
// By using a separate listening socket class, regular connected sockets save
// 16 bytes (12 bytes array + 1 byte count + 3 bytes padding) of memory overhead on 32-bit systems
static constexpr size_t MAX_ACCEPTED_SOCKETS = 3;
std::array<std::unique_ptr<LWIPRawImpl>, MAX_ACCEPTED_SOCKETS> accepted_sockets_;
uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue
bool rx_closed_ = false;
pbuf *rx_buf_ = nullptr;
size_t rx_buf_offset_ = 0;
// don't use lwip nodelay flag, it sometimes causes reconnect
// instead use it for determining whether to call lwip_output
bool nodelay_ = false;
sa_family_t family_ = 0;
};
std::unique_ptr<Socket> socket(int domain, int type, int protocol) {
auto *pcb = tcp_new();
if (pcb == nullptr)
return nullptr;
auto *sock = new LWIPRawImpl((sa_family_t) domain, pcb); // NOLINT(cppcoreguidelines-owning-memory)
// Create listening socket implementation since user sockets typically bind+listen
// Accepted connections are created directly as LWIPRawImpl in the accept callback
auto *sock = new LWIPRawListenImpl((sa_family_t) domain, pcb); // NOLINT(cppcoreguidelines-owning-memory)
sock->init();
return std::unique_ptr<Socket>{sock};
}

View File

@@ -0,0 +1,5 @@
CODEOWNERS = ["@jesserockz"]
# Allows split_buffer to be configured in yaml, to allow use of the C++ api.
CONFIG_SCHEMA = {}

View File

@@ -0,0 +1,133 @@
#include "split_buffer.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::split_buffer {
static constexpr const char *const TAG = "split_buffer";
SplitBuffer::~SplitBuffer() { this->free(); }
bool SplitBuffer::init(size_t total_length) {
this->free(); // Clean up any existing allocation
if (total_length == 0) {
return false;
}
this->total_length_ = total_length;
size_t current_buffer_size = total_length;
RAMAllocator<uint8_t *> ptr_allocator;
RAMAllocator<uint8_t> allocator;
// Try to allocate the entire buffer first
while (current_buffer_size > 0) {
// Calculate how many buffers we need of this size
size_t needed_buffers = (total_length + current_buffer_size - 1) / current_buffer_size;
// Try to allocate array of buffer pointers
uint8_t **temp_buffers = ptr_allocator.allocate(needed_buffers);
if (temp_buffers == nullptr) {
// If we can't even allocate the pointer array, don't need to continue
ESP_LOGE(TAG, "Failed to allocate pointers");
return false;
}
// Initialize all pointers to null
for (size_t i = 0; i < needed_buffers; i++) {
temp_buffers[i] = nullptr;
}
// Try to allocate all the buffers
bool allocation_success = true;
for (size_t i = 0; i < needed_buffers; i++) {
size_t this_buffer_size = current_buffer_size;
// Last buffer might be smaller if total_length is not divisible by current_buffer_size
if (i == needed_buffers - 1 && total_length % current_buffer_size != 0) {
this_buffer_size = total_length % current_buffer_size;
}
temp_buffers[i] = allocator.allocate(this_buffer_size);
if (temp_buffers[i] == nullptr) {
allocation_success = false;
break;
}
// Initialize buffer to zero
memset(temp_buffers[i], 0, this_buffer_size);
}
if (allocation_success) {
// Success! Store the result
this->buffers_ = temp_buffers;
this->buffer_count_ = needed_buffers;
this->buffer_size_ = current_buffer_size;
ESP_LOGD(TAG, "Allocated %zu * %zu bytes - %zu bytes", this->buffer_count_, this->buffer_size_,
this->total_length_);
return true;
}
// Allocation failed, clean up and try smaller buffers
for (size_t i = 0; i < needed_buffers; i++) {
if (temp_buffers[i] != nullptr) {
allocator.deallocate(temp_buffers[i], 0);
}
}
ptr_allocator.deallocate(temp_buffers, 0);
// Halve the buffer size and try again
current_buffer_size = current_buffer_size / 2;
}
ESP_LOGE(TAG, "Failed to allocate %zu bytes", total_length);
return false;
}
void SplitBuffer::free() {
if (this->buffers_ != nullptr) {
RAMAllocator<uint8_t> allocator;
for (size_t i = 0; i < this->buffer_count_; i++) {
if (this->buffers_[i] != nullptr) {
allocator.deallocate(this->buffers_[i], 0);
}
}
RAMAllocator<uint8_t *> ptr_allocator;
ptr_allocator.deallocate(this->buffers_, 0);
this->buffers_ = nullptr;
}
this->buffer_count_ = 0;
this->buffer_size_ = 0;
this->total_length_ = 0;
}
uint8_t &SplitBuffer::operator[](size_t index) {
if (index >= this->total_length_) {
ESP_LOGE(TAG, "Out of bounds - %zu >= %zu", index, this->total_length_);
// Return reference to a static dummy byte to avoid crash
static uint8_t dummy = 0;
return dummy;
}
size_t buffer_index = index / this->buffer_size_;
size_t offset_in_buffer = index - this->buffer_size_ * buffer_index;
return this->buffers_[buffer_index][offset_in_buffer];
}
const uint8_t &SplitBuffer::operator[](size_t index) const {
if (index >= this->total_length_) {
ESP_LOGE(TAG, "Out of bounds - %zu >= %zu", index, this->total_length_);
// Return reference to a static dummy byte to avoid crash
static const uint8_t DUMMY = 0;
return DUMMY;
}
size_t buffer_index = index / this->buffer_size_;
size_t offset_in_buffer = index - this->buffer_size_ * buffer_index;
return this->buffers_[buffer_index][offset_in_buffer];
}
} // namespace esphome::split_buffer

View File

@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <cstdlib>
namespace esphome::split_buffer {
class SplitBuffer {
public:
SplitBuffer() = default;
~SplitBuffer();
// Initialize the buffer with the desired total length
bool init(size_t total_length);
// Free all allocated buffers
void free();
// Access operators
uint8_t &operator[](size_t index);
const uint8_t &operator[](size_t index) const;
// Get the total length
size_t size() const { return this->total_length_; }
// Get buffer information
size_t get_buffer_count() const { return this->buffer_count_; }
size_t get_buffer_size() const { return this->buffer_size_; }
// Check if successfully initialized
bool is_valid() const { return this->buffers_ != nullptr && this->buffer_count_ > 0; }
private:
uint8_t **buffers_{nullptr};
size_t buffer_count_{0};
size_t buffer_size_{0};
size_t total_length_{0};
};
} // namespace esphome::split_buffer

View File

@@ -347,7 +347,7 @@ def final_validate_device_schema(
def validate_pin(opt, device):
def validator(value):
if opt in device:
if opt in device and not CORE.testing_mode:
raise cv.Invalid(
f"The uart {opt} is used both by {name} and {device[opt]}, "
f"but can only be used by one. Please create a new uart bus for {name}."

View File

@@ -9,6 +9,7 @@ from esphome.components.esp32 import (
import esphome.config_validation as cv
from esphome.const import CONF_DEVICES, CONF_ID
from esphome.cpp_types import Component
from esphome.types import ConfigType
AUTO_LOAD = ["bytebuffer"]
CODEOWNERS = ["@clydebarrow"]
@@ -20,6 +21,7 @@ USBClient = usb_host_ns.class_("USBClient", Component)
CONF_VID = "vid"
CONF_PID = "pid"
CONF_ENABLE_HUBS = "enable_hubs"
CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests"
def usb_device_schema(cls=USBClient, vid: int = None, pid: [int] = None) -> cv.Schema:
@@ -44,6 +46,9 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(USBHost),
cv.Optional(CONF_ENABLE_HUBS, default=False): cv.boolean,
cv.Optional(CONF_MAX_TRANSFER_REQUESTS, default=16): cv.int_range(
min=1, max=32
),
cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()),
}
),
@@ -58,10 +63,14 @@ async def register_usb_client(config):
return var
async def to_code(config):
async def to_code(config: ConfigType) -> None:
add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024)
if config.get(CONF_ENABLE_HUBS):
add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True)
max_requests = config[CONF_MAX_TRANSFER_REQUESTS]
cg.add_define("USB_HOST_MAX_REQUESTS", max_requests)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for device in config.get(CONF_DEVICES) or ():

View File

@@ -2,6 +2,7 @@
// Should not be needed, but it's required to pass CI clang-tidy checks
#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4)
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include <vector>
#include "usb/usb_host.h"
@@ -16,23 +17,25 @@ namespace usb_host {
// THREADING MODEL:
// This component uses a dedicated USB task for event processing to prevent data loss.
// - USB Task (high priority): Handles USB events, executes transfer callbacks
// - Main Loop Task: Initiates transfers, processes completion events
// - USB Task (high priority): Handles USB events, executes transfer callbacks, releases transfer slots
// - Main Loop Task: Initiates transfers, processes device connect/disconnect events
//
// Thread-safe communication:
// - Lock-free queues for USB task -> main loop events (SPSC pattern)
// - Lock-free TransferRequest pool using atomic bitmask (MCSP pattern)
// - Lock-free TransferRequest pool using atomic bitmask (MCMP pattern - multi-consumer, multi-producer)
//
// TransferRequest pool access pattern:
// - get_trq_() [allocate]: Called from BOTH USB task and main loop threads
// * USB task: via USB UART input callbacks that restart transfers immediately
// * Main loop: for output transfers and flow-controlled input restarts
// - release_trq() [deallocate]: Called from main loop thread only
// - release_trq() [deallocate]: Called from BOTH USB task and main loop threads
// * USB task: immediately after transfer callback completes (critical for preventing slot exhaustion)
// * Main loop: when transfer submission fails
//
// The multi-threaded allocation is intentional for performance:
// - USB task can immediately restart input transfers without context switching
// The multi-threaded allocation/deallocation is intentional for performance:
// - USB task can immediately restart input transfers and release slots without context switching
// - Main loop controls backpressure by deciding when to restart after consuming data
// The atomic bitmask ensures thread-safe allocation without mutex blocking.
// The atomic bitmask ensures thread-safe allocation/deallocation without mutex blocking.
static const char *const TAG = "usb_host";
@@ -52,8 +55,13 @@ static const uint8_t USB_DIR_IN = 1 << 7;
static const uint8_t USB_DIR_OUT = 0;
static const size_t SETUP_PACKET_SIZE = 8;
static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible.
static_assert(MAX_REQUESTS <= 16, "MAX_REQUESTS must be <= 16 to fit in uint16_t bitmask");
static const size_t MAX_REQUESTS = USB_HOST_MAX_REQUESTS; // maximum number of outstanding requests possible.
static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be between 1 and 32");
// Select appropriate bitmask type based on MAX_REQUESTS
// uint16_t for <= 16 requests, uint32_t for 17-32 requests
using trq_bitmask_t = std::conditional<(MAX_REQUESTS <= 16), uint16_t, uint32_t>::type;
static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop
static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples)
static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5)
@@ -83,8 +91,6 @@ struct TransferRequest {
enum EventType : uint8_t {
EVENT_DEVICE_NEW,
EVENT_DEVICE_GONE,
EVENT_TRANSFER_COMPLETE,
EVENT_CONTROL_COMPLETE,
};
struct UsbEvent {
@@ -96,9 +102,6 @@ struct UsbEvent {
struct {
usb_device_handle_t handle;
} device_gone;
struct {
TransferRequest *trq;
} transfer;
} data;
// Required for EventPool - no cleanup needed for POD types
@@ -163,10 +166,9 @@ class USBClient : public Component {
uint16_t pid_{};
// Lock-free pool management using atomic bitmask (no dynamic allocation)
// Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available
// Supports multiple concurrent consumers (both threads can allocate)
// Single producer for deallocation (main loop only)
// Limited to 16 slots by uint16_t size (enforced by static_assert)
std::atomic<uint16_t> trq_in_use_;
// Supports multiple concurrent consumers and producers (both threads can allocate/deallocate)
// Bitmask type automatically selected: uint16_t for <= 16 slots, uint32_t for 17-32 slots
std::atomic<trq_bitmask_t> trq_in_use_;
TransferRequest requests_[MAX_REQUESTS]{};
};
class USBHost : public Component {

View File

@@ -228,12 +228,6 @@ void USBClient::loop() {
case EVENT_DEVICE_GONE:
this->on_removed(event->data.device_gone.handle);
break;
case EVENT_TRANSFER_COMPLETE:
case EVENT_CONTROL_COMPLETE: {
auto *trq = event->data.transfer.trq;
this->release_trq(trq);
break;
}
}
// Return event to pool for reuse
this->event_pool.release(event);
@@ -313,25 +307,6 @@ void USBClient::on_removed(usb_device_handle_t handle) {
}
}
// Helper to queue transfer cleanup to main loop
static void queue_transfer_cleanup(TransferRequest *trq, EventType type) {
auto *client = trq->client;
// Allocate event from pool
UsbEvent *event = client->event_pool.allocate();
if (event == nullptr) {
// No events available - increment counter for periodic logging
client->event_queue.increment_dropped_count();
return;
}
event->type = type;
event->data.transfer.trq = trq;
// Push to lock-free queue (always succeeds since pool size == queue size)
client->event_queue.push(event);
}
// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task)
static void control_callback(const usb_transfer_t *xfer) {
auto *trq = static_cast<TransferRequest *>(xfer->context);
@@ -346,8 +321,9 @@ static void control_callback(const usb_transfer_t *xfer) {
trq->callback(trq->status);
}
// Queue cleanup to main loop
queue_transfer_cleanup(trq, EVENT_CONTROL_COMPLETE);
// Release transfer slot immediately in USB task
// The release_trq() uses thread-safe atomic operations
trq->client->release_trq(trq);
}
// THREAD CONTEXT: Called from both USB task and main loop threads (multi-consumer)
@@ -358,20 +334,20 @@ static void control_callback(const usb_transfer_t *xfer) {
// This multi-threaded access is intentional for performance - USB task can
// immediately restart transfers without waiting for main loop scheduling.
TransferRequest *USBClient::get_trq_() {
uint16_t mask = this->trq_in_use_.load(std::memory_order_relaxed);
trq_bitmask_t mask = this->trq_in_use_.load(std::memory_order_relaxed);
// Find first available slot (bit = 0) and try to claim it atomically
// We use a while loop to allow retrying the same slot after CAS failure
size_t i = 0;
while (i != MAX_REQUESTS) {
if (mask & (1U << i)) {
if (mask & (static_cast<trq_bitmask_t>(1) << i)) {
// Slot is in use, move to next slot
i++;
continue;
}
// Slot i appears available, try to claim it atomically
uint16_t desired = mask | (1U << i); // Set bit i to mark as in-use
trq_bitmask_t desired = mask | (static_cast<trq_bitmask_t>(1) << i); // Set bit i to mark as in-use
if (this->trq_in_use_.compare_exchange_weak(mask, desired, std::memory_order_acquire, std::memory_order_relaxed)) {
// Successfully claimed slot i - prepare the TransferRequest
@@ -386,7 +362,7 @@ TransferRequest *USBClient::get_trq_() {
i = 0;
}
ESP_LOGE(TAG, "All %d transfer slots in use", MAX_REQUESTS);
ESP_LOGE(TAG, "All %zu transfer slots in use", MAX_REQUESTS);
return nullptr;
}
void USBClient::disconnect() {
@@ -452,8 +428,11 @@ static void transfer_callback(usb_transfer_t *xfer) {
trq->callback(trq->status);
}
// Queue cleanup to main loop
queue_transfer_cleanup(trq, EVENT_TRANSFER_COMPLETE);
// Release transfer slot AFTER callback completes to prevent slot exhaustion
// This is critical for high-throughput transfers (e.g., USB UART at 115200 baud)
// The callback has finished accessing xfer->data_buffer, so it's safe to release
// The release_trq() uses thread-safe atomic operations
trq->client->release_trq(trq);
}
/**
* Performs a transfer input operation.
@@ -521,12 +500,12 @@ void USBClient::dump_config() {
" Product id %04X",
this->vid_, this->pid_);
}
// THREAD CONTEXT: Only called from main loop thread (single producer for deallocation)
// - Via event processing when handling EVENT_TRANSFER_COMPLETE/EVENT_CONTROL_COMPLETE
// - Directly when transfer submission fails
// THREAD CONTEXT: Called from both USB task and main loop threads
// - USB task: Immediately after transfer callback completes
// - Main loop: When transfer submission fails
//
// THREAD SAFETY: Lock-free using atomic AND to clear bit
// Single-producer pattern makes this simpler than allocation
// Thread-safe atomic operation allows multi-threaded deallocation
void USBClient::release_trq(TransferRequest *trq) {
if (trq == nullptr)
return;
@@ -540,8 +519,8 @@ void USBClient::release_trq(TransferRequest *trq) {
// Atomically clear bit i to mark slot as available
// fetch_and with inverted bitmask clears the bit atomically
uint16_t bit = 1U << index;
this->trq_in_use_.fetch_and(static_cast<uint16_t>(~bit), std::memory_order_release);
trq_bitmask_t bit = static_cast<trq_bitmask_t>(1) << index;
this->trq_in_use_.fetch_and(static_cast<trq_bitmask_t>(~bit), std::memory_order_release);
}
} // namespace usb_host

View File

@@ -19,72 +19,54 @@ ListEntitiesIterator::~ListEntitiesIterator() {}
#ifdef USE_BINARY_SENSOR
bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator);
return true;
}
#endif
#ifdef USE_COVER
bool ListEntitiesIterator::on_cover(cover::Cover *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::cover_all_json_generator);
return true;
}
#endif
#ifdef USE_FAN
bool ListEntitiesIterator::on_fan(fan::Fan *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::fan_all_json_generator);
return true;
}
#endif
#ifdef USE_LIGHT
bool ListEntitiesIterator::on_light(light::LightState *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::light_all_json_generator);
return true;
}
#endif
#ifdef USE_SENSOR
bool ListEntitiesIterator::on_sensor(sensor::Sensor *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::sensor_all_json_generator);
return true;
}
#endif
#ifdef USE_SWITCH
bool ListEntitiesIterator::on_switch(switch_::Switch *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::switch_all_json_generator);
return true;
}
#endif
#ifdef USE_BUTTON
bool ListEntitiesIterator::on_button(button::Button *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::button_all_json_generator);
return true;
}
#endif
#ifdef USE_TEXT_SENSOR
bool ListEntitiesIterator::on_text_sensor(text_sensor::TextSensor *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_sensor_all_json_generator);
return true;
}
#endif
#ifdef USE_LOCK
bool ListEntitiesIterator::on_lock(lock::Lock *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::lock_all_json_generator);
return true;
}
@@ -92,8 +74,6 @@ bool ListEntitiesIterator::on_lock(lock::Lock *obj) {
#ifdef USE_VALVE
bool ListEntitiesIterator::on_valve(valve::Valve *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::valve_all_json_generator);
return true;
}
@@ -101,8 +81,6 @@ bool ListEntitiesIterator::on_valve(valve::Valve *obj) {
#ifdef USE_CLIMATE
bool ListEntitiesIterator::on_climate(climate::Climate *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::climate_all_json_generator);
return true;
}
@@ -110,8 +88,6 @@ bool ListEntitiesIterator::on_climate(climate::Climate *obj) {
#ifdef USE_NUMBER
bool ListEntitiesIterator::on_number(number::Number *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::number_all_json_generator);
return true;
}
@@ -119,8 +95,6 @@ bool ListEntitiesIterator::on_number(number::Number *obj) {
#ifdef USE_DATETIME_DATE
bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::date_all_json_generator);
return true;
}
@@ -128,8 +102,6 @@ bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) {
#ifdef USE_DATETIME_TIME
bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::time_all_json_generator);
return true;
}
@@ -137,8 +109,6 @@ bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) {
#ifdef USE_DATETIME_DATETIME
bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::datetime_all_json_generator);
return true;
}
@@ -146,8 +116,6 @@ bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) {
#ifdef USE_TEXT
bool ListEntitiesIterator::on_text(text::Text *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_all_json_generator);
return true;
}
@@ -155,8 +123,6 @@ bool ListEntitiesIterator::on_text(text::Text *obj) {
#ifdef USE_SELECT
bool ListEntitiesIterator::on_select(select::Select *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::select_all_json_generator);
return true;
}
@@ -164,8 +130,6 @@ bool ListEntitiesIterator::on_select(select::Select *obj) {
#ifdef USE_ALARM_CONTROL_PANEL
bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::alarm_control_panel_all_json_generator);
return true;
}
@@ -173,8 +137,6 @@ bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmCont
#ifdef USE_EVENT
bool ListEntitiesIterator::on_event(event::Event *obj) {
if (this->events_->count() == 0)
return true;
// Null event type, since we are just iterating over entities
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::event_all_json_generator);
return true;
@@ -183,8 +145,6 @@ bool ListEntitiesIterator::on_event(event::Event *obj) {
#ifdef USE_UPDATE
bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) {
if (this->events_->count() == 0)
return true;
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::update_all_json_generator);
return true;
}

View File

@@ -152,6 +152,10 @@ void DeferredUpdateEventSource::loop() {
void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type,
message_generator_t *message_generator) {
// Skip if no connected clients to avoid unnecessary deferred queue processing
if (this->count() == 0)
return;
// allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing
// up in the web GUI and reduces event load during initial connect
if (!entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all"))
@@ -197,6 +201,9 @@ void DeferredUpdateEventSourceList::loop() {
void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type,
message_generator_t *message_generator) {
// Skip if no event sources (no connected clients) to avoid unnecessary iteration
if (this->empty())
return;
for (DeferredUpdateEventSource *dues : *this) {
dues->deferrable_send_state(source, event_type, message_generator);
}
@@ -424,8 +431,6 @@ static JsonDetail get_request_detail(AsyncWebServerRequest *request) {
#ifdef USE_SENSOR
void WebServer::on_sensor_update(sensor::Sensor *obj, float state) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", sensor_state_json_generator);
}
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -453,13 +458,8 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail
const auto uom_ref = obj->get_unit_of_measurement_ref();
// Build JSON directly inline
std::string state;
if (std::isnan(value)) {
state = "NA";
} else {
state = value_accuracy_with_uom_to_string(value, obj->get_accuracy_decimals(), uom_ref);
}
std::string state =
std::isnan(value) ? "NA" : value_accuracy_with_uom_to_string(value, obj->get_accuracy_decimals(), uom_ref);
set_json_icon_state_value(root, obj, "sensor", state, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
@@ -473,8 +473,6 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail
#ifdef USE_TEXT_SENSOR
void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj, const std::string &state) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", text_sensor_state_json_generator);
}
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -514,8 +512,6 @@ std::string WebServer::text_sensor_json(text_sensor::TextSensor *obj, const std:
#ifdef USE_SWITCH
void WebServer::on_switch_update(switch_::Switch *obj, bool state) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", switch_state_json_generator);
}
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -627,8 +623,6 @@ std::string WebServer::button_json(button::Button *obj, JsonDetail start_config)
#ifdef USE_BINARY_SENSOR
void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", binary_sensor_state_json_generator);
}
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -667,8 +661,6 @@ std::string WebServer::binary_sensor_json(binary_sensor::BinarySensor *obj, bool
#ifdef USE_FAN
void WebServer::on_fan_update(fan::Fan *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", fan_state_json_generator);
}
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -743,8 +735,6 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) {
#ifdef USE_LIGHT
void WebServer::on_light_update(light::LightState *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", light_state_json_generator);
}
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -800,8 +790,7 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_id(root, obj, "light", start_config);
root["state"] = obj->remote_values.is_on() ? "ON" : "OFF";
set_json_value(root, obj, "light", obj->remote_values.is_on() ? "ON" : "OFF", start_config);
light::LightJSONSchema::dump_json(*obj, root);
if (start_config == DETAIL_ALL) {
@@ -819,8 +808,6 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi
#ifdef USE_COVER
void WebServer::on_cover_update(cover::Cover *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", cover_state_json_generator);
}
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -906,8 +893,6 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) {
#ifdef USE_NUMBER
void WebServer::on_number_update(number::Number *obj, float state) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", number_state_json_generator);
}
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -948,7 +933,13 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail
const auto uom_ref = obj->traits.get_unit_of_measurement_ref();
set_json_id(root, obj, "number", start_config);
std::string val_str = std::isnan(value)
? "\"NaN\""
: value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step()));
std::string state_str = std::isnan(value) ? "NA"
: value_accuracy_with_uom_to_string(
value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref);
set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config);
if (start_config == DETAIL_ALL) {
root["min_value"] =
value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step()));
@@ -960,14 +951,6 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail
root["uom"] = uom_ref;
this->add_sorting_info_(root, obj);
}
if (std::isnan(value)) {
root["value"] = "\"NaN\"";
root["state"] = "NA";
} else {
root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step()));
root["state"] =
value_accuracy_with_uom_to_string(value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref);
}
return builder.serialize();
}
@@ -975,8 +958,6 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail
#ifdef USE_DATETIME_DATE
void WebServer::on_date_update(datetime::DateEntity *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", date_state_json_generator);
}
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1020,10 +1001,8 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_id(root, obj, "date", start_config);
std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day);
root["value"] = value;
root["state"] = value;
set_json_icon_state_value(root, obj, "date", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1034,8 +1013,6 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con
#ifdef USE_DATETIME_TIME
void WebServer::on_time_update(datetime::TimeEntity *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", time_state_json_generator);
}
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1078,10 +1055,8 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_id(root, obj, "time", start_config);
std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second);
root["value"] = value;
root["state"] = value;
set_json_icon_state_value(root, obj, "time", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1092,8 +1067,6 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con
#ifdef USE_DATETIME_DATETIME
void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", datetime_state_json_generator);
}
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1136,11 +1109,9 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_id(root, obj, "datetime", start_config);
std::string value =
str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second);
root["value"] = value;
root["state"] = value;
set_json_icon_state_value(root, obj, "datetime", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1151,8 +1122,6 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s
#ifdef USE_TEXT
void WebServer::on_text_update(text::Text *obj, const std::string &state) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", text_state_json_generator);
}
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1191,16 +1160,11 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_id(root, obj, "text", start_config);
std::string state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value;
set_json_icon_state_value(root, obj, "text", state, value, start_config);
root["min_length"] = obj->traits.get_min_length();
root["max_length"] = obj->traits.get_max_length();
root["pattern"] = obj->traits.get_pattern();
if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) {
root["state"] = "********";
} else {
root["state"] = value;
}
root["value"] = value;
if (start_config == DETAIL_ALL) {
root["mode"] = (int) obj->traits.get_mode();
this->add_sorting_info_(root, obj);
@@ -1212,8 +1176,6 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json
#ifdef USE_SELECT
void WebServer::on_select_update(select::Select *obj, const std::string &state, size_t index) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", select_state_json_generator);
}
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1270,8 +1232,6 @@ std::string WebServer::select_json(select::Select *obj, const std::string &value
#ifdef USE_CLIMATE
void WebServer::on_climate_update(climate::Climate *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", climate_state_json_generator);
}
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1412,8 +1372,6 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf
#ifdef USE_LOCK
void WebServer::on_lock_update(lock::Lock *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", lock_state_json_generator);
}
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1485,8 +1443,6 @@ std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDet
#ifdef USE_VALVE
void WebServer::on_valve_update(valve::Valve *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", valve_state_json_generator);
}
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1568,8 +1524,6 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) {
#ifdef USE_ALARM_CONTROL_PANEL
void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", alarm_control_panel_state_json_generator);
}
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1714,8 +1668,6 @@ static const char *update_state_to_string(update::UpdateState state) {
}
void WebServer::on_update(update::UpdateEntity *obj) {
if (this->events_.empty())
return;
this->events_.deferrable_send_state(obj, "state", update_state_json_generator);
}
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
@@ -1754,9 +1706,8 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_id(root, obj, "update", start_config);
root["value"] = obj->update_info.latest_version;
root["state"] = update_state_to_string(obj->state);
set_json_icon_state_value(root, obj, "update", update_state_to_string(obj->state), obj->update_info.latest_version,
start_config);
if (start_config == DETAIL_ALL) {
root["current_version"] = obj->update_info.current_version;
root["title"] = obj->update_info.title;

View File

@@ -412,6 +412,9 @@ void AsyncEventSource::try_send_nodefer(const char *message, const char *event,
void AsyncEventSource::deferrable_send_state(void *source, const char *event_type,
message_generator_t *message_generator) {
// Skip if no connected clients to avoid unnecessary processing
if (this->empty())
return;
for (auto *ses : this->sessions_) {
if (ses->fd_.load() != 0) { // Skip dead sessions
ses->deferrable_send_state(source, event_type, message_generator);

View File

@@ -267,7 +267,9 @@ network::IPAddress WiFiComponent::get_dns_address(int num) {
}
std::string WiFiComponent::get_use_address() const {
if (this->use_address_.empty()) {
return App.get_name() + ".local";
// ".local" suffix length for mDNS hostnames
constexpr size_t mdns_local_suffix_len = 5;
return make_name_with_suffix(App.get_name(), '.', "local", mdns_local_suffix_len);
}
return this->use_address_;
}
@@ -576,8 +578,9 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res)
format_mac_addr_upper(bssid.data(), bssid_s);
if (res.get_matches()) {
ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), res.get_is_hidden() ? "(HIDDEN) " : "",
bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi())));
ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
LOG_STR_ARG(get_signal_bars(res.get_rssi())));
ESP_LOGD(TAG,
" Channel: %u\n"
" RSSI: %d dB",

View File

@@ -647,7 +647,7 @@ class AddDynamicAutoLoadsValidationStep(ConfigValidationStep):
"""
# Has to happen after normal schema is validated and before final schema validation
priority = -10.0
priority = -5.0
def __init__(self, path: ConfigPath, comp: ComponentManifest) -> None:
self.path = path

View File

@@ -1195,6 +1195,13 @@ def validate_bytes(value):
def hostname(value):
"""Validate that the value is a valid hostname.
Maximum length is 63 characters per RFC 1035.
Note: If this limit is changed, update MAX_NAME_WITH_SUFFIX_SIZE in
esphome/core/helpers.cpp to accommodate the new maximum length.
"""
value = string(value)
if re.match(r"^[a-z0-9-]{1,63}$", value, re.IGNORECASE) is not None:
return value

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.10.0-dev"
__version__ = "2025.11.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -529,6 +529,8 @@ class EsphomeCore:
self.dashboard = False
# True if command is run from vscode api
self.vscode = False
# True if running in testing mode (disables validation checks for grouped testing)
self.testing_mode = False
# The name of the node
self.name: str | None = None
# The friendly name of the node

View File

@@ -340,8 +340,8 @@ void Application::calculate_looping_components_() {
}
}
// Pre-reserve vector to avoid reallocations
this->looping_components_.reserve(total_looping);
// Initialize FixedVector with exact size - no reallocation possible
this->looping_components_.init(total_looping);
// Add all components with loop override that aren't already LOOP_DONE
// Some components (like logger) may call disable_loop() during initialization

View File

@@ -102,9 +102,15 @@ class Application {
arch_init();
this->name_add_mac_suffix_ = name_add_mac_suffix;
if (name_add_mac_suffix) {
const std::string mac_suffix = get_mac_address().substr(6);
this->name_ = name + "-" + mac_suffix;
this->friendly_name_ = friendly_name.empty() ? "" : friendly_name + " " + mac_suffix;
// MAC address suffix length (last 6 characters of 12-char MAC address string)
constexpr size_t mac_address_suffix_len = 6;
const std::string mac_addr = get_mac_address();
// Use pointer + offset to avoid substr() allocation
const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len;
this->name_ = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len);
if (!friendly_name.empty()) {
this->friendly_name_ = make_name_with_suffix(friendly_name, ' ', mac_suffix_ptr, mac_address_suffix_len);
}
} else {
this->name_ = name;
this->friendly_name_ = friendly_name;
@@ -472,7 +478,7 @@ class Application {
// - When a component is enabled, it's swapped with the first inactive component
// and active_end_ is incremented
// - This eliminates branch mispredictions from flag checking in the hot loop
std::vector<Component *> looping_components_{};
FixedVector<Component *> looping_components_{};
#ifdef USE_SOCKET_SELECT_SUPPORT
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
#endif

View File

@@ -200,7 +200,7 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_NAME): cv.valid_name,
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string,
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(cv.string, cv.Length(max=120)),
cv.Optional(CONF_AREA): validate_area_config,
cv.Optional(CONF_COMMENT): cv.string,
cv.Required(CONF_BUILD_PATH): cv.string,

View File

@@ -83,7 +83,9 @@
#define USE_LVGL_TILEVIEW
#define USE_LVGL_TOUCHSCREEN
#define USE_MDNS
#define USE_MDNS_STORE_SERVICES
#define MDNS_SERVICE_COUNT 3
#define MDNS_DYNAMIC_TXT_COUNT 3
#define USE_MEDIA_PLAYER
#define USE_NEXTION_TFT_UPLOAD
#define USE_NUMBER
@@ -174,6 +176,8 @@
#define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE
#define USE_ESP32_BLE_SERVER_ON_CONNECT
#define USE_ESP32_BLE_SERVER_ON_DISCONNECT
#define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1
#define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1
#define USE_ESP32_CAMERA_JPEG_ENCODER
#define USE_I2C
#define USE_IMPROV
@@ -190,6 +194,7 @@
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_SORTING
#define USE_WIFI_11KV_SUPPORT
#define USB_HOST_MAX_REQUESTS 16
#ifdef USE_ARDUINO
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 2, 1)

View File

@@ -246,12 +246,15 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
"\n to distinguish them"
)
raise cv.Invalid(
f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. "
f"{conflict_msg}. "
"Each entity on a device must have a unique name within its platform."
f"{sanitized_msg}"
)
# Skip duplicate entity name validation when testing_mode is enabled
# This flag is used for grouped component testing
if not CORE.testing_mode:
raise cv.Invalid(
f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. "
f"{conflict_msg}. "
"Each entity on a device must have a unique name within its platform."
f"{sanitized_msg}"
)
# Store metadata about this entity
entity_metadata: EntityMetadata = {

View File

@@ -235,6 +235,30 @@ std::string str_sprintf(const char *fmt, ...) {
return str;
}
// Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term)
static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128;
std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) {
char buffer[MAX_NAME_WITH_SUFFIX_SIZE];
size_t name_len = name.size();
size_t total_len = name_len + 1 + suffix_len;
// Silently truncate if needed: prioritize keeping the full suffix
if (total_len >= MAX_NAME_WITH_SUFFIX_SIZE) {
// NOTE: This calculation could underflow if suffix_len >= MAX_NAME_WITH_SUFFIX_SIZE - 2,
// but this is safe because this helper is only called with small suffixes:
// MAC suffixes (6-12 bytes), ".local" (5 bytes), etc.
name_len = MAX_NAME_WITH_SUFFIX_SIZE - suffix_len - 2; // -2 for separator and null terminator
total_len = name_len + 1 + suffix_len;
}
memcpy(buffer, name.c_str(), name_len);
buffer[name_len] = sep;
memcpy(buffer + name_len + 1, suffix_ptr, suffix_len);
buffer[total_len] = '\0';
return std::string(buffer, total_len);
}
// Parsing & formatting
size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) {

View File

@@ -162,6 +162,54 @@ template<typename T, size_t N> class StaticVector {
const_reverse_iterator rend() const { return const_reverse_iterator(begin()); }
};
/// Fixed-capacity vector - allocates once at runtime, never reallocates
/// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append)
/// when size is known at initialization but not at compile time
template<typename T> class FixedVector {
private:
T *data_{nullptr};
size_t size_{0};
size_t capacity_{0};
public:
FixedVector() = default;
~FixedVector() {
if (data_ != nullptr) {
delete[] data_;
}
}
// Disable copy to avoid accidental copies
FixedVector(const FixedVector &) = delete;
FixedVector &operator=(const FixedVector &) = delete;
// Allocate capacity - can only be called once on empty vector
void init(size_t n) {
if (data_ == nullptr && n > 0) {
data_ = new T[n];
capacity_ = n;
size_ = 0;
}
}
/// Add element without bounds checking
/// Caller must ensure sufficient capacity was allocated via init()
/// Silently ignores pushes beyond capacity (no exception or assertion)
void push_back(const T &value) {
if (size_ < capacity_) {
data_[size_++] = value;
}
}
size_t size() const { return size_; }
/// Access element without bounds checking (matches std::vector behavior)
/// Caller must ensure index is valid (i < size())
T &operator[](size_t i) { return data_[i]; }
const T &operator[](size_t i) const { return data_[i]; }
};
///@}
/// @name Mathematics
@@ -309,6 +357,16 @@ std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt,
/// sprintf-like function returning std::string.
std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...);
/// Concatenate a name with a separator and suffix using an efficient stack-based approach.
/// This avoids multiple heap allocations during string construction.
/// Maximum name length supported is 120 characters for friendly names.
/// @param name The base name string
/// @param sep The separator character (e.g., '-', ' ', or '.')
/// @param suffix_ptr Pointer to the suffix characters
/// @param suffix_len Length of the suffix
/// @return The concatenated string: name + sep + suffix
std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len);
///@}
/// @name Parsing & formatting

View File

@@ -410,7 +410,7 @@ def run_ota_impl_(
af, socktype, _, _, sa = r
_LOGGER.info("Connecting to %s port %s...", sa[0], sa[1])
sock = socket.socket(af, socktype)
sock.settimeout(10.0)
sock.settimeout(20.0)
try:
sock.connect(sa)
except OSError as err:

View File

@@ -118,11 +118,11 @@ class PinRegistry(dict):
parent_config = fconf.get_config_for_path(parent_path)
final_val_fun(pin_config, parent_config)
allow_others = pin_config.get(CONF_ALLOW_OTHER_USES, False)
if count != 1 and not allow_others:
if count != 1 and not allow_others and not CORE.testing_mode:
raise cv.Invalid(
f"Pin {pin_config[CONF_NUMBER]} is used in multiple places"
)
if count == 1 and allow_others:
if count == 1 and allow_others and not CORE.testing_mode:
raise cv.Invalid(
f"Pin {pin_config[CONF_NUMBER]} incorrectly sets {CONF_ALLOW_OTHER_USES}: true"
)

View File

@@ -43,6 +43,35 @@ def patch_structhash():
cli.clean_build_dir = patched_clean_build_dir
def patch_file_downloader():
"""Patch PlatformIO's FileDownloader to retry on PackageException errors."""
from platformio.package.download import FileDownloader
from platformio.package.exception import PackageException
original_init = FileDownloader.__init__
def patched_init(self, *args: Any, **kwargs: Any) -> None:
max_retries = 3
for attempt in range(max_retries):
try:
return original_init(self, *args, **kwargs)
except PackageException as e:
if attempt < max_retries - 1:
_LOGGER.warning(
"Package download failed: %s. Retrying... (attempt %d/%d)",
str(e),
attempt + 1,
max_retries,
)
else:
# Final attempt - re-raise
raise
return None
FileDownloader.__init__ = patched_init
IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
FILTER_PLATFORMIO_LINES = [
r"Verbose mode can be enabled via `-v, --verbose` option.*",
@@ -75,6 +104,9 @@ FILTER_PLATFORMIO_LINES = [
r"Creating BIN file .*",
r"Warning! Could not find file \".*.crt\"",
r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.",
r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.",
r"Warning: esp-idf-size exited with code 2",
r"esp_idf_size: error: unrecognized arguments: --ng",
]
@@ -97,6 +129,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
import platformio.__main__
patch_structhash()
patch_file_downloader()
return run_external_command(platformio.__main__.main, *cmd, **kwargs)

View File

@@ -147,7 +147,7 @@ lib_deps =
makuna/NeoPixelBus@2.8.0 ; neopixelbus
esphome/ESP32-audioI2S@2.3.0 ; i2s_audio
droscy/esp_wireguard@0.4.2 ; wireguard
esphome/esp-audio-libs@1.1.4 ; audio
esphome/esp-audio-libs@2.0.1 ; audio
build_flags =
${common:arduino.build_flags}
@@ -170,7 +170,7 @@ lib_deps =
${common:idf.lib_deps}
droscy/esp_wireguard@0.4.2 ; wireguard
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
esphome/esp-audio-libs@1.1.4 ; audio
esphome/esp-audio-libs@2.0.1 ; audio
build_flags =
${common:idf.build_flags}
-Wno-nonnull-compare

View File

@@ -11,8 +11,8 @@ pyserial==3.5
platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile
esptool==5.1.0
click==8.1.7
esphome-dashboard==20250904.0
aioesphomeapi==41.11.0
esphome-dashboard==20251009.0
aioesphomeapi==41.13.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.18.15 # dashboard_import

View File

@@ -1,7 +1,7 @@
pylint==3.3.9
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.13.3 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating
ruff==0.14.0 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating
pre-commit
# Unit tests

523
script/analyze_component_buses.py Executable file
View File

@@ -0,0 +1,523 @@
#!/usr/bin/env python3
"""Analyze component test files to detect which common bus configs they use.
This script scans component test files and extracts which common bus configurations
(i2c, spi, uart, etc.) are included via the packages mechanism. This information
is used to group components that can be tested together.
Components can only be grouped together if they use the EXACT SAME set of common
bus configurations, ensuring that merged configs are compatible.
Example output:
{
"component1": {
"esp32-ard": ["i2c", "uart_19200"],
"esp32-idf": ["i2c", "uart_19200"]
},
"component2": {
"esp32-ard": ["spi"],
"esp32-idf": ["spi"]
}
}
"""
from __future__ import annotations
import argparse
from functools import lru_cache
import json
from pathlib import Path
import re
import sys
from typing import Any
# Add esphome to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from esphome import yaml_util
from esphome.config_helpers import Extend, Remove
# Path to common bus configs
COMMON_BUS_PATH = Path("tests/test_build_components/common")
# Package dependencies - maps packages to the packages they include
# When a component uses a package on the left, it automatically gets
# the packages on the right as well
PACKAGE_DEPENDENCIES = {
"modbus": ["uart"], # modbus packages include uart packages
# Add more package dependencies here as needed
}
# Bus types that can be defined directly in config files
# Components defining these directly cannot be grouped (they create unique bus IDs)
DIRECT_BUS_TYPES = ("i2c", "spi", "uart", "modbus")
# Signature for components with no bus requirements
# These components can be merged with any other group
NO_BUSES_SIGNATURE = "no_buses"
# Base bus components - these ARE the bus implementations and should not
# be flagged as needing migration since they are the platform/base components
BASE_BUS_COMPONENTS = {
"i2c",
"spi",
"uart",
"modbus",
"canbus",
}
# Components that must be tested in isolation (not grouped or batched with others)
# These have known build issues that prevent grouping
# NOTE: This should be kept in sync with both test_build_components and split_components_for_ci.py
ISOLATED_COMPONENTS = {
"animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display",
"esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged",
"ethernet": "Defines ethernet: which conflicts with wifi: used by most components",
"ethernet_info": "Related to ethernet component which conflicts with wifi",
"lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs",
"matrix_keypad": "Needs isolation due to keypad",
"mcp4725": "no YAML config to specify i2c bus id",
"mcp47a1": "no YAML config to specify i2c bus id",
"modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus",
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
"packages": "cannot merge packages",
}
@lru_cache(maxsize=1)
def get_common_bus_packages() -> frozenset[str]:
"""Get the list of common bus package names.
Reads from tests/test_build_components/common/ directory
and caches the result. All bus types support component grouping
for config validation since --testing-mode bypasses runtime conflicts.
Returns:
Frozenset of common bus package names (i2c, spi, uart, etc.)
"""
if not COMMON_BUS_PATH.exists():
return frozenset()
# List all directories in common/ - these are the bus package names
return frozenset(d.name for d in COMMON_BUS_PATH.iterdir() if d.is_dir())
def uses_local_file_references(component_dir: Path) -> bool:
"""Check if a component uses local file references via $component_dir.
Components that reference local files cannot be grouped because each needs
a unique component_dir path pointing to their specific directory.
Args:
component_dir: Path to the component's test directory
Returns:
True if the component uses $component_dir for local file references
"""
common_yaml = component_dir / "common.yaml"
if not common_yaml.exists():
return False
try:
content = common_yaml.read_text()
except Exception: # pylint: disable=broad-exception-caught
return False
# Pattern to match $component_dir or ${component_dir} references
# These indicate local file usage that prevents grouping
return bool(re.search(r"\$\{?component_dir\}?", content))
def is_platform_component(component_dir: Path) -> bool:
"""Check if a component is a platform component (abstract base class).
Platform components have IS_PLATFORM_COMPONENT = True and cannot be
instantiated without a platform-specific implementation. These components
define abstract methods and cause linker errors if compiled standalone.
Examples: canbus, mcp23x08_base, mcp23x17_base
Args:
component_dir: Path to the component's test directory
Returns:
True if this is a platform component
"""
# Check in the actual component source, not tests
# tests/components/X -> tests/components -> tests -> repo root
repo_root = component_dir.parent.parent.parent
comp_init = (
repo_root / "esphome" / "components" / component_dir.name / "__init__.py"
)
if not comp_init.exists():
return False
try:
content = comp_init.read_text()
return "IS_PLATFORM_COMPONENT = True" in content
except Exception: # pylint: disable=broad-exception-caught
return False
def _contains_extend_or_remove(data: Any) -> bool:
"""Recursively check if data contains Extend or Remove objects.
Args:
data: Parsed YAML data structure
Returns:
True if any Extend or Remove objects are found
"""
if isinstance(data, (Extend, Remove)):
return True
if isinstance(data, dict):
for value in data.values():
if _contains_extend_or_remove(value):
return True
if isinstance(data, list):
for item in data:
if _contains_extend_or_remove(item):
return True
return False
def analyze_yaml_file(yaml_file: Path) -> dict[str, Any]:
"""Load a YAML file once and extract all needed information.
This loads the YAML file a single time and extracts all information needed
for component analysis, avoiding multiple file reads.
Args:
yaml_file: Path to the YAML file to analyze
Returns:
Dictionary with keys:
- buses: set of common bus package names
- has_extend_remove: bool indicating if Extend/Remove objects are present
- has_direct_bus_config: bool indicating if buses are defined directly (not via packages)
- loaded: bool indicating if file was successfully loaded
"""
result = {
"buses": set(),
"has_extend_remove": False,
"has_direct_bus_config": False,
"loaded": False,
}
if not yaml_file.exists():
return result
try:
data = yaml_util.load_yaml(yaml_file)
result["loaded"] = True
except Exception: # pylint: disable=broad-exception-caught
return result
# Check for Extend/Remove objects
result["has_extend_remove"] = _contains_extend_or_remove(data)
# Check if buses are defined directly (not via packages)
# Components that define i2c, spi, uart, or modbus directly in test files
# cannot be grouped because they create unique bus IDs
if isinstance(data, dict):
for bus_type in DIRECT_BUS_TYPES:
if bus_type in data:
result["has_direct_bus_config"] = True
break
# Extract common bus packages
if not isinstance(data, dict) or "packages" not in data:
return result
packages = data["packages"]
if not isinstance(packages, dict):
return result
valid_buses = get_common_bus_packages()
for pkg_name in packages:
if pkg_name not in valid_buses:
continue
result["buses"].add(pkg_name)
# Add any package dependencies (e.g., modbus includes uart)
if pkg_name not in PACKAGE_DEPENDENCIES:
continue
for dep in PACKAGE_DEPENDENCIES[pkg_name]:
if dep not in valid_buses:
continue
result["buses"].add(dep)
return result
def analyze_component(component_dir: Path) -> tuple[dict[str, list[str]], bool, bool]:
"""Analyze a component directory to find which buses each platform uses.
Args:
component_dir: Path to the component's test directory
Returns:
Tuple of:
- Dictionary mapping platform to list of bus configs
Example: {"esp32-ard": ["i2c", "spi"], "esp32-idf": ["i2c"]}
- Boolean indicating if component uses !extend or !remove
- Boolean indicating if component defines buses directly (not via packages)
"""
if not component_dir.is_dir():
return {}, False, False
platform_buses = {}
has_extend_remove = False
has_direct_bus_config = False
# Analyze all YAML files in the component directory
for yaml_file in component_dir.glob("*.yaml"):
analysis = analyze_yaml_file(yaml_file)
# Track if any file uses extend/remove
if analysis["has_extend_remove"]:
has_extend_remove = True
# Track if any file defines buses directly
if analysis["has_direct_bus_config"]:
has_direct_bus_config = True
# For test.*.yaml files, extract platform and buses
if yaml_file.name.startswith("test.") and yaml_file.suffix == ".yaml":
# Extract platform name (e.g., test.esp32-ard.yaml -> esp32-ard)
platform = yaml_file.stem.replace("test.", "")
# Always add platform, even if it has no buses (empty list)
# This allows grouping components that don't use any shared buses
platform_buses[platform] = (
sorted(analysis["buses"]) if analysis["buses"] else []
)
return platform_buses, has_extend_remove, has_direct_bus_config
def analyze_all_components(
tests_dir: Path = None,
) -> tuple[dict[str, dict[str, list[str]]], set[str], set[str]]:
"""Analyze all component test directories.
Args:
tests_dir: Path to tests/components directory (defaults to auto-detect)
Returns:
Tuple of:
- Dictionary mapping component name to platform->buses mapping
- Set of component names that cannot be grouped
- Set of component names that define buses directly (need migration warning)
"""
if tests_dir is None:
tests_dir = Path("tests/components")
if not tests_dir.exists():
print(f"Error: {tests_dir} does not exist", file=sys.stderr)
return {}, set(), set()
components = {}
non_groupable = set()
direct_bus_components = set()
for component_dir in sorted(tests_dir.iterdir()):
if not component_dir.is_dir():
continue
component_name = component_dir.name
platform_buses, has_extend_remove, has_direct_bus_config = analyze_component(
component_dir
)
if platform_buses:
components[component_name] = platform_buses
# Note: Components using $component_dir are now groupable because the merge
# script rewrites these to absolute paths with component-specific substitutions
# Check if component is explicitly isolated
# These have known issues that prevent grouping with other components
if component_name in ISOLATED_COMPONENTS:
non_groupable.add(component_name)
# Check if component is a base bus component
# These ARE the bus platform implementations and define buses directly for testing
# They cannot be grouped with components that use bus packages (causes ID conflicts)
if component_name in BASE_BUS_COMPONENTS:
non_groupable.add(component_name)
# Check if component uses !extend or !remove directives
# These rely on specific config structure and cannot be merged with other components
# The directives work within a component's own package hierarchy but break when
# merging independent components together
if has_extend_remove:
non_groupable.add(component_name)
# Check if component defines buses directly in test files
# These create unique bus IDs and cause conflicts when merged
# Exclude base bus components (i2c, spi, uart, etc.) since they ARE the platform
if has_direct_bus_config and component_name not in BASE_BUS_COMPONENTS:
non_groupable.add(component_name)
direct_bus_components.add(component_name)
return components, non_groupable, direct_bus_components
def create_grouping_signature(
platform_buses: dict[str, list[str]], platform: str
) -> str:
"""Create a signature string for grouping components.
Components with the same signature can be grouped together for testing.
All valid bus types can be grouped since --testing-mode bypasses runtime
conflicts during config validation.
Args:
platform_buses: Mapping of platform to list of buses
platform: The specific platform to create signature for
Returns:
Signature string (e.g., "i2c" or "uart") or empty if no valid buses
"""
buses = platform_buses.get(platform, [])
if not buses:
return ""
# Only include valid bus types in signature
common_buses = get_common_bus_packages()
valid_buses = [b for b in buses if b in common_buses]
if not valid_buses:
return ""
return "+".join(sorted(valid_buses))
def group_components_by_signature(
components: dict[str, dict[str, list[str]]], platform: str
) -> dict[str, list[str]]:
"""Group components by their bus signature for a specific platform.
Args:
components: Component analysis results from analyze_all_components()
platform: Platform to group for (e.g., "esp32-ard")
Returns:
Dictionary mapping signature to list of component names
Example: {"i2c+uart_19200": ["comp1", "comp2"], "spi": ["comp3"]}
"""
signature_groups: dict[str, list[str]] = {}
for component_name, platform_buses in components.items():
if platform not in platform_buses:
continue
signature = create_grouping_signature(platform_buses, platform)
if not signature:
continue
if signature not in signature_groups:
signature_groups[signature] = []
signature_groups[signature].append(component_name)
return signature_groups
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Analyze component test files to detect common bus usage"
)
parser.add_argument(
"--components",
"-c",
nargs="+",
help="Specific components to analyze (default: all)",
)
parser.add_argument(
"--platform",
"-p",
help="Show grouping for a specific platform",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
parser.add_argument(
"--group",
action="store_true",
help="Show component groupings by bus signature",
)
args = parser.parse_args()
# Analyze components
tests_dir = Path("tests/components")
if args.components:
# Analyze only specified components
components = {}
non_groupable = set()
direct_bus_components = set()
for comp in args.components:
comp_dir = tests_dir / comp
platform_buses, has_extend_remove, has_direct_bus_config = (
analyze_component(comp_dir)
)
if platform_buses:
components[comp] = platform_buses
# Note: Components using $component_dir are now groupable
if comp in ISOLATED_COMPONENTS:
non_groupable.add(comp)
if comp in BASE_BUS_COMPONENTS:
non_groupable.add(comp)
if has_direct_bus_config and comp not in BASE_BUS_COMPONENTS:
non_groupable.add(comp)
direct_bus_components.add(comp)
else:
# Analyze all components
components, non_groupable, direct_bus_components = analyze_all_components(
tests_dir
)
# Output results
if args.group and args.platform:
# Show groupings for a specific platform
groups = group_components_by_signature(components, args.platform)
if args.json:
print(json.dumps(groups, indent=2))
else:
print(f"Component groupings for {args.platform}:")
print()
for signature, comp_list in sorted(groups.items()):
print(f" {signature}:")
for comp in sorted(comp_list):
print(f" - {comp}")
print()
elif args.json:
# JSON output
print(json.dumps(components, indent=2))
else:
# Human-readable output
for component, platform_buses in sorted(components.items()):
non_groupable_marker = (
" [NON-GROUPABLE]" if component in non_groupable else ""
)
print(f"{component}{non_groupable_marker}:")
for platform, buses in sorted(platform_buses.items()):
bus_str = ", ".join(buses)
print(f" {platform}: {bus_str}")
print()
print(f"Total components analyzed: {len(components)}")
if non_groupable:
print(f"Non-groupable components (use local files): {len(non_groupable)}")
for comp in sorted(non_groupable):
print(f" - {comp}")
if __name__ == "__main__":
main()

View File

@@ -237,6 +237,16 @@ def main() -> None:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
changed_components = parse_list_components_output(result.stdout)
# Filter to only components that have test files
# Components without tests shouldn't generate CI test jobs
tests_dir = Path(root_path) / "tests" / "components"
changed_components_with_tests = [
component
for component in changed_components
if (component_test_dir := tests_dir / component).exists()
and any(component_test_dir.glob("test.*.yaml"))
]
# Build output
output: dict[str, Any] = {
"integration_tests": run_integration,
@@ -244,7 +254,8 @@ def main() -> None:
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"changed_components": changed_components,
"component_test_count": len(changed_components),
"changed_components_with_tests": changed_components_with_tests,
"component_test_count": len(changed_components_with_tests),
}
# Output as JSON

379
script/merge_component_configs.py Executable file
View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Merge multiple component test configurations into a single test file.
This script combines multiple component test files that use the same common bus
configurations into a single merged test file. This allows testing multiple
compatible components together, reducing CI build time.
The merger handles:
- Component-specific substitutions (prefixing to avoid conflicts)
- Multiple instances of component configurations
- Shared common bus packages (included only once)
- Platform-specific configurations
- Uses ESPHome's built-in merge_config for proper YAML merging
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import sys
from typing import Any
# Add esphome to path so we can import from it
sys.path.insert(0, str(Path(__file__).parent.parent))
from esphome import yaml_util
from esphome.config_helpers import merge_config
from script.analyze_component_buses import PACKAGE_DEPENDENCIES, get_common_bus_packages
def load_yaml_file(yaml_file: Path) -> dict:
"""Load YAML file using ESPHome's YAML loader.
Args:
yaml_file: Path to the YAML file
Returns:
Parsed YAML as dictionary
"""
if not yaml_file.exists():
raise FileNotFoundError(f"YAML file not found: {yaml_file}")
return yaml_util.load_yaml(yaml_file)
def extract_packages_from_yaml(data: dict) -> dict[str, str]:
"""Extract COMMON BUS package includes from parsed YAML.
Only extracts packages that are from test_build_components/common/,
ignoring component-specific packages.
Args:
data: Parsed YAML dictionary
Returns:
Dictionary mapping package name to include path (as string representation)
Only includes common bus packages (i2c, spi, uart, etc.)
"""
if "packages" not in data:
return {}
packages_value = data["packages"]
if not isinstance(packages_value, dict):
# List format doesn't include common bus packages (those use dict format)
return {}
# Get common bus package names (cached)
common_bus_packages = get_common_bus_packages()
packages = {}
# Dictionary format: packages: {name: value}
for name, value in packages_value.items():
# Only include common bus packages, ignore component-specific ones
if name not in common_bus_packages:
continue
packages[name] = str(value)
# Also track package dependencies (e.g., modbus includes uart)
if name not in PACKAGE_DEPENDENCIES:
continue
for dep in PACKAGE_DEPENDENCIES[name]:
if dep not in common_bus_packages:
continue
# Mark as included via dependency
packages[f"_dep_{dep}"] = f"(included via {name})"
return packages
def prefix_substitutions_in_dict(
data: Any, prefix: str, exclude: set[str] | None = None
) -> Any:
"""Recursively prefix all substitution references in a data structure.
Args:
data: YAML data structure (dict, list, or scalar)
prefix: Prefix to add to substitution names
exclude: Set of substitution names to exclude from prefixing
Returns:
Data structure with prefixed substitution references
"""
if exclude is None:
exclude = set()
def replace_sub(text: str) -> str:
"""Replace substitution references in a string."""
def replace_match(match):
sub_name = match.group(1)
if sub_name in exclude:
return match.group(0)
# Always use braced format in output for consistency
return f"${{{prefix}_{sub_name}}}"
# Match both ${substitution} and $substitution formats
return re.sub(r"\$\{?(\w+)\}?", replace_match, text)
if isinstance(data, dict):
result = {}
for key, value in data.items():
result[key] = prefix_substitutions_in_dict(value, prefix, exclude)
return result
if isinstance(data, list):
return [prefix_substitutions_in_dict(item, prefix, exclude) for item in data]
if isinstance(data, str):
return replace_sub(data)
return data
def deduplicate_by_id(data: dict) -> dict:
"""Deduplicate list items with the same ID.
Keeps only the first occurrence of each ID. If items with the same ID
are identical, this silently deduplicates. If they differ, the first
one is kept (ESPHome's validation will catch if this causes issues).
Args:
data: Parsed config dictionary
Returns:
Config with deduplicated lists
"""
if not isinstance(data, dict):
return data
result = {}
for key, value in data.items():
if isinstance(value, list):
# Check for items with 'id' field
seen_ids = set()
deduped_list = []
for item in value:
if isinstance(item, dict) and "id" in item:
item_id = item["id"]
if item_id not in seen_ids:
seen_ids.add(item_id)
deduped_list.append(item)
# else: skip duplicate ID (keep first occurrence)
else:
# No ID, just add it
deduped_list.append(item)
result[key] = deduped_list
elif isinstance(value, dict):
# Recursively deduplicate nested dicts
result[key] = deduplicate_by_id(value)
else:
result[key] = value
return result
def merge_component_configs(
component_names: list[str],
platform: str,
tests_dir: Path,
output_file: Path,
) -> None:
"""Merge multiple component test configs into a single file.
Args:
component_names: List of component names to merge
platform: Platform to merge for (e.g., "esp32-ard")
tests_dir: Path to tests/components directory
output_file: Path to output merged config file
"""
if not component_names:
raise ValueError("No components specified")
# Track packages to ensure they're identical
all_packages = None
# Start with empty config
merged_config_data = {}
# Process each component
for comp_name in component_names:
comp_dir = tests_dir / comp_name
test_file = comp_dir / f"test.{platform}.yaml"
if not test_file.exists():
raise FileNotFoundError(f"Test file not found: {test_file}")
# Load the component's test file
comp_data = load_yaml_file(test_file)
# Validate packages are compatible
# Components with no packages (no_buses) can merge with any group
comp_packages = extract_packages_from_yaml(comp_data)
if all_packages is None:
# First component - set the baseline
all_packages = comp_packages
elif not comp_packages:
# This component has no packages (no_buses) - it can merge with any group
pass
elif not all_packages:
# Previous components had no packages, but this one does - adopt these packages
all_packages = comp_packages
elif comp_packages != all_packages:
# Both have packages but they differ - this is an error
raise ValueError(
f"Component {comp_name} has different packages than previous components. "
f"Expected: {all_packages}, Got: {comp_packages}. "
f"All components must use the same common bus configs to be merged."
)
# Handle $component_dir by replacing with absolute path
# This allows components that use local file references to be grouped
comp_abs_dir = str(comp_dir.absolute())
# Save top-level substitutions BEFORE expanding packages
# In ESPHome, top-level substitutions override package substitutions
top_level_subs = (
comp_data["substitutions"].copy()
if "substitutions" in comp_data and comp_data["substitutions"] is not None
else {}
)
# Expand packages - but we'll restore substitution priority after
if "packages" in comp_data:
packages_value = comp_data["packages"]
if isinstance(packages_value, dict):
# Dict format - check each package
common_bus_packages = get_common_bus_packages()
for pkg_name, pkg_value in list(packages_value.items()):
if pkg_name in common_bus_packages:
continue
if not isinstance(pkg_value, dict):
continue
# Component-specific package - expand its content into top level
comp_data = merge_config(comp_data, pkg_value)
elif isinstance(packages_value, list):
# List format - expand all package includes
for pkg_value in packages_value:
if not isinstance(pkg_value, dict):
continue
comp_data = merge_config(comp_data, pkg_value)
# Remove all packages (common will be re-added at the end)
del comp_data["packages"]
# Restore top-level substitution priority
# Top-level substitutions override any from packages
if "substitutions" not in comp_data or comp_data["substitutions"] is None:
comp_data["substitutions"] = {}
# Merge: package subs as base, top-level subs override
comp_data["substitutions"].update(top_level_subs)
# Now prefix the final merged substitutions
comp_data["substitutions"] = {
f"{comp_name}_{sub_name}": sub_value
for sub_name, sub_value in comp_data["substitutions"].items()
}
# Add component_dir substitution with absolute path for this component
comp_data["substitutions"][f"{comp_name}_component_dir"] = comp_abs_dir
# Prefix substitution references throughout the config
comp_data = prefix_substitutions_in_dict(comp_data, comp_name)
# Use ESPHome's merge_config to merge this component into the result
# merge_config handles list merging with ID-based deduplication automatically
merged_config_data = merge_config(merged_config_data, comp_data)
# Add packages back (only once, since they're identical)
# IMPORTANT: Only re-add common bus packages (spi, i2c, uart, etc.)
# Do NOT re-add component-specific packages as they contain unprefixed $component_dir refs
if all_packages:
first_comp_data = load_yaml_file(
tests_dir / component_names[0] / f"test.{platform}.yaml"
)
if "packages" in first_comp_data and isinstance(
first_comp_data["packages"], dict
):
# Filter to only include common bus packages
# Only dict format can contain common bus packages
common_bus_packages = get_common_bus_packages()
filtered_packages = {
name: value
for name, value in first_comp_data["packages"].items()
if name in common_bus_packages
}
if filtered_packages:
merged_config_data["packages"] = filtered_packages
# Deduplicate items with same ID (keeps first occurrence)
merged_config_data = deduplicate_by_id(merged_config_data)
# Remove esphome section since it will be provided by the wrapper file
# The wrapper file includes this merged config via packages and provides
# the proper esphome: section with name, platform, etc.
if "esphome" in merged_config_data:
del merged_config_data["esphome"]
# Write merged config
output_file.parent.mkdir(parents=True, exist_ok=True)
yaml_content = yaml_util.dump(merged_config_data)
output_file.write_text(yaml_content)
print(f"Successfully merged {len(component_names)} components into {output_file}")
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Merge multiple component test configs into a single file"
)
parser.add_argument(
"--components",
"-c",
required=True,
help="Comma-separated list of component names to merge",
)
parser.add_argument(
"--platform",
"-p",
required=True,
help="Platform to merge for (e.g., esp32-ard)",
)
parser.add_argument(
"--output",
"-o",
required=True,
type=Path,
help="Output file path for merged config",
)
parser.add_argument(
"--tests-dir",
type=Path,
default=Path("tests/components"),
help="Path to tests/components directory",
)
args = parser.parse_args()
component_names = [c.strip() for c in args.components.split(",")]
try:
merge_component_configs(
component_names=component_names,
platform=args.platform,
tests_dir=args.tests_dir,
output_file=args.output,
)
except Exception as e:
print(f"Error merging configs: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

268
script/split_components_for_ci.py Executable file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Split components into batches with intelligent grouping.
This script analyzes components to identify which ones share common bus configurations
and intelligently groups them into batches to maximize the efficiency of the
component grouping system in CI.
Components with the same bus signature are placed in the same batch whenever possible,
allowing the test_build_components.py script to merge them into single builds.
"""
from __future__ import annotations
import argparse
from collections import defaultdict
import json
from pathlib import Path
import sys
# Add esphome to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from script.analyze_component_buses import (
ISOLATED_COMPONENTS,
NO_BUSES_SIGNATURE,
analyze_all_components,
create_grouping_signature,
)
# Weighting for batch creation
# Isolated components can't be grouped/merged, so they count as 10x
# Groupable components can be merged into single builds, so they count as 1x
ISOLATED_WEIGHT = 10
GROUPABLE_WEIGHT = 1
def has_test_files(component_name: str, tests_dir: Path) -> bool:
"""Check if a component has test files.
Args:
component_name: Name of the component
tests_dir: Path to tests/components directory
Returns:
True if the component has test.*.yaml files
"""
component_dir = tests_dir / component_name
if not component_dir.exists() or not component_dir.is_dir():
return False
# Check for test.*.yaml files
return any(component_dir.glob("test.*.yaml"))
def create_intelligent_batches(
components: list[str],
tests_dir: Path,
batch_size: int = 40,
) -> list[list[str]]:
"""Create batches optimized for component grouping.
Args:
components: List of component names to batch
tests_dir: Path to tests/components directory
batch_size: Target size for each batch
Returns:
List of component batches (lists of component names)
"""
# Filter out components without test files
# Platform components like 'climate' and 'climate_ir' don't have test files
components_with_tests = [
comp for comp in components if has_test_files(comp, tests_dir)
]
# Log filtered components to stderr for debugging
if len(components_with_tests) < len(components):
filtered_out = set(components) - set(components_with_tests)
print(
f"Note: Filtered {len(filtered_out)} components without test files: "
f"{', '.join(sorted(filtered_out))}",
file=sys.stderr,
)
# Analyze all components to get their bus signatures
component_buses, non_groupable, _direct_bus_components = analyze_all_components(
tests_dir
)
# Group components by their bus signature ONLY (ignore platform)
# All platforms will be tested by test_build_components.py for each batch
# Key: signature, Value: list of components
signature_groups: dict[str, list[str]] = defaultdict(list)
for component in components_with_tests:
# Components that can't be grouped get unique signatures
# This includes both manually curated ISOLATED_COMPONENTS and
# automatically detected non_groupable components
# These can share a batch/runner but won't be grouped/merged
if component in ISOLATED_COMPONENTS or component in non_groupable:
signature_groups[f"isolated_{component}"].append(component)
continue
# Get signature from any platform (they should all have the same buses)
# Components not in component_buses were filtered out by has_test_files check
comp_platforms = component_buses[component]
for platform, buses in comp_platforms.items():
if buses:
signature = create_grouping_signature({platform: buses}, platform)
# Group by signature only - platform doesn't matter for batching
signature_groups[signature].append(component)
break # Only use first platform for grouping
else:
# No buses found for any platform - can be grouped together
signature_groups[NO_BUSES_SIGNATURE].append(component)
# Create batches by keeping signature groups together
# Components with the same signature stay in the same batches
batches = []
# Sort signature groups to prioritize groupable components
# 1. Put "isolated_*" signatures last (can't be grouped with others)
# 2. Sort groupable signatures by size (largest first)
# 3. "no_buses" components CAN be grouped together
def sort_key(item):
signature, components = item
is_isolated = signature.startswith("isolated_")
# Put "isolated_*" last (1), groupable first (0)
# Within each category, sort by size (largest first)
return (is_isolated, -len(components))
sorted_groups = sorted(signature_groups.items(), key=sort_key)
# Strategy: Create batches using weighted sizes
# - Isolated components count as 10x (since they can't be grouped/merged)
# - Groupable components count as 1x (can be merged into single builds)
# - This distributes isolated components across more runners
# - Ensures each runner has a good mix of groupable vs isolated components
current_batch = []
current_weight = 0
for signature, group_components in sorted_groups:
is_isolated = signature.startswith("isolated_")
weight_per_component = ISOLATED_WEIGHT if is_isolated else GROUPABLE_WEIGHT
for component in group_components:
# Check if adding this component would exceed the batch size
if current_weight + weight_per_component > batch_size and current_batch:
# Start a new batch
batches.append(current_batch)
current_batch = []
current_weight = 0
# Add component to current batch
current_batch.append(component)
current_weight += weight_per_component
# Don't forget the last batch
if current_batch:
batches.append(current_batch)
return batches
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Split components into intelligent batches for CI testing"
)
parser.add_argument(
"--components",
"-c",
required=True,
help="JSON array of component names",
)
parser.add_argument(
"--batch-size",
"-b",
type=int,
default=40,
help="Target batch size (default: 40, weighted)",
)
parser.add_argument(
"--tests-dir",
type=Path,
default=Path("tests/components"),
help="Path to tests/components directory",
)
parser.add_argument(
"--output",
"-o",
choices=["json", "github"],
default="github",
help="Output format (json or github for GitHub Actions)",
)
args = parser.parse_args()
# Parse component list from JSON
try:
components = json.loads(args.components)
except json.JSONDecodeError as e:
print(f"Error parsing components JSON: {e}", file=sys.stderr)
return 1
if not isinstance(components, list):
print("Components must be a JSON array", file=sys.stderr)
return 1
# Create intelligent batches
batches = create_intelligent_batches(
components=components,
tests_dir=args.tests_dir,
batch_size=args.batch_size,
)
# Convert batches to space-separated strings for CI
batch_strings = [" ".join(batch) for batch in batches]
if args.output == "json":
# Output as JSON array
print(json.dumps(batch_strings))
else:
# Output for GitHub Actions (set output)
output_json = json.dumps(batch_strings)
print(f"components={output_json}")
# Print summary to stderr so it shows in CI logs
# Count actual components being batched
actual_components = sum(len(batch.split()) for batch in batch_strings)
# Re-analyze to get isolated component counts for summary
_, non_groupable, _ = analyze_all_components(args.tests_dir)
# Count isolated vs groupable components
all_batched_components = [comp for batch in batches for comp in batch]
isolated_count = sum(
1
for comp in all_batched_components
if comp in ISOLATED_COMPONENTS or comp in non_groupable
)
groupable_count = actual_components - isolated_count
print("\n=== Intelligent Batch Summary ===", file=sys.stderr)
print(f"Total components requested: {len(components)}", file=sys.stderr)
print(f"Components with test files: {actual_components}", file=sys.stderr)
print(f" - Groupable (weight=1): {groupable_count}", file=sys.stderr)
print(f" - Isolated (weight=10): {isolated_count}", file=sys.stderr)
if actual_components < len(components):
print(
f"Components skipped (no test files): {len(components) - actual_components}",
file=sys.stderr,
)
print(f"Number of batches: {len(batches)}", file=sys.stderr)
print(f"Batch size target (weighted): {args.batch_size}", file=sys.stderr)
if len(batches) > 0:
print(
f"Average components per batch: {actual_components / len(batches):.1f}",
file=sys.stderr,
)
print(file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,106 +0,0 @@
#!/usr/bin/env bash
set -e
help() {
echo "Usage: $0 [-e <config|compile|clean>] [-c <string>] [-t <string>]" 1>&2
echo 1>&2
echo " - e - Parameter for esphome command. Default compile. Common alternative is config." 1>&2
echo " - c - Component folder name to test. Default *. E.g. '-c logger'." 1>&2
echo " - t - Target name to test. Put '-t list' to display all possibilities. E.g. '-t esp32-s2-idf-51'." 1>&2
exit 1
}
# Parse parameter:
# - `e` - Parameter for `esphome` command. Default `compile`. Common alternative is `config`.
# - `c` - Component folder name to test. Default `*`.
esphome_command="compile"
target_component="*"
while getopts e:c:t: flag
do
case $flag in
e) esphome_command=${OPTARG};;
c) target_component=${OPTARG};;
t) requested_target_platform=${OPTARG};;
\?) help;;
esac
done
cd "$(dirname "$0")/.."
if ! [ -d "./tests/test_build_components/build" ]; then
mkdir ./tests/test_build_components/build
fi
start_esphome() {
if [ -n "$requested_target_platform" ] && [ "$requested_target_platform" != "$target_platform_with_version" ]; then
echo "Skipping $target_platform_with_version"
return
fi
# create dynamic yaml file in `build` folder.
# `./tests/test_build_components/build/[target_component].[test_name].[target_platform_with_version].yaml`
component_test_file="./tests/test_build_components/build/$target_component.$test_name.$target_platform_with_version.yaml"
cp $target_platform_file $component_test_file
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS sed is...different
sed -i '' "s!\$component_test_file!../../.$f!g" $component_test_file
else
sed -i "s!\$component_test_file!../../.$f!g" $component_test_file
fi
# Start esphome process
echo "> [$target_component] [$test_name] [$target_platform_with_version]"
set -x
# TODO: Validate escape of Command line substitution value
python3 -m esphome -s component_name $target_component -s component_dir ../../components/$target_component -s test_name $test_name -s target_platform $target_platform $esphome_command $component_test_file
{ set +x; } 2>/dev/null
}
# Find all test yaml files.
# - `./tests/components/[target_component]/[test_name].[target_platform].yaml`
# - `./tests/components/[target_component]/[test_name].all.yaml`
for f in ./tests/components/$target_component/*.*.yaml; do
[ -f "$f" ] || continue
IFS='/' read -r -a folder_name <<< "$f"
target_component="${folder_name[3]}"
IFS='.' read -r -a file_name <<< "${folder_name[4]}"
test_name="${file_name[0]}"
target_platform="${file_name[1]}"
file_name_parts=${#file_name[@]}
if [ "$target_platform" = "all" ] || [ $file_name_parts = 2 ]; then
# Test has *not* defined a specific target platform. Need to run tests for all possible target platforms.
for target_platform_file in ./tests/test_build_components/build_components_base.*.yaml; do
IFS='/' read -r -a folder_name <<< "$target_platform_file"
IFS='.' read -r -a file_name <<< "${folder_name[3]}"
target_platform="${file_name[1]}"
start_esphome
done
else
# Test has defined a specific target platform.
# Validate we have a base test yaml for selected platform.
# The target_platform is sourced from the following location.
# 1. `./tests/test_build_components/build_components_base.[target_platform].yaml`
# 2. `./tests/test_build_components/build_components_base.[target_platform]-ard.yaml`
target_platform_file="./tests/test_build_components/build_components_base.$target_platform.yaml"
if ! [ -f "$target_platform_file" ]; then
echo "No base test file [./tests/test_build_components/build_components_base.$target_platform.yaml] for component test [$f] found."
exit 1
fi
for target_platform_file in ./tests/test_build_components/build_components_base.$target_platform*.yaml; do
# trim off "./tests/test_build_components/build_components_base." prefix
target_platform_with_version=${target_platform_file:52}
# ...now remove suffix starting with "." leaving just the test target hardware and software platform (possibly with version)
# For example: "esp32-s3-idf-50"
target_platform_with_version=${target_platform_with_version%.*}
start_esphome
done
fi
done

View File

@@ -0,0 +1 @@
test_build_components.py

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