Compare commits

...
Author SHA1 Message Date
Jesse Hills 9d530f5c56 Merge pull request #20680 from esphome/bump-2026.10.0b4
2026.10.0b4
2026-10-14 11:14:49 +13:00
Jesse Hills 5a78f636b9 Bump version to 2026.10.0b4 2026-10-14 10:43:50 +13:00
tomaszduda23andClaude 53d504137b [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 10:43:49 +13:00
bb0546fc0b [script] Keep the running script.wait item out of the queue while its chain runs (#20570)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-14 10:43:49 +13:00
emes30andJ. Nick Koston b43630cf8e [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-14 10:43:49 +13:00
Firat HajiyevandFirat17aze c9664d4e42 [wifi] Reject unsupported WPA3 on ESP8266 (#20592)
Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com>
2026-10-14 10:43:49 +13:00
Kevin Ahrendt 39ea6a3220 [audio] Bump microFLAC to v0.3.0 (#20591) 2026-10-14 10:43:49 +13:00
J. Nick Koston f4a6d6f18b [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) 2026-10-14 10:43:49 +13:00
esphome[bot] 6c24d4aa43 Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) 2026-10-14 10:43:49 +13:00
Jesse Hills 2c4b1f3ed5 Merge pull request #20542 from esphome/bump-2026.10.0b3
2026.10.0b3
2026-10-12 15:42:25 +13:00
J. Nick Koston 0df7b0b366 [core] Move zstd_module from the api wizard to esphome.helpers (#20342) 2026-10-12 12:46:59 +13:00
Jesse Hills bd4b32873a Bump version to 2026.10.0b3 2026-10-12 11:52:33 +13:00
Bascht74 9e580db434 [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) 2026-10-12 11:52:31 +13:00
Jesse Hills 87187c5391 [api] Let a wizard input pick multiple entities (#20537) 2026-10-12 11:52:31 +13:00
tomaszduda23andClaude 60402614cd [nrf52] fix zigbee time component (#20527)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 11:52:25 +13:00
puddly 2727d35655 [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) 2026-10-12 11:51:44 +13:00
J. Nick Koston fb1ef29d54 [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) 2026-10-12 11:51:39 +13:00
Frédéric MetrichandClaude Opus 5.5 7c0f63ea20 [core] Copy trigger arguments in wait_until and script.wait (#20536)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
heikkikiiandJ. Nick Koston 5d75828ceb [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
bd2e8d184d [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
J. Nick Koston 3c7ce71d6f [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) 2026-10-12 11:46:44 +13:00
Frédéric MetrichandClaude Opus 5.5 58e03a875f [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
Clyde StubbsandClaude Opus 5.5 41a9fca881 [ci] Reset sdkconfig after ESP-IDF component discovery (#20511)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:46:44 +13:00
ec58e864f5 [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:44 +13:00
esphome[bot] 375fbd829e Bump bundled esphome-device-builder to 1.23.1 (#20497) 2026-10-12 11:46:44 +13:00
4bae89bd4a [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-12 11:46:43 +13:00
Bascht74andJ. Nick Koston 897b2f16a9 [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:46:43 +13:00
tomaszduda23 93c8796ae8 [nrf52] fix build error on 3.4.0 with pch ON (#20474) 2026-10-12 11:46:43 +13:00
J. Nick Koston 654afb536e [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) 2026-10-12 11:46:43 +13:00
jabla 97154f9e5f [espidf] Clean the build when the IDF target changes (#20473) 2026-10-12 11:46:43 +13:00
c9e49b8e8f [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-12 11:42:45 +13:00
J. Nick Koston 3ae52e21cf [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) 2026-10-12 11:38:38 +13:00
Zhaoqi Xu f2395f9001 [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) 2026-10-12 11:38:38 +13:00
Ali Alaeiandtomaszduda23 3d248496ab [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319)
Co-authored-by: tomaszduda23 <tomaszduda23@gmail.com>
2026-10-12 11:38:38 +13:00
esphome[bot] 34eca948c4 Bump bundled esphome-device-builder to 1.23.0 (#20446) 2026-10-12 11:38:37 +13:00
J. Nick Koston 58815575ef [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) 2026-10-12 11:38:37 +13:00
J. Nick Koston 28b012389d [ci] Keep bus group partners on the same runner (#20431) 2026-10-12 11:38:37 +13:00
J. Nick Koston 54f86537ad [esp32][libretiny] Skip registering static destructors that never run (#20440) 2026-10-12 11:38:37 +13:00
J. Nick Koston 3284d93a33 [nrf52] Fake a larger flash in testing mode (#20439) 2026-10-12 11:38:37 +13:00
J. Nick Koston 01686a5f67 [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) 2026-10-12 11:38:37 +13:00
J. Nick Koston 3b0c485701 [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) 2026-10-12 11:38:37 +13:00
J. Nick Koston e61d348f0c [wifi] Use numeric scheduler ids instead of string names (#20357) 2026-10-12 11:38:37 +13:00
J. Nick Koston 22e594bc62 [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) 2026-10-12 11:38:37 +13:00
J. Nick Koston 061733672f [ci] Refit component batch estimates to a full run (#20401) 2026-10-12 11:38:37 +13:00
J. Nick Koston b1f5da9561 [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) 2026-10-12 11:38:37 +13:00
J. Nick Koston db517059a3 [ota] Collapse duplicate CI test variants (#20407) 2026-10-12 11:38:37 +13:00
J. Nick Koston 0b40892775 [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) 2026-10-12 11:38:37 +13:00
J. Nick Koston 8e6ed71e2a [ethernet] Collapse duplicate CI test variants (#20406) 2026-10-12 11:38:37 +13:00
J. Nick Koston cf569702c4 [ci] Stop the toolchain tests from installing ESP-IDF (#20413) 2026-10-12 11:38:37 +13:00
Jesse Hills 2affc04e62 Merge pull request #20421 from esphome/bump-2026.10.0b2
2026.10.0b2
2026-10-09 15:42:04 +13:00
Jesse Hills a648822027 Bump version to 2026.10.0b2 2026-10-09 13:45:36 +13:00
J. Nick Koston c6244fa4b9 [esp32] Record the PC for cache error and interrupt watchdog crashes (#20415) 2026-10-09 13:45:35 +13:00
J. Nick Koston f6746b4977 [api] Fix C++ unit test link failure when api is only a dependency (#20418) 2026-10-09 13:45:35 +13:00
J. Nick Koston f1f1a77151 [ci] Deal clang-tidy files across five shards (#20411) 2026-10-09 13:45:35 +13:00
J. Nick Koston ba1af627fd [ci] Pin the shared I2C bus in test configs that left it implicit (#20412) 2026-10-09 13:45:35 +13:00
J. Nick Koston b82dfd7e64 [mdns] Bump espressif/mdns to 1.14.0 (#20383) 2026-10-09 13:45:35 +13:00
Kevin AhrendtandJ. Nick Koston 7de2bcb584 [api][sendspin] Add a request for the Sendspin pairing token (#20355)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-09 13:45:35 +13:00
esphome[bot] d5d11e1077 Bump aioesphomeapi from 46.8.0 to 46.9.0 (#20358) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt ab66b20944 [sendspin] Add an unpaired access switch (#20350) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt baf1682635 [sendspin] Add the dynamic pairing code (#20349) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt eea11834f8 [sendspin] Add the static pairing code, pairing window and unpaired access option (#20348) 2026-10-09 13:45:34 +13:00
Kevin Ahrendt 1d86cc39e9 [sendspin] Update to sendspin-cpp v0.9.3 (#20347) 2026-10-09 13:45:34 +13:00
J. Nick Koston 686252f151 [esp8266] Fix Arduino core regressions that keep data in RAM and link the unused unwinder (#20337) 2026-10-09 13:45:23 +13:00
J. Nick Koston f5c15f66a5 [esp8266] Force-include the throw stubs into the core and libraries on the native toolchain (#20336) 2026-10-09 13:45:23 +13:00
J. Nick Koston 9d6b50320d [noise] Bump noise-c to 0.1.31 and libsodium to 1.10021.12 (#20345) 2026-10-09 13:45:23 +13:00
Jesse HillsandJ. Nick Koston b903c54be4 [combination] Use cv.rename_key for the deprecated coeffecient key (#20159)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-09 13:45:23 +13:00
esphome[bot] 63b4a59588 Bump bundled esphome-device-builder to 1.22.0 (#20334) 2026-10-09 13:45:23 +13:00
Jesse Hills adc6255fd4 [core] Let cv.rename_key pass non-dict input through (#20331) 2026-10-09 13:45:23 +13:00
J. Nick Koston b28e7a1357 [esp8266] Normalize misspelled boards and fall back to PlatformIO for unknown ones (#20322) 2026-10-09 13:45:23 +13:00
J. Nick Koston e7ba9a5726 [api] Add a progmem option for bytes fields kept in flash (#20318) 2026-10-09 13:45:22 +13:00
J. Nick Koston 2586e78127 [core] Warn on every run that Intel Mac support ends by 2027.6.0 (#20303) 2026-10-09 13:45:22 +13:00
J. Nick Koston 23f6dedb42 Pin smp to 4.1.0 on Intel macOS so cbor2 installs from a wheel (#20302) 2026-10-09 13:45:22 +13:00
David Buezas 10f30ddb59 [cc1101] Keep the remote_receiver interrupt on a shared GDO0 pin in begin_tx (#20296) 2026-10-09 13:45:22 +13:00
Keith BurzinskiandClaude Opus 5.5 96a66f3ddd [core] Wake the main loop on its own task notification index on ESP32 (#20326)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:45:22 +13:00
esphome[bot] 5bf8ceac66 Bump aioesphomeapi from 46.7.0 to 46.8.0 (#20321) 2026-10-09 13:45:22 +13:00
mrtoy-me 4d42352ec5 [tas58xx] Improve TAS5805M audio performance and use GPIO1 for TAS5825M faults (#20319) 2026-10-09 13:45:22 +13:00
esphome[bot] 479232b6c0 Bump aioesphomeapi from 46.6.0 to 46.7.0 (#20317) 2026-10-09 13:45:22 +13:00
Jesse Hills 33cf262616 Merge pull request #20305 from esphome/bump-2026.10.0b1
2026.10.0b1
2026-10-08 12:14:49 +13:00
Jesse Hills 5e4990badc Bump version to 2026.10.0b1 2026-10-08 09:27:43 +13:00
J. Nick Koston 16568c0453 [sx126x] Keep constant send_packet payloads in shared flash tables (#20273) 2026-10-07 10:24:14 -10:00
J. Nick Koston 677c089614 [sx127x] Keep constant send_packet payloads in shared flash tables (#20275) 2026-10-07 10:23:59 -10:00
J. Nick Koston 6db2e781ce [api] Keep Home Assistant action fields in a flash table (#20237) 2026-10-07 10:09:49 -10:00
J. Nick Koston 3296bccd3c [api] Keep the protobuf write cursor in a register and emit sub-message tags inline (#20124) 2026-10-07 10:08:25 -10:00
J. Nick Koston 0323fd36d4 [core] Clone git PlatformIO packages in parallel in the prefetch (#18837) 2026-10-08 09:00:07 +13:00
J. Nick Koston 1ba323e9a3 [core] Keep log tags in flash on ESP8266 with ESPHOME_LOG_TAG (#20290) 2026-10-07 09:51:17 -10:00
Jesse HillsandJ. Nick Koston 6f940a486b [api] Add a device wizard for Home Assistant onboarding (#19966)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-07 09:49:31 -10:00
dependabot[bot] 82cffe81f3 Bump platformdirs from 4.12.2 to 4.12.3 (#20297)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-07 19:30:59 +00:00
dependabot[bot] 887e3c302f Bump filelock from 4.0.9 to 4.0.10 (#20298)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-07 19:30:26 +00:00
TheSwert 2560a4436e [audio_http][core] Add ca_certificate_path to audio_http media source (#19392) 2026-10-07 09:28:10 -10:00
Clyde StubbsandClaude Sonnet 5 6dec759261 [core] Select I/O expander pin providers by I2C address (#18939)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 09:18:40 -10:00
1c0d406cbb [serial_proxy] Add USB_SERIAL port type and device identity (#19308)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-10-07 19:05:07 +00:00
Jesse Hills ae6ebd2d9d [epaper_spi] Add is_updating condition (#20261) 2026-10-07 14:52:25 -04:00
J. Nick Koston 13a8efd694 [api] Reply when an infrared/RF raw timings transmit has completed (#19086) 2026-10-07 08:25:44 -10:00
678975b6b2 [usb_host] iManufacturer and iProduct descriptor filters (#19095)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-10-07 13:25:28 -05:00
006f5f0fc9 [xiaomi_mccgq02hl] Add Xiaomi Mijia Door/Window Sensor 2 (MCCGQ02HL) (#20104)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-07 07:09:35 -10:00
J. Nick Koston cf96c53279 [espnow] Keep constant send payloads in shared flash tables (#20271) 2026-10-07 05:40:12 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] 72413494b0 [esp32] Disable mbedTLS TLS and TLS-only crypto when no component needs them (#18877)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-07 05:29:17 -10:00
J. Nick Koston 51dbd3a63f [core] Test that if and while branches resume after delays, stops, nesting and script modes (#20282) 2026-10-07 08:33:20 -04:00
J. Nick Koston 19561d44a2 [cc1101] Cover shared, string and trigger argument send_packet payloads in the component test (#20263) 2026-10-07 08:32:18 -04:00
J. Nick Koston 1661f84deb [udp] Cover identical constant and trigger argument write payloads in the component test (#20266) 2026-10-07 08:32:04 -04:00
J. Nick Koston f0035c1db6 [sx127x] Cover shared constant, string and trigger argument send_packet payloads in the component test (#20274) 2026-10-07 08:31:24 -04:00
J. Nick Koston b78f00a037 [espnow] Cover a send payload lambda that reads trigger arguments in the component test (#20270) 2026-10-07 08:31:04 -04:00
J. Nick Koston 40809ffa10 [sx126x] Cover shared constant and trigger argument send_packet payloads in the component test (#20272) 2026-10-07 08:30:48 -04:00
luar123 e4d8913680 [zigbee] Add zigbee support for number on esp32 (#20243) 2026-10-07 08:21:17 -04:00
Bascht74 d234a4d51e [tcp_uart][uart_tcp] Add a disconnects sensor (#20096) 2026-10-06 20:45:29 -10:00
Raphael HehlandJ. Nick Koston 3cc89a35a2 [hoermann_hcp] Send each command once, and the lamp as a state (#19765)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-06 20:23:08 -10:00
Jonathan Swoboda 15aac378df [ci] Run label checks in parallel instead of cancelling them (#20259) 2026-10-06 20:19:41 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] 0e04b230fe [api] Add outgoing connections so the device can dial Home Assistant (or anything that implements native API) (#18919)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-07 16:22:14 +13:00
Guillaume RischardandClyde Stubbs f3f79c301b [mipi_spi] Add Waveshare ESP32-S3-Touch-LCD-1.46 SPD2010 model (#19056)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-10-07 13:24:08 +11:00
Francesco Pace 923700dcc9 [espectre] Add Wi-Fi CSI motion detection component (#19801) 2026-10-06 19:46:06 -05:00
Clyde Stubbs 19dce8840d [ci] Ignore esphome/idf_component.yml in external component bot (#20258) 2026-10-07 11:19:44 +11:00
Jesse Hills e040647530 [homeassistant] Add text, select and button platforms (#19964) 2026-10-07 11:46:36 +13:00
Bascht74andJ. Nick Koston ab5a4b4f04 [uart_tcp] Claim the UART, reject duplicate server ports, pace on loop_interval (#20180)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-06 15:50:28 -06:00
dependabot[bot] cb1b4b0bd7 Update tzdata requirement from >=2026.4 to >=2026.5 (#20250)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 17:07:13 -04:00
dependabot[bot] 597a4e46b1 Bump pylint from 4.1.1 to 4.1.2 (#20249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 17:06:37 -04:00
Clyde Stubbs e68a499429 [motion] Add gesture detection (#16787) 2026-10-06 15:57:20 -05:00
Jonathan Swoboda a56c85949a [wireguard] Bump the library to esphome/wireguard 0.4.8 (#20246) 2026-10-06 16:28:03 -04:00
J. Nick Koston 4eeb110247 [esp32] Extract prefetched ESP-IDF tool archives in parallel (#18841) 2026-10-07 09:26:46 +13:00
mrtoy-me 194273955c [tas58xx] Add support for tas5825 dac (#19971) 2026-10-06 15:22:00 -05:00
Clyde StubbsandClaude Opus 5.5 490d478e54 [mipi_spi] Light platform for AMOLED display brightness (#20212)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:02:34 +11:00
Keith BurzinskiandClaude Fable 5.1 1520d228bc [i2s_audio] Park the speaker data-out pin low at setup (#20226)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 11:43:49 -06:00
Bascht74 61ac0cb753 [tcp_uart] Keep received bytes readable after the connection drops (#20164) 2026-10-06 11:39:02 -06:00
dependabot[bot] 2b02602a49 Bump smpclient from 7.2.0 to 7.3.0 (#17443)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-06 11:37:14 -06:00
Bascht74andJ. Nick Koston cad670785b [socket] Add host tests for TcpClientLink and TcpListener (#20167)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-06 11:28:06 -06:00
Kevin Ahrendt 980fed9e96 [sendspin] Require a type on the switch platform (#20189) 2026-10-06 11:23:57 -06:00
Jonathan Swoboda f670993fe9 [ethernet] Pin the IDF version in the KSZ8851SNL test fixtures (#20245) 2026-10-06 11:40:53 -04:00
Jonathan Swoboda fb20966353 [wireguard] Restart the connection while the peer is offline so the endpoint is re-resolved (#20231) 2026-10-06 11:16:57 -04:00
Clyde Stubbs 74d0a90308 [esphome] Revise warning text for password use (#20230) 2026-10-06 11:11:25 -04:00
J. Nick Koston fef3804aa5 [espidf] Fix V1 ECDSA signed builds when skipping the bootloader (#20244) 2026-10-06 09:09:47 -06:00
luar123 e66ef45df7 [zigbee] add missing tests (#20234) 2026-10-06 11:08:01 -04:00
J. Nick Koston 1f53ccb572 [api] Cover Home Assistant action fields in the api test config (#20236) 2026-10-06 11:04:45 -04:00
b63794b53c [web_server_idf] Respect explicit HTTP header limit (#18657)
Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com>
Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
2026-10-06 11:00:50 -04:00
J. Nick Koston a69c648c6f [neopixelbus] Cover addressable_color_wipe with a random color on ESP8266 (#20221) 2026-10-06 09:04:52 -04:00
J. Nick Koston 29090d50a7 [event] Cover how configs and components read and set event types in the component test (#20216) 2026-10-06 09:04:37 -04:00
J. Nick Koston 9b1a75b0ed [fan] Cover preset mode reads and runtime sets in the component test (#20209) 2026-10-06 09:03:58 -04:00
J. Nick Koston b7ddd1daa2 [climate] Cover custom mode reads and runtime sets in the component test (#20208) 2026-10-06 09:03:20 -04:00
Bascht74 6002c28a8d [host] Ignore SIGPIPE so a write to a closed peer fails with EPIPE (#20228) 2026-10-06 22:38:16 +11:00
Bascht74 718b6651fe [ci] Run pylint when its version, dependencies or configuration change (#20202) 2026-10-05 21:20:28 -05:00
J. Nick Koston 6191b7553e [light] Cover get_effects() as published configs use it in the component test (#20203) 2026-10-05 21:07:37 -05:00
Bascht74 082a0b746a [lvgl] Remove redundant __aexit__ override (#20201) 2026-10-05 19:45:15 -05:00
J. Nick Koston 8d8bd73112 [ble_device_base] Keep MAC filter lists in shared flash tables (#20088) 2026-10-05 19:28:36 -05:00
J. Nick Koston 3a3707926d [wifi] Run the LN882x fallback AP on its own instead of retrying AP+STA every loop (#20166) 2026-10-05 19:25:14 -05:00
J. Nick Koston 80dc93d6ce [core] [select] Keep select options in flash with a shared ConstVector (#20117) 2026-10-05 19:24:31 -05:00
J. Nick Koston 40af90ad25 [logger] Use the static ring buffer structure as its handle on ESP32 (#20129) 2026-10-05 19:24:13 -05:00
J. Nick Koston 4097f45677 [sx1509] Keep the keypad keys in flash (#20111) 2026-10-05 19:23:39 -05:00
esphome[bot] 039f07d332 Bump bundled esphome-device-builder to 1.21.0 (#20199) 2026-10-05 22:10:00 +00:00
dependabot[bot] cdcc7bbf74 Bump ruff from 0.16.9 to 0.16.10 (#20196)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 22:07:49 +00:00
dependabot[bot]andJ. Nick Koston 9af9414dcc Bump cryptography from 48.0.1 to 50.0.2 (#20193)
Co-authored-by: J. Nick Koston <nick@koston.org>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 22:07:43 +00:00
dependabot[bot] cf334e8758 Bump pylint from 4.0.10 to 4.1.1 (#20194)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 22:07:37 +00:00
dependabot[bot]andJ. Nick Koston 888043762e Bump filelock from 4.0.6 to 4.0.9 (#20195)
Co-authored-by: J. Nick Koston <nick@koston.org>
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 22:07:33 +00:00
dependabot[bot] 7af96c8d36 Bump CodSpeedHQ/action from 5.2.1 to 5.4.0 (#20197)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 22:07:10 +00:00
J. Nick Koston a778baddca [usb_uart] Only compile the vendor drivers that are configured (#20190) 2026-10-05 17:56:10 -04:00
Samuel Sieb fc441646f4 [unicode][font] move extract_unicode_codepoint to new component (#19969) 2026-10-05 17:40:06 +00:00
J. Nick Koston 4a47a984a0 [audio_file] Keep audio files in flash instead of building them in setup() (#20072) 2026-10-05 12:41:33 -04:00
J. Nick Koston 8735aee847 [wifi] Leave the AP before an LN882x reboots (#20176) 2026-10-05 10:56:24 -05:00
J. Nick Koston c8eaf518ba [libretiny] Flag the LN882H SDK default MAC (#20175) 2026-10-05 10:56:02 -05:00
J. Nick Koston bb8c23805a [wifi] Connect LN882x to the BSSID asked for (#20174) 2026-10-05 10:55:11 -05:00
J. Nick Koston f334b9ea11 [core] Decode base64 in a single pass (#20123) 2026-10-05 10:54:17 -05:00
J. Nick Koston 666cf169b9 [remote_base] Keep RC switch protocols in flash (#20081) 2026-10-05 10:54:03 -05:00
J. Nick Koston a058937fe0 [api] Count packed varints a machine word at a time (#20122) 2026-10-05 10:53:47 -05:00
J. Nick Koston 417fdd9dc7 [nrf52] Share the ccache between devices (#20028) 2026-10-05 10:53:35 -05:00
Sebastian Noack bc388b1de5 [epaper_spi] Add SSD1681 and the Waveshare 1.54in V2 panel (#18073) 2026-10-05 11:51:49 -04:00
Jonathan Swoboda 9820896430 [wireguard] Bump the library to esphome/wireguard 0.4.7 (#20188) 2026-10-05 11:34:07 -04:00
J. Nick Koston 4258b226a4 [wifi] Default LN882x power save to none, as on BK72xx and RTL87xx (#20170) 2026-10-05 10:26:22 -05:00
J. Nick Koston 5be0aca6d2 [libretiny] Build LN882x with 1-byte enums to match the SDK's WiFi library (#20173) 2026-10-05 10:24:19 -05:00
J. Nick Koston 1110857ffa [wifi] Keep ESP8266's report of a replaced attempt from failing the next one (#20183) 2026-10-05 10:21:52 -05:00
J. Nick Koston 9a97419cfc [ethernet] Store PHY registers in a StaticVector sized from the config (#20131) 2026-10-05 10:21:23 -05:00
ca74e71348 [emontx] Use total state class for energy sensors (#20128)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-05 09:16:21 -05:00
Jonathan Swoboda 00303fb72b [esp32][api][noise] Resolve arduino's libsodium requirement to the converted esphome/libsodium (#20185) 2026-10-05 09:55:18 -04:00
Bascht74 aa02a0996a [socket][tcp_uart][uart_tcp] Reject an IPv6 host at config time (#20165) 2026-10-05 08:48:33 -05:00
Bascht74 59b49d34cc [tcp_uart][uart_tcp] Expose the no-arg load_settings() and mark UartTcp final (#20163) 2026-10-05 08:47:18 -05:00
Bascht74 862047386c [modbus] Reject send_wait_time and turnaround_time above 65535 ms (#20160) 2026-10-05 08:46:36 -05:00
Clyde Stubbs bbd05e4811 [esp32] Work around ESP-IDF stack bug on P4 (#20181) 2026-10-05 07:50:18 -05:00
J. Nick Koston a02fa71dc8 Merge pull request #20179 from esphome/sync/integration-durations 2026-10-05 07:48:03 -05:00
esphome[bot] 99fd1bca3e [ci] Refresh integration test durations 2026-10-05 05:58:43 +00:00
J. Nick Koston 2d73ea24ca [libretiny] Keep a scan that finds no networks from resetting the LN882x (#20169) 2026-10-04 21:23:09 -05:00
Max Slotov 645144ad4d [uart] Validate parity and stop bits correctly (#20161) 2026-10-05 13:21:48 +11:00
J. Nick Koston 88378ab354 [mipi_spi] Keep the init sequence in flash (#20089) 2026-10-04 21:21:28 -05:00
J. Nick Koston f91ac5ed1d [mipi_dsi] Keep the init sequence in flash (#20091) 2026-10-04 21:20:56 -05:00
J. Nick Koston 3b319240c6 [mipi_rgb] Keep the init sequence in flash (#20090) 2026-10-04 21:20:32 -05:00
Clyde StubbsandJ. Nick Koston d9b3f299e0 [exponential_moving_average] Add EMA preserved across reboots (#19882)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-05 13:19:12 +11:00
J. Nick Koston 6f2d2a2001 [libretiny] Fix LN882x boot crash with the logger or a uart on UART0 (#20162) 2026-10-05 14:53:47 +13:00
esphome[bot] 8b9d51cf93 Bump bundled esphome-device-builder to 1.20.0 (#20172)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-10-04 20:21:46 -05:00
J. Nick Koston 66eec7af15 [wifi] Keep a second fallback AP start from resetting the LN882x (#20168) 2026-10-05 13:58:35 +13:00
J. Nick Koston 60e511282a [udp] Keep addresses in a shared flash table (#20085) 2026-10-05 13:45:18 +13:00
J. Nick Koston e4be4c7fcb [select] Keep select.is option lists in flash (#20108) 2026-10-04 18:30:45 -05:00
J. Nick Koston 99bf080092 [select] Cover select.is in the component test (#20112) 2026-10-04 18:30:44 -05:00
J. Nick Koston 0017b9c439 [matrix_keypad] Keep the keys in flash (#20110) 2026-10-05 12:26:42 +13:00
J. Nick Koston ad1b6ab37e [web_server] Keep allowed origins in a shared flash table (#20086) 2026-10-05 12:23:36 +13:00
J. Nick Koston acf7257293 [core] Use the static queue structure as the FreeRTOSQueue handle (#20133) 2026-10-05 12:17:22 +13:00
J. Nick Koston b36c5441cb [esp32] Remove the unused loop_task_handle global (#20132) 2026-10-05 12:16:16 +13:00
J. Nick Koston feedea4954 [web_server] Remove dead ESP-IDF event source connect callback and pack expose_log_ (#20130) 2026-10-05 12:15:58 +13:00
J. Nick Koston 4f9cd7821a [core] Log ignored --skip-bootloader at debug level (#20059) 2026-10-05 12:14:42 +13:00
J. Nick Koston 31e0a31c65 [api] Skip the listen backlog setter when it matches the default (#20066) 2026-10-05 12:13:41 +13:00
J. Nick Koston e09cab94d4 [version] Skip the hide hash and hide timestamp setters when they match the defaults (#20067) 2026-10-05 12:13:01 +13:00
J. Nick Koston e708c85100 [uart] Skip the parity setter when it matches the default (#20069) 2026-10-05 12:12:34 +13:00
J. Nick Koston cfbbd13b86 [remote_receiver] Pack carrier_duty_percent_ next to with_dma_ (#20135) 2026-10-05 12:05:29 +13:00
J. Nick Koston 8f0e032b5e [esp32_ble] Group the one byte io_cap_ with the other byte fields (#20136) 2026-10-05 12:05:15 +13:00
J. Nick Koston 0dc10d9f30 [improv_ble] Pack the byte fields into the padding after connecting_sta_ (#20137) 2026-10-05 12:04:44 +13:00
J. Nick Koston c4aa8fa9c5 [wifi] Place ap_ before the byte fields so they pack after it (#20138) 2026-10-05 12:02:40 +13:00
J. Nick Koston 9b83f37b78 [core] Let the empty Scheduler mutex take no space on single-threaded platforms (#20139) 2026-10-05 12:02:27 +13:00
J. Nick Koston a1a4474a01 [script] Let the empty argument tuple take no space for scripts without parameters (#20140) 2026-10-05 12:02:24 +13:00
J. Nick Koston 3a06318b65 [template] Use distinct empty types so both unused select members take no space (#20141) 2026-10-05 12:02:18 +13:00
J. Nick Koston 2f9e37d6a9 [wifi] Let the empty pending_ flags struct take no space (#20142) 2026-10-05 12:00:23 +13:00
J. Nick Koston fe2e541dc8 [template] Pack restore_value_ next to optimistic_ in TemplateNumber (#20149) 2026-10-05 09:26:21 +13:00
J. Nick Koston 474c2791fe [ledc] Pack initialized_ next to bit_depth_ (#20150) 2026-10-05 09:25:35 +13:00
J. Nick Koston 7421b165e8 [rtttl] Pack state_ after wholenote_duration_ (#20151) 2026-10-05 09:25:28 +13:00
J. Nick Koston 52fc5f2c75 [ld2450] Pack direction_dedup_ after zone_config_ (#20152) 2026-10-05 09:25:25 +13:00
Jesse Hills 4f539d3391 [homeassistant] Use input_number.set_value for input_number entities (#19965) 2026-10-05 08:56:34 +13:00
guillempages aad7907c37 [http_request] Reduce log level for missing header (#18932) 2026-10-05 06:55:55 +11:00
J. Nick Koston fb8f679be9 [ring_buffer] Use the static ring buffer structure as its handle (#20134) 2026-10-04 15:40:57 -04:00
J. Nick Koston 84435e7d81 [sendspin] Pack mdns_advertised_ next to task_stack_in_psram_ (#20148) 2026-10-04 15:39:20 -04:00
J. Nick Koston d0473438d9 [i2s_audio] Pack pdm_ with the other microphone bools (#20145) 2026-10-04 15:38:54 -04:00
J. Nick Koston 2ee13d59ac [speaker] Pack mute_state_ next to state_ (#20143) 2026-10-04 15:38:32 -04:00
J. Nick Koston bccdbc23c4 [mixer] Pack has_contributed_ next to pause_state_ (#20144) 2026-10-04 15:38:12 -04:00
J. Nick Koston 4b5375145a [microphone] Move bits_per_sample_ to the end of MicrophoneSource (#20146) 2026-10-04 15:38:05 -04:00
J. Nick Koston c6e3a5a165 [voice_assistant] Group the one byte members at the end (#20147) 2026-10-04 15:37:12 -04:00
J. Nick Koston 63cb75726b [noise] Use the shared PROGMEM table helper for the PSK (#20101) 2026-10-04 13:06:05 -05:00
abderrahmen bejaoui b070c828b3 [es8311] Encode the REG02 MCLK pre-multiplier instead of writing the factor (#20121) 2026-10-04 11:37:03 +00:00
J. Nick Koston 2d56559f04 [lvgl] Reject ESP8266 at validation (#20099) 2026-10-03 06:36:33 -05:00
J. Nick Koston 8691a43879 [font] Keep the glyph table in flash (#20079) 2026-10-03 06:29:06 -05:00
J. Nick Koston f5c79eeec0 [qmi8658] Pin motion_id in test config to fix grouped component test conflict (#20075) 2026-10-02 19:29:34 -05:00
dependabot[bot] fcf8fe2c11 Bump pre-commit-ci/lite-action from 1.1.0 to 1.2.0 (#20057)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 14:38:02 -05:00
dependabot[bot] 4447f8a065 Bump platformdirs from 4.12.1 to 4.12.2 (#20054)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 14:37:45 -05:00
dependabot[bot] 911bd3dadb Bump pylint from 4.0.9 to 4.0.10 (#20056)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 14:37:34 -05:00
dependabot[bot] 03f264775f Bump filelock from 4.0.5 to 4.0.6 (#20055)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 14:37:16 -05:00
Bascht74andJ. Nick Koston fe5a155c13 [const] Hoist the allowed ips key to the shared constants (#20053)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 19:14:51 +00:00
25eaf1d025 [wifi] Start IPv6 link-local on connect, not on DHCPv4 lease (#19749)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-02 19:07:58 +00:00
Bascht74 5328e5f5e0 [tcp_uart] Add a server role with an IPv4 allow list (#20026) 2026-10-02 13:47:10 -05:00
de0a0b2af4 [ethernet] Fix IPv6 SLAAC: link-local on link-up, pass all-nodes multicast (#19752)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 13:46:16 -05:00
J. Nick Koston a5178a0f3d [bmp280_base][bmp3xx_base][bmp581_base][spa06_base][mlx90393] Use the sub_* helpers for optional child entities (#20051) 2026-10-02 14:33:03 -04:00
J. Nick Koston 483b9ebe2c [combination][libretiny][sound_level] Use the sub_* helpers for optional child entities (#20050) 2026-10-02 14:32:45 -04:00
J. Nick Koston 3cc4df4b3a [rd03d][sim800l][tas58xx][bedjet][hoermann_hcp] Use the sub_* helpers for optional child entities (#20049) 2026-10-02 14:32:26 -04:00
J. Nick Koston 8a49ba7e98 [iaqcore][t6615][hc8][as3935][tx20] Use the sub_* helpers for optional child entities (#20048) 2026-10-02 14:32:09 -04:00
J. Nick Koston b130c79267 [xiaomi_hhccpot002][xiaomi_miscale][xiaomi_wx08zm][radon_eye_rd200][am43] Use the sub_* helpers for optional child entities (#20047) 2026-10-02 14:32:00 -04:00
J. Nick Koston b0d5681022 [lc709203f][max17043][pulse_counter][pulse_meter][duty_time] Use the sub_* helpers for optional child entities (#20046) 2026-10-02 14:30:17 -04:00
J. Nick Koston b15bc083f6 [mcp9600][tem3200][kmeteriso][ufire_ec][ufire_ise] Use the sub_* helpers for optional child entities (#20045) 2026-10-02 14:30:01 -04:00
J. Nick Koston 690227f6d8 [honeywellabp][honeywellabp2_i2c][npi19][xgzp68xx][xdb401] Use the sub_* helpers for optional child entities (#20044) 2026-10-02 14:29:49 -04:00
J. Nick Koston 46b12b43b2 [ens210][htu31d][hyt271][honeywell_hih_i2c][ms8607] Use the sub_* helpers for optional child entities (#20043) 2026-10-02 14:29:27 -04:00
J. Nick Koston ba9d54b8ad [bmi160][mpu6050][mpu6886][mlx90614][max31855] Use the sub_* helpers for optional child entities (#20042) 2026-10-02 14:29:14 -04:00
J. Nick Koston 7169c0c930 [ags10][cm1106][sds011][pm1006][senseair] Use the sub_* helpers for optional child entities (#20041) 2026-10-02 14:28:59 -04:00
J. Nick Koston e3fcf173c2 [bmp085][dps310][lps22][mpl3115a2][ms5611] Use the sub_* helpers for optional child entities (#20040) 2026-10-02 14:28:44 -04:00
J. Nick Koston 6d33b0401d [nrf52] Use the shared managed ccache settings for the native build (#20027) 2026-10-02 13:28:40 -05:00
J. Nick Koston 78f39c7fee [hdc1080][hdc2010][hdc2080][hdc302x][hte501] Use the sub_* helpers for optional child entities (#20039) 2026-10-02 14:28:31 -04:00
J. Nick Koston 79ad67b56f [aht10][am2315c][am2320][dht][dht12] Use the sub_* helpers for optional child entities (#20038) 2026-10-02 14:28:18 -04:00
Bascht74andJ. Nick Koston b6c79fdc9a [socket] Share the server side as TcpListener (#20037)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 13:07:00 -05:00
J. Nick Koston cbe89c940d [atm90e32] Use the sub_* helpers for optional child entities (#20034) 2026-10-02 12:30:24 -04:00
J. Nick Koston 4f1be0fcb2 [shtcx] Use the sub_* helpers for optional child entities (#20033) 2026-10-02 12:29:58 -04:00
J. Nick Koston 25a30fc472 [mhz19] Use the sub_* helpers for optional child entities (#20032) 2026-10-02 12:29:37 -04:00
J. Nick Koston 2d286031a4 [sht3xd] Use the sub_* helpers for optional child entities (#20031) 2026-10-02 12:29:09 -04:00
J. Nick Koston 995109aa4f [es8388] Use the sub_* helpers for optional child entities (#20030) 2026-10-02 12:28:41 -04:00
Bascht74andJ. Nick Koston 5def4d46ca [socket] Add an IPv4 allow list (#20025)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 11:22:21 -05:00
J. Nick Koston a531ea07a2 [xiaomi_cgdk2] Use the sub_* helpers for optional child entities (#20020) 2026-10-02 12:11:02 -04:00
J. Nick Koston 984de58853 [xiaomi_mhoc401] Use the sub_* helpers for optional child entities (#20021) 2026-10-02 12:10:41 -04:00
J. Nick Koston 09ba060fd5 [xiaomi_xmwsdj04mmc] Use the sub_* helpers for optional child entities (#20022) 2026-10-02 12:10:15 -04:00
J. Nick Koston d0eddc8fc8 [xiaomi_cgpr1] Use the sub_* helpers for optional child entities (#20023) 2026-10-02 12:09:50 -04:00
J. Nick Koston bf176633b2 [xiaomi_mhoc303] Use the sub_* helpers for optional child entities (#20024) 2026-10-02 12:09:28 -04:00
26398747a1 [uart_tcp] Add a hardware UART copied to one TCP socket (#19887)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 10:23:29 -05:00
J. Nick Koston 6491eaeaae [tcp_uart] Report a failed flush when the flush drops the link (#20013) 2026-10-02 10:10:28 -05:00
J. Nick Koston 7096546d83 [nrf52] Create the precompiled header in ccache depend mode (#20019) 2026-10-02 15:07:13 +00:00
J. Nick Koston 1c31e9d28d [xiaomi_cgd1] Use the sub_* helpers for optional child entities (#20018) 2026-10-02 10:50:58 -04:00
J. Nick Koston f595d8677c [xiaomi_cgg1] Use the sub_* helpers for optional child entities (#20017) 2026-10-02 10:50:48 -04:00
J. Nick Koston 416d0c46ac [xiaomi_lywsdcgq] Use the sub_* helpers for optional child entities (#20016) 2026-10-02 10:50:37 -04:00
J. Nick Koston 97235a20b8 [xiaomi_lywsd02] Use the sub_* helpers for optional child entities (#20015) 2026-10-02 10:49:07 -04:00
J. Nick Koston 61446e3e4f [xiaomi_lywsd02mmc] Use the sub_* helpers for optional child entities (#20014) 2026-10-02 10:49:00 -04:00
1440998f3c [core] Add new options suspend_loop and loop_interval (#18136)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 09:09:14 -05:00
J. Nick Koston 4c9a44301d [zyaura] Use the sub_* helpers for optional child entities (#20008) 2026-10-02 09:07:52 -05:00
J. Nick Koston 54cb4c711a [mmc5983] Use the sub_* helpers for optional child entities (#20007) 2026-10-02 09:07:39 -05:00
J. Nick Koston 7b018b9dca [socket] Move the outgoing buffer into the TCP client link (#20010) 2026-10-02 09:07:35 -05:00
J. Nick Koston 65fef7d5d8 [cs5460a] Use the sub_* helpers for optional child entities (#20006) 2026-10-02 09:07:26 -05:00
J. Nick Koston 87d4bea24b [pm2005] Use the sub_* helpers for optional child entities (#20004) 2026-10-02 09:07:14 -05:00
J. Nick Koston d938bc9913 [ee895] Use the sub_* helpers for optional child entities (#20005) 2026-10-02 09:06:56 -05:00
Jonathan Swoboda a27c73b44e [wifi] Warn when eap is configured with esp32_hosted (#20011) 2026-10-02 09:38:20 -04:00
J. Nick Koston 0c0015ef88 [const] Move the role and reconnect interval keys to shared constants (#20009) 2026-10-02 08:35:07 -05:00
4724aea42e [tcp_uart] Add a TCP client that implements a UART (#19884)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-02 08:00:40 -05:00
J. Nick Koston d970e9292e [esphome][ota][core] Compress OTA uploads on ESP32, RP2040, LibreTiny and host (#19037) 2026-10-02 08:00:10 -05:00
J. Nick Koston 797682cccc [espidf] Skip the lwip sources that compile to empty objects (#20000) 2026-10-02 07:59:52 -05:00
J. Nick Koston a58f3d34aa [core] Compile util.cpp only on MQTT builds (#20003) 2026-10-02 07:59:36 -05:00
J. Nick Koston 3fd8f00e97 [socket] Compile the lookup and client link helpers only when required (#20002) 2026-10-02 07:31:41 -05:00
Jeff BrownandJ. Nick Koston 12b016e3d2 [substitutions] Support !include with a condition (#19455)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-02 07:27:29 -05:00
J. Nick Koston 8617368294 [espidf] Read the ESP-IDF version in process instead of starting idf_tools (#19998) 2026-10-01 19:48:00 -05:00
J. Nick Koston 6479e079d2 [espidf] Build all and size in one ninja run (#19997) 2026-10-01 19:39:18 -05:00
J. Nick Koston ff97353767 [espidf] Resolve the ESP-IDF tool paths once per build (#19996) 2026-10-01 19:39:06 -05:00
Bascht74andJ. Nick Koston b324a7c6e0 [socket] Add a reconnecting TCP client link (#19999)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-01 23:40:09 +00:00
99070187b8 [bluetooth_connection] Cancel pending Bluedroid opens on disconnect (#19859)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-01 21:15:23 +00:00
J. Nick Koston dff793a949 [espidf] Mirror registry components locally so configures skip the registry (#19949) 2026-10-01 15:20:27 -05:00
4075c15bea [stcc4] Add component for the Sensirion STCC4 CO2 sensor (#19434)
Co-authored-by: will <will-tm@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-01 15:17:48 -05:00
esphome[bot] 2d37c6f58b Bump bundled esphome-device-builder to 1.19.0 (#19995) 2026-10-01 14:51:43 -05:00
dependabot[bot] d49741dc10 Bump prek from 0.5.3 to 0.5.4 (#19993)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-01 14:48:16 -05:00
dependabot[bot] 8ac184228d Bump platformdirs from 4.12.0 to 4.12.1 (#19994)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-01 14:32:17 -05:00
dependabot[bot] 96c6120849 Bump zeroconf from 0.151.3 to 0.151.5 (#19992)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-01 14:32:05 -05:00
dependabot[bot] f7b4ea9f0e Bump filelock from 4.0.4 to 4.0.5 (#19991)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-01 14:31:37 -05:00
J. Nick Koston 7962b52d2a [ci] Skip the bootloader in the component batch build jobs (#19931) 2026-10-01 14:05:15 -05:00
bfb7e76b2e [light] Add color option to strobe and other color state (#19874)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-01 19:02:58 +00:00
J. Nick Koston 0ed997da92 [espidf] Add --skip-bootloader for OTA only builds (#19925) 2026-10-01 13:55:30 -05:00
J. Nick Koston 3ed1bc7e20 [esp32_ble_tracker] Guard Bluedroid against stale queued connection requests (#19375) 2026-10-01 13:50:46 -05:00
J. Nick Koston 5d4b436fc0 [mk2pvrouter] Fix an ignored qualifiers warning in the group length check (#19986) 2026-10-01 13:35:56 -05:00
J. Nick Koston 1346342250 [light] Fix a double promotion warning in the light test config (#19989) 2026-10-01 13:35:41 -05:00
J. Nick Koston 36bcce563a [sensor] Fix a double promotion warning in the NTC resistance filter (#19985) 2026-10-01 13:35:15 -05:00
J. Nick Koston e500ece4d5 [uart] Fix a maybe uninitialized warning in the fixed size read (#19988) 2026-10-01 13:35:01 -05:00
J. Nick Koston 2de4f4cf81 [speaker] Fix the fall through warning in the media player turn off command (#19987) 2026-10-01 13:34:43 -05:00
J. Nick Koston e9f0a53ec9 [display] Fix the null pointer comparison warning in the display log macro (#19984) 2026-10-01 13:34:17 -05:00
J. Nick Koston 3bae9e4ee2 [climate] Fix the restore state memset warning on every platform (#19983) 2026-10-01 13:33:50 -05:00
J. Nick KostonandJonathan Swoboda 788d2bc631 [ci] Balance component test batches by estimated build time (#19980)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-10-01 18:19:30 +00:00
Remco van Essen afba431600 [resampler] Add resampler microphone platform (#19953) 2026-10-01 13:15:13 -05:00
5e67258818 [noise] Import noise-c and libsodium as ESP-IDF managed components (#18351)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-01 12:59:00 -05:00
J. Nick Koston 879c5cfc5b [xiaomi_gcls002] Use the sub_* helpers for optional child entities (#19973) 2026-10-01 12:37:55 -04:00
J. Nick Koston b4cb164e5d [xiaomi_jqjcy01ym] Use the sub_* helpers for optional child entities (#19974) 2026-10-01 11:37:39 -05:00
J. Nick Koston af9c4cb778 [xiaomi_mjyd02yla] Use the sub_* helpers for optional child entities (#19975) 2026-10-01 12:37:12 -04:00
J. Nick Koston 852fb0c5a5 [midea] Use the sub_* helpers for optional child entities (#19976) 2026-10-01 12:37:09 -04:00
J. Nick Koston 9a855b4377 [ina260] Use the sub_* helpers for optional child entities (#19977) 2026-10-01 12:36:37 -04:00
eb423be9d2 [socket] Add an IPv4 lookup next to set_sockaddr (#19909)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-01 11:36:09 -05:00
J. Nick Koston 54f6fb0eb2 [espidf] Skip ldgen when only app code changes (#18857) 2026-10-01 11:25:48 -05:00
J. Nick Koston 826571518a [noise] Generate the responder ephemeral key ahead of the handshake (#19000) 2026-10-01 16:15:31 +00:00
56a7a11162 [openthread] rewrite openthread esp.cpp to use new esp functions (#16334)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-01 12:48:31 +00:00
Jeff BrownandJ. Nick Koston 2accef437f [pmsa003i] Fix spurious component failures and warnings on power up (#19933)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-01 12:46:49 +00:00
tomaszduda23andClaude Sonnet 4.6 5a3e95b161 [nrf52] Add nRF Connect SDK 3.4.0 / Zephyr 4.x support (#19863)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-01 07:41:18 -05:00
J. Nick Koston 7e32770c5a [ci] Pin SOURCE_DATE_EPOCH in the idf.py equivalence check (#19970) 2026-10-01 12:37:42 +00:00
Panda 3dc608c772 [core] Parse ESPHOME_SERIAL_LOGGING_RESET as a boolean (#19963) 2026-10-01 06:56:32 -05:00
esphome[bot] 5a11f8d7c3 Bump bundled esphome-device-builder to 1.18.0 (#19968) 2026-10-01 04:58:06 +00:00
mtmatteo79 94ed17815c [bthome_mithermometer] Decode 0.1C temperature and 1% humidity objects (#19956) 2026-09-30 19:55:39 -04:00
J. Nick Koston 6f5d7475f0 [esp8266] Derive the native image header flash size from the linker script (#19961) 2026-10-01 00:50:40 +02:00
568c1ae494 [zigbee] Implement switch support for esp32 (#17579)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-30 22:45:50 +00:00
Clyde StubbsandClaude Opus 5.5 cb47dfcf0e [light] Fix white flash on turn-on during an effect, start strobe on its first color (#19916)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 07:22:41 +10:00
Nate ClarkandClaude Opus 5.5 35474aae3d [mqtt] Stop disabling the mbedTLS hardware MPI accelerator for TLS (#19958)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:24:22 +02:00
dependabot[bot] ea5832a410 Bump pytest-mock from 3.15.1 to 3.16.0 (#19952)
Signed-off-by: dependabot[bot] <support@github.com>
2026-09-30 22:21:01 +02:00
Remco van Essen 1d69ddc09e [tas58xx] Add TAS58xx audio amplifier (TAS5805M) (#19595) 2026-09-30 20:19:23 +02:00
Keith Burzinski e742e64fb0 [serial_proxy] Never let a client write stall the main loop past a fixed budget (#19923) 2026-09-30 12:33:17 -05:00
Remco van Essen 52079dd3f3 [audio_dac] Add a hook that runs once the I2S clocks are running (#19900) 2026-09-30 09:45:15 -04:00
J. Nick Koston 3fac97867e [espidf] Resolve cmake through the IDF environment when reading the CMake cache (#19947) 2026-09-30 13:17:07 +02:00
Panda 6c43247395 [core] Ignore empty CLI address cache entries (#19924) 2026-09-30 12:41:52 +02:00
J. Nick Koston e8c5cd56a8 [xdb401] Fix format warnings in the timing and raw pressure logs (#19945) 2026-09-30 11:28:46 +02:00
J. Nick Koston 9208804707 [json] Fix format truncation warnings in the arena tests (#19938) 2026-09-30 09:10:20 +00:00
J. Nick Koston 199cc08270 [midea] Fix a format warning in the esp32 test config (#19946) 2026-09-30 05:00:04 -04:00
J. Nick Koston 6121bb3bcf [ufm01] Fix a format warning when logging the frame sync position (#19944) 2026-09-30 04:59:23 -04:00
J. Nick Koston c2d830ebd3 [pmwcs3] Use the sub_* helpers for optional child entities (#19943) 2026-09-30 04:58:45 -04:00
J. Nick Koston a706fa9e6b [gdk101] Use the sub_* helpers for optional child entities (#19942) 2026-09-30 04:58:30 -04:00
J. Nick Koston 08ed25833d [mmc5603] Use the sub_* helpers for optional child entities (#19941) 2026-09-30 04:58:14 -04:00
J. Nick Koston 3062e1f844 [smt100] Use the sub_* helpers for optional child entities (#19940) 2026-09-30 04:58:02 -04:00
J. Nick Koston 39f41656b9 [seeed_mr60bha2] Use the sub_* helpers for optional child entities (#19939) 2026-09-30 04:57:46 -04:00
J. Nick Koston 987618b49e [micro_wake_word] Fix a format warning when logging a model version mismatch (#19937) 2026-09-30 04:57:01 -04:00
J. Nick Koston e7f2159f0e [api] Fix format warnings in the api test config (#19936) 2026-09-30 04:56:37 -04:00
J. Nick Koston f327cbbcfe [dlms_meter] Fix a format warning when logging the receive timeout (#19935) 2026-09-30 04:56:15 -04:00
J. Nick Koston 3cccdca771 [modbus] Fix a format warning when logging the frame buffer size (#19934) 2026-09-30 04:55:50 -04:00
Clyde StubbsandClaude Opus 5.5 5baad05926 [lvgl] Use contextvars to improve lvgl scheduling (#19242)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 04:54:38 -04:00
J. Nick Koston e111f1130c [max9611] Use the sub_* helpers for optional child entities (#19930) 2026-09-30 04:14:51 -04:00
J. Nick Koston 4d534dc420 [uponor_smatrix] Use the sub_* helpers for optional child entities (#19929) 2026-09-30 04:14:40 -04:00
J. Nick Koston 0fd0f72550 [alpha3] Use the sub_* helpers for optional child entities (#19928) 2026-09-30 04:14:27 -04:00
J. Nick Koston 4a34315fc7 [ufm01] Use the sub_* helpers for optional child entities (#19927) 2026-09-30 04:14:11 -04:00
J. Nick Koston 4a00cebd61 [kuntze] Use the sub_* helpers for optional child entities (#19926) 2026-09-30 04:13:53 -04:00
cd0a0f3fca [zigbee] Add cluster: device_class to expose ESP32 sensors via measurement clusters instead of Analog/Binary Input (#17388)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-09-29 21:48:08 -04:00
J. Nick Koston 23afdc1d9b [bme680] Use the sub_* helpers for optional child entities (#19917) 2026-09-29 21:32:45 -04:00
J. Nick Koston ef4d06e0ea [ld6002b] Use the sub_* helpers for optional child entities (#19918) 2026-09-29 21:32:13 -04:00
J. Nick Koston 83d689d944 [xiaomi_hhccjcy10] Use the sub_* helpers for optional child entities (#19919) 2026-09-29 21:32:02 -04:00
J. Nick Koston 203a7f70a3 [emc2101] Use the sub_* helpers for optional child entities (#19920) 2026-09-29 21:31:50 -04:00
J. Nick Koston 7d904b6330 [ens160_base] Use the sub_* helpers for optional child entities (#19921) 2026-09-29 21:31:39 -04:00
Jesse Hills ac1190094a [ethernet] Add KSZ8851SNL SPI ethernet support (#19883) 2026-09-30 13:48:10 +13:00
J. Nick Koston ed88002c37 [core] Extract native toolchain package archives in parallel (#18840) 2026-09-30 02:23:05 +02:00
Keith BurzinskiandClaude Fable 5.1 ef0819f983 [nfc] Hold NDEF records in a fixed-size list (#19912)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 01:12:55 +02:00
J. Nick Koston 7187acec04 [core] Add a line based yaml editor and use it for rename (#19410) 2026-09-30 11:15:14 +13:00
J. Nick Koston 25ddc94d5d [web_server_idf] Build state documents in a stack arena so a state event does no heap allocation (#19383) 2026-09-30 11:10:40 +13:00
J. Nick Koston 5502f620d4 [web_server_idf] Send events straight from the caller's buffers instead of a std::string (#19376) 2026-09-30 11:10:40 +13:00
guillempagesandJ. Nick Koston 99c7f9e625 [runtime_image] Improve format logs (#19903)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-09-29 23:28:28 +02:00
Clyde Stubbs 35d41adf86 [lvgl] Animation updates and debug borders option (#19420) 2026-09-30 07:20:13 +10:00
Keith BurzinskiandClaude Fable 5.1 662c7db222 [nfc] Pass tags to triggers by reference and keep the listener list in static storage (#19902)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 15:00:23 -05:00
dependabot[bot] 6b465443bc Bump filelock from 4.0.3 to 4.0.4 (#19905) 2026-09-29 21:48:38 +02:00
dependabot[bot] fc270cb90f Bump platformdirs from 4.11.14 to 4.12.0 (#19904) 2026-09-29 21:48:15 +02:00
J. Nick Koston 46be513a39 [core] Create the PlatformIO HTTP cache dir before the first registry lookup (#19906) 2026-09-29 19:34:38 +00:00
Jesse Hills cc6828f34f Merge branch 'release' into dev 2026-09-30 08:13:38 +13:00
Jesse Hills 0b2b16e664 Merge pull request #19872 from esphome/bump-2026.9.1
2026.9.1
2026-09-29 20:12:00 +01:00
Bascht74andJonathan Swoboda a43d3382b5 [socket] Let Zephyr clang-tidy see the BSD socket implementation (#19901)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-09-29 13:58:12 -04:00
J. Nick Koston b8ba8aa1a8 [espidf] Run cmake and ninja directly instead of through idf.py (#19871) 2026-09-29 19:55:22 +02:00
J. Nick Koston a0f933ea60 [htu21d] Use the sub_* helpers for optional child entities (#19894) 2026-09-29 13:54:55 -04:00
J. Nick Koston b6cd92e915 [hmc5883l] Use the sub_* helpers for optional child entities (#19893) 2026-09-29 13:54:52 -04:00
J. Nick Koston 4618c73b05 [ccs811] Use the sub_* helpers for optional child entities (#19895) 2026-09-29 13:54:33 -04:00
J. Nick Koston 1b86959ee0 [inkbird_ibsth1_mini] Use the sub_* helpers for optional child entities (#19896) 2026-09-29 13:54:11 -04:00
J. Nick Koston 1a9f43909a [mopeka_std_check] Use the sub_* helpers for optional child entities (#19897) 2026-09-29 13:53:59 -04:00
J. Nick Koston 55a7340d06 [esp8266] Make the native arduino toolchain the default (#19886) 2026-09-29 18:52:28 +02:00
J. Nick Koston b22f2e6b4c [core] Leave the precompiled header off on Windows for a GCC with bug 14940 (#19875) 2026-09-29 18:51:12 +02:00
J. Nick Koston a7e0afeb46 [esp8266] Use our own Arduino core builds for the native toolchain (#19876) 2026-09-29 18:38:51 +02:00
J. Nick Koston 5be41dec15 [esp8266] Use our own compiler builds for the native toolchain (#19849) 2026-09-29 18:31:24 +02:00
J. Nick Koston 5fdb73aceb [libretiny] Load the precompiled header on Apple Silicon Macs (#19857) 2026-09-29 18:26:40 +02:00
luar123 c1303c048b [esp32][rp2][esp8266][libretiny]mask gpio flags (1/3) (#19393) 2026-09-29 15:50:07 +00:00
n-IA-haneandn-IA-hane 627832f7ba [speaker_source] Account for playback callbacks during speaker writes (#19499)
Co-authored-by: n-IA-hane <n-IA-hane@users.noreply.github.com>
2026-09-29 10:36:37 -04:00
Christopher PruijsenandJonathan Swoboda 757a1c42f9 [dfrobot_sen0395] Fix output_latency treating milliseconds as seconds (#19873)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-09-29 09:03:19 -04:00
Roman Priesol 9b9d548ad5 [speaker] Skip a playlist item that fails instead of retrying it forever (#19862) 2026-09-29 07:56:34 -04:00
J. Nick Koston eb9aae04d0 [rp2] Bump arduino-pico framework to 6.1.1 (#19881) 2026-09-29 12:51:03 +02:00
54a9238425 [epaper_spi] reTerminal Sticky: Four-level grayscale for SSD1677 panels, and a full-refresh action (#19213)
Co-authored-by: Pierre <pierre@mysweethome.ch>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-09-29 19:38:01 +10:00
J. Nick Koston 21c9604918 [network] Keep lwip off the PSRAM tier when Ethernet is configured (#19367) 2026-09-29 01:56:17 -05:00
J. Nick Koston bf7aa1dc60 [sm300d2] Use the sub_* helpers for optional child entities (#19866) 2026-09-28 19:58:53 -04:00
J. Nick Koston 571cbb72eb [xiaomi_lywsd03mmc] Use the sub_* helpers for optional child entities (#19870) 2026-09-28 19:58:05 -04:00
J. Nick Koston 8032542076 [as5600] Use the sub_* helpers for optional child entities (#19869) 2026-09-28 19:57:51 -04:00
J. Nick Koston aba15f8ff6 [thermopro_ble] Use the sub_* helpers for optional child entities (#19868) 2026-09-28 19:57:37 -04:00
Jesse Hills 7a4ffa98f6 Bump version to 2026.9.1 2026-09-29 12:57:36 +13:00
J. Nick Koston eca4c2d0a5 [ethernet] Move received frames to PSRAM on SPI ethernet chips (#19377) 2026-09-29 12:57:35 +13:00
Suliman AbdulrazzaqandClyde Stubbs 43b6b41d16 [lvgl] Honor esphome build_flags when generating lv_conf.h (#19743)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-29 12:57:35 +13:00
Clyde Stubbs b6d70d4e34 [mipi_spi] Fix reversion in dc/cs sequence for spi_16 (#19745) 2026-09-29 12:57:35 +13:00
tomaszduda23andJ. Nick Koston 4af2db7e8a [nrf52] set WDT timeout to 30sec for Adafruit bootloader (#19596)
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-09-29 12:57:35 +13:00
J. Nick Koston 92059abf6c [web_server] Mask the value of a password text entity, not only its state (#19385) 2026-09-29 12:57:35 +13:00
Jonathan SwobodaandClaude Fable 5.1 e6d124d229 [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:57:35 +13:00
J. Nick Koston 0575eb14f2 [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) 2026-09-29 12:57:35 +13:00
J. Nick Koston c732903390 [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) 2026-09-29 12:57:35 +13:00
J. Nick Koston eaee1cdd86 [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) 2026-09-29 12:57:35 +13:00
J. Nick Koston 964a54f7a7 [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) 2026-09-29 12:57:34 +13:00
rexmoriarty 3e5797d153 [mixer] Don't discard a start request while reaping a stopped task (#19368) 2026-09-29 12:57:34 +13:00
J. Nick Koston 7e99301644 [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) 2026-09-29 12:57:34 +13:00
08387ad289 [mipi_dsi] Let IDF pick the DPHY PLL reference clock (#18984)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-09-29 12:57:34 +13:00
J. Nick Koston 3f06553023 [ltr501] Use the sub_* helpers for optional child entities (#19867) 2026-09-28 19:57:25 -04:00
Clyde Stubbs 2d6a7dae54 [mipi_rgb] Fix SWRESET delay for st7701s (#17646) 2026-09-29 09:40:39 +10:00
J. Nick Koston 30dc453dc1 [ethernet] Move received frames to PSRAM on SPI ethernet chips (#19377) 2026-09-29 10:30:51 +13:00
Jonathan Swoboda 89cc689864 [esp32_hosted][esp32_ble] Support esp_hosted 3.x alongside 2.x, keep builds on 2.12.13 for now (#19621) 2026-09-28 17:08:06 -04:00
Clyde Stubbs 13c27429a4 [split_buffer] Allow direct access to buffer chunks (#19819) 2026-09-29 06:30:29 +10:00
dependabot[bot]andesphome[bot] 8cbc18d040 Bump flake8 from 7.3.0 to 7.4.1 (#19850)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-09-28 15:43:38 -04:00
dependabot[bot]andesphome[bot] 728449f69d Bump ruff from 0.16.8 to 0.16.9 (#19851)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-09-28 15:32:15 -04:00
dependabot[bot] 7e7035db72 Bump github/codeql-action/analyze from 4.38.1 to 4.38.2 (#19856)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 15:31:53 -04:00
dependabot[bot] 45f8dbb4da Bump platformdirs from 4.11.12 to 4.11.14 (#19854)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 15:31:37 -04:00
dependabot[bot] eb97d15f2e Bump github/codeql-action/init from 4.38.1 to 4.38.2 (#19855)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 15:31:05 -04:00
dependabot[bot] 98754bedcd Bump pylint from 4.0.8 to 4.0.9 (#19853)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 15:30:51 -04:00
dependabot[bot] 9e8044868c Bump filelock from 4.0.1 to 4.0.3 (#19852)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 15:30:32 -04:00
J. Nick Koston fc803fcc6d [libretiny] Enable the PlatformIO precompiled header on Linux hosts (#19827) 2026-09-28 18:06:53 +00:00
esphome[bot] 025cf1f638 Bump bundled esphome-device-builder to 1.17.2 (#19848) 2026-09-28 19:50:10 +02:00
J. Nick Koston 45189ef445 [nrf52] Precompile the core headers on the sdk-nrf build (#19826) 2026-09-28 19:49:54 +02:00
J. Nick Koston d31ffd4ca3 [esp32] Enable the precompiled header on the PlatformIO toolchain (#19825) 2026-09-28 19:41:59 +02:00
J. Nick Koston 4ec8fae5df [rp2] Enable the PlatformIO precompiled header (#19824) 2026-09-28 19:26:51 +02:00
J. Nick Koston c52ce65d1c [esp8266] Precompile the prefix header on PlatformIO builds (#19823) 2026-09-28 19:19:46 +02:00
esphome[bot] 1f08ea68e6 [ci] Refresh integration test durations (#19847) 2026-09-28 17:12:41 +00:00
J. Nick Koston 6c2a4d3999 [host] Precompile the core headers on the native build (#19822) 2026-09-28 18:58:27 +02:00
J. Nick Koston 446c47f7f8 [esp8266] Precompile the prefix header on the native toolchain (#19821) 2026-09-28 18:51:44 +02:00
J. Nick Koston 787ee418b0 [esp32] Precompile the core headers for ESP-IDF source compiles (#19820) 2026-09-28 18:28:03 +02:00
tomaszduda23andJonathan Swoboda 45b9c8b94c [zephyr_mcumgr] add LED which is controled by bootloader (#16322)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-09-28 17:37:44 +02:00
tomaszduda23andJ. Nick Koston b4f2334b99 [nrf52] Use full newlib when the logger is enabled, disable unused serial and printk (#19766)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-09-28 16:57:00 +02:00
esphome[bot] 3fb4a9cb3a Bump bundled esphome-device-builder to 1.17.1 (#19838) 2026-09-28 14:34:33 +00:00
J. Nick Koston b56198f242 [tsl2591] Use the sub_* helpers for optional child entities (#19831) 2026-09-28 08:44:32 -04:00
J. Nick Koston 21d9aff769 [qmc5883l] Use the sub_* helpers for optional child entities (#19832) 2026-09-28 08:44:20 -04:00
J. Nick Koston b4b4000633 [wireguard] Use the sub_* helpers for optional child entities (#19833) 2026-09-28 08:44:07 -04:00
J. Nick Koston 9f7d7284a2 [pzemdc] Use the sub_* helpers for optional child entities (#19834) 2026-09-28 08:42:26 -04:00
J. Nick Koston 4c23564851 [hlw8032] Use the sub_* helpers for optional child entities (#19835) 2026-09-28 08:41:34 -04:00
PandaandJ. Nick Koston b8252f56ea [platformio] Fix ccache path for non-root containers (#19807)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-09-28 14:27:01 +02:00
1229 changed files with 53118 additions and 9174 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ runs:
# cached path cannot diverge.
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
. venv/bin/activate
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import FRAMEWORK_RELEASES, TOOLCHAIN_VERSION as t; print(f"{FRAMEWORK_RELEASES[f].tag}-{t}")')
[ -n "$key" ] || exit 1
echo "key=$key" >> "$GITHUB_OUTPUT"
- name: Cache the native toolchain (write on dev)
+3
View File
@@ -19,6 +19,7 @@ on:
- "esphome/core/**"
- "esphome/writer.py"
- "esphome/build_gen/**"
- "esphome/build_helpers/**"
- "esphome/espidf/**"
- "esphome/platformio/**"
- "esphome/components/bk72xx/**"
@@ -195,6 +196,7 @@ jobs:
# the default.
id:
- esp8266-arduino
- esp8266-arduino-native
- esp32-arduino-platformio
- esp32-arduino-esp-idf
- esp32-idf-platformio
@@ -217,5 +219,6 @@ jobs:
run: |
docker run --rm \
-v "${{ github.workspace }}/docker/test_configs:/config" \
-e ESPHOME_LDGEN_STRICT=1 \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+34 -9
View File
@@ -299,7 +299,7 @@ jobs:
# Pushes any fixes the hooks made back to the pull request. This step
# must keep its default name: the GitHub App that performs the push
# locates the workflow run by that name.
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
- uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0
if: always()
with:
msg: apply automatic formatting fixes
@@ -626,7 +626,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0
with:
run: |
. venv/bin/activate
@@ -893,14 +893,20 @@ jobs:
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 1/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 1
name: Run script/clang-tidy for ESP32 IDF 1/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 1
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 2/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 2
name: Run script/clang-tidy for ESP32 IDF 2/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 2
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 3/3
options: --environment esp32-idf-tidy --split-num 3 --split-at 3
name: Run script/clang-tidy for ESP32 IDF 3/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 3
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 4/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 4
- id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 5/5
options: --environment esp32-idf-tidy --split-num 5 --split-at 5
steps:
- name: Check out code from GitHub
@@ -1225,9 +1231,28 @@ jobs:
if [ -n "$compile_csv" ]; then
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
# The bootloader has no ESPHome code and these builds never
# flash; the check_idf_py batch keeps the full build so the
# native sub-build and the equivalence check stay covered.
skip_flag="--skip-bootloader"
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
skip_flag=""
fi
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
# The real idf.py must find nothing to configure or build in a
# tree built above; catches drift on ESP-IDF bumps.
echo "Checking the native ESP-IDF build matches idf.py"
python3 script/check_idf_py_equivalence.py
fi
else
echo "All components in this batch are validate-only -- skipping compile stage."
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
# determine-jobs and this step disagree on what compiles; fail
# rather than let the check run nowhere.
echo "::error::This batch was picked for the idf.py check but compiled nothing"
exit 1
fi
fi
- name: Print ccache statistics
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:${{matrix.language}}"
+14 -8
View File
@@ -125,14 +125,20 @@ jobs:
}
async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
const changedFiles = await github.rest.pulls.listFiles({
owner: owner,
repo: repo,
pull_number: prNumber,
});
const changedFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner: owner,
repo: repo,
pull_number: prNumber,
per_page: 100,
}
);
const esphomeChanges = changedFiles.data
.filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
// Files used only for development and CI, which do not affect use as an external component
const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"];
const esphomeChanges = changedFiles
.filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/'))
.map(file => {
const match = file.filename.match(/esphome\/([^/]+)/);
return match ? match[1] : null;
@@ -144,7 +150,7 @@ jobs:
}
const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
const componentChanges = changedFiles.data
const componentChanges = changedFiles
.filter(file => file.filename.startsWith('esphome/components/'))
.map(file => {
const match = file.filename.match(/esphome\/components\/([^/]+)\//);
@@ -7,10 +7,6 @@ on:
permissions:
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check blocking labels
+2 -2
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.8
rev: v0.16.10
hooks:
# Run the linter.
- id: ruff
@@ -18,7 +18,7 @@ repos:
# Run the formatter.
- id: ruff-format
- repo: https://github.com/PyCQA/flake8
rev: 7.3.0
rev: 7.4.1
hooks:
- id: flake8
additional_dependencies:
+13 -1
View File
@@ -187,10 +187,12 @@ esphome/components/esp32_rmt/* @jesserockz
esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
esphome/components/esp_ldo/* @clydebarrow
esphome/components/espectre/* @francescopace
esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat
esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
@@ -245,8 +247,11 @@ esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/button/* @jesserockz
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/select/* @jesserockz
esphome/components/homeassistant/switch/* @Links2004
esphome/components/homeassistant/text/* @jesserockz
esphome/components/honeywell_hih_i2c/* @Benichou34
esphome/components/honeywellabp/* @RubyBailey
esphome/components/honeywellabp2_i2c/* @jpfaff
@@ -282,6 +287,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci
esphome/components/integration/* @OttoWinter
esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_base/* @bdraco @kbx81
esphome/components/ir_rf_proxy/* @kbx81
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
@@ -541,6 +547,7 @@ esphome/components/st7735/* @SenexCrenshaw
esphome/components/st7789v/* @kbx81
esphome/components/st7920/* @marsjan155
esphome/components/statsd/* @Links2004
esphome/components/stcc4/* @j9brown
esphome/components/stts22h/* @B48D81EFCC
esphome/components/substitutions/* @esphome/core
esphome/components/sun/* @OttoWinter
@@ -554,12 +561,14 @@ esphome/components/syslog/* @clydebarrow
esphome/components/systa_bus/* @Mat931
esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
esphome/components/tcp_uart/* @Bascht74
esphome/components/tee501/* @Stock-M
esphome/components/teleinfo/* @0hax
esphome/components/teleinfo/* @0hax @FredM67
esphome/components/tem3200/* @bakerkj
esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar
esphome/components/template/datetime/* @rfdarter
@@ -599,11 +608,13 @@ esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/uart_mux/* @kbx81
esphome/components/uart_tcp/* @Bascht74
esphome/components/udp/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
esphome/components/ultrasonic/* @ssieb @swoboda1337
esphome/components/unicode/* @esphome/core
esphome/components/update/* @jesserockz
esphome/components/uponor_smatrix/* @kroimon
esphome/components/usb_cdc_acm/* @kbx81
@@ -647,6 +658,7 @@ esphome/components/xiaomi_body_scale/* @dckiller51
esphome/components/xiaomi_hhccjcy10/* @fariouche
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
esphome/components/xiaomi_lywsd03mmc/* @ahpohl
esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027
esphome/components/xiaomi_mhoc303/* @drug123
esphome/components/xiaomi_mhoc401/* @vevsvevs
esphome/components/xiaomi_rtcgq02lm/* @jesserockz
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.10.0-dev
PROJECT_NUMBER = 2026.10.0b4
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.17.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+6 -2
View File
@@ -21,10 +21,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native toolchain installs on the persistent cache root, not the
# container's ephemeral user cache dir (re-downloaded on every restart).
# Keep the native toolchain installs and compiler caches on the persistent
# cache root, not the container's user cache dir: it is lost on every
# restart, and not writable when the container runs as a non-root user.
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
export ESPHOME_ARDUINO8266_PREFIX="$(dirname "${pio_cache_base}")/arduino8266"
export ESPHOME_HOST_PREFIX="$(dirname "${pio_cache_base}")/host"
export ESPHOME_PLATFORMIO_CCACHE_DIR="$(dirname "${pio_cache_base}")/platformio-ccache"
# If /build is mounted, use that as the build path
# otherwise use path in /config (so that builds aren't lost on container restart)
@@ -15,10 +15,14 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native toolchain installs on the persistent /data volume, not the
# container's ephemeral user cache dir (wiped on every add-on update/restart).
# Keep the native toolchain installs and compiler caches on the persistent
# /data volume, not the container's ephemeral user cache dir (wiped on every
# add-on update/restart).
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
export ESPHOME_ARDUINO8266_PREFIX=/data/cache/arduino8266
export ESPHOME_HOST_PREFIX=/data/cache/host
export ESPHOME_PLATFORMIO_CCACHE_DIR=/data/cache/platformio-ccache
if bashio::config.true 'leave_front_door_open'; then
export DISABLE_HA_AUTHENTICATION=true
@@ -0,0 +1,8 @@
esphome:
name: docker-test-esp8266-native
esp8266:
board: d1_mini
toolchain: arduino
logger:
+2
View File
@@ -3,5 +3,7 @@ esphome:
esp8266:
board: d1_mini
# The PlatformIO path stays covered whatever the default is
toolchain: platformio
logger:
+87 -166
View File
@@ -7,6 +7,7 @@ import importlib
import logging
import os
from pathlib import Path
import platform
import re
import sys
import time
@@ -18,7 +19,6 @@ from typing import TYPE_CHECKING, Protocol
from esphome import const, platform_hooks
from esphome.build_helpers.native import analysis_backend, native_backend
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API,
@@ -36,7 +36,6 @@ from esphome.const import (
CONF_LOGGER,
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
@@ -56,12 +55,13 @@ from esphome.const import (
)
from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
ESPHOME_COMMAND,
PICOTOOL_PACKAGE,
FlashImage,
detect_rp2040_bootsel,
@@ -85,7 +85,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
# Maximum buffer size for serial log reading to prevent unbounded memory growth
SERIAL_BUFFER_MAX_SIZE = 65536
@@ -150,6 +149,7 @@ class ArgsProtocol(Protocol):
file: str | None
no_logs: bool
only_generate: bool
skip_bootloader: bool
show_secrets: bool
dashboard: bool
configuration: str
@@ -829,6 +829,14 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
# Debug only: an orchestrator cannot see YAML toolchain overrides,
# so this is its expected no-op, and a full build is safe.
_LOGGER.debug(
"--skip-bootloader ignored: only supported on ESP32 with the "
"esp-idf toolchain"
)
CORE.skip_bootloader = False
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
@@ -838,7 +846,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
# working on machines that cannot run the toolchain.
if CORE.is_esp8266:
if CORE.is_esp8266 and CORE.using_toolchain_platformio:
from esphome.components.esp8266 import check_rosetta
check_rosetta()
@@ -859,7 +867,10 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
return rc
# Create factory.bin, ota.bin, and firmware.elf copy
toolchain.create_factory_bin()
if not toolchain.create_factory_bin():
# A build whose factory image could not be produced must not
# exit 0; downloads would serve an image from an older build.
return 1
toolchain.create_ota_bin()
toolchain.create_elf_copy()
from esphome.build_helpers.idedata import warn_if_idedata_missing
@@ -977,8 +988,9 @@ def upload_using_esptool(
# and partitions included where the target needs them)
image = native.get_factory_firmware_path()
if not image.is_file():
hint = getattr(native, "missing_image_hint", lambda: None)()
raise EsphomeError(
f"{image} does not exist; compile the configuration first"
hint or f"{image} does not exist; compile the configuration first"
)
flash_images = [FlashImage(path=image, offset="0x0")]
else:
@@ -1289,10 +1301,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
The native API uses challenge-response auth with MD5/SHA256 hashing of a
server-issued nonce, so the password is never sent over the wire; the
``web_server`` path uses HTTP Basic auth which transmits credentials in
cleartext over the LAN. (The native path also supports gzip compression
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
backend reports ``supports_compression() == false`` and the firmware is
sent uncompressed regardless of which platform is used.) Falls back to
cleartext over the LAN. (The native path also compresses the upload:
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
``web_server`` only when that is the only available platform.
"""
# Use a dict (insertion-ordered) instead of a list so error messages and
@@ -1301,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
platform = ota_item.get(CONF_PLATFORM)
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[platform] = None
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
if not available:
raise EsphomeError(
@@ -1381,6 +1392,12 @@ def _upload_via_native_api(
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
elif getattr(args, "bootloader", False):
check_partition_access("--bootloader")
if (
getattr(args, "file", None) is None
and (native := native_backend())
and (hint := getattr(native, "missing_image_hint", lambda: None)())
):
raise EsphomeError(hint)
binary = CORE.bootloader_bin
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
if getattr(args, "file", None) is not None:
@@ -1756,6 +1773,18 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
if (
CORE.skip_bootloader
and CORE.is_esp32
and CORE.using_toolchain_esp_idf
and any(
get_port_type(device) == PortType.SERIAL for device in (args.device or [])
)
):
# Fail before the compile: the result could never flash over serial.
# Elsewhere the flag is ignored, so serial stays fine there.
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
return 1
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
@@ -1917,7 +1946,10 @@ def run_multiple_configs(
safe_print()
cmd = command_builder(f)
rc = run_external_process(*cmd)
# The parent already logged the Intel macOS warning; children skip it.
rc = run_external_process(
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
)
if rc == 0:
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
@@ -2104,155 +2136,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
from esphome.cli.rename import command_rename as run
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
)
)
return 1
# Load existing yaml file
raw_contents = CORE.config_path.read_text(encoding="utf-8")
yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
safe_print(
color(
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
)
)
return 1
old_name = yaml[CONF_ESPHOME][CONF_NAME]
match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name)
if match is None:
# Only swap the ``name:`` line that sits directly under the
# top-level ``esphome:`` block. A naked ``re.sub`` would
# also clobber any other ``name:`` line whose value happens
# to match (e.g. a sensor / output / wifi entry sharing the
# device's hostname), silently rewriting unrelated user
# configuration. The pattern anchors:
# - at the start of the line so ``friendly_name:``,
# ``device_name:`` etc. don't match the trailing ``name:``
# substring; and
# - at the end of the value (lookahead for whitespace +
# comment + EOL) so ``old_name`` doesn't match as a
# prefix of a longer value (``kitchen`` vs ``kitchen2``).
name_pattern = re.compile(
rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))"
)
out_lines: list[str] = []
in_esphome_block = False
for line in raw_contents.splitlines(keepends=True):
if line and not line[0].isspace() and line.strip():
in_esphome_block = line.lstrip().startswith("esphome:")
out_lines.append(line)
continue
if in_esphome_block:
line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1)
out_lines.append(line)
new_raw = "".join(out_lines)
else:
old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)]
if (
len(
re.findall(
rf"^\s+{match.group(1)}:\s+[\"']?{old_name}[\"']?",
raw_contents,
flags=re.MULTILINE,
)
)
> 1
):
safe_print(
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
)
return 1
new_raw = re.sub(
rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?",
f'\\1: "{new_name}"',
raw_contents,
flags=re.MULTILINE,
)
# ``new_name == old_name`` (after substitution resolution) is
# a no-op rewrite that would still queue a pointless re-flash.
# Catch it before the path-equality check below — covers the
# case where the config filename doesn't match the device name
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
if new_path.exists():
safe_print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
"Refusing to overwrite an existing configuration.",
)
)
return 1
safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
new_path.write_text(new_raw, encoding="utf-8")
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
if rc != 0:
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
new_path.unlink()
return 1
cli_args = [
"run",
str(new_path),
"--no-logs",
"--device",
CORE.address,
]
if args.dashboard:
cli_args.insert(0, "--dashboard")
try:
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
except KeyboardInterrupt:
rc = 1
if rc != 0:
new_path.unlink()
return 1
if CORE.config_path != new_path:
CORE.config_path.unlink()
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
return run(args, config)
PRE_CONFIG_ACTIONS = {
@@ -2287,6 +2173,15 @@ SIMPLE_CONFIG_ACTIONS = [
]
def _add_skip_bootloader_arg(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--skip-bootloader",
help="Do not build the bootloader or the factory image; "
"the result can only be flashed over OTA.",
action="store_true",
)
def _add_states_args(parser: argparse.ArgumentParser) -> None:
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
@@ -2367,7 +2262,8 @@ def parse_args(argv):
metavar="{" + ",".join(t.value for t in Toolchain) + "}",
help=(
"Select toolchain for compiling. Overrides '<platform>.toolchain' in YAML. "
f"Default: {Toolchain.PLATFORMIO.value}."
"Default: the platform's native toolchain where it has one, else "
f"{Toolchain.PLATFORMIO.value}."
),
)
@@ -2427,6 +2323,7 @@ def parse_args(argv):
help="Only generate source code, do not compile.",
action="store_true",
)
_add_skip_bootloader_arg(parser_compile)
parser_upload = subparsers.add_parser(
"upload",
@@ -2490,7 +2387,7 @@ def parse_args(argv):
"-r",
action="store_true",
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
)
_add_states_args(parser_logs)
@@ -2523,6 +2420,7 @@ def parse_args(argv):
parser_run.add_argument(
"--no-logs", help="Disable starting logs.", action="store_true"
)
_add_skip_bootloader_arg(parser_run)
_add_states_args(parser_run)
@@ -2531,7 +2429,7 @@ def parse_args(argv):
"-r",
action="store_true",
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
)
parser_run.add_argument(
"--ota-platform",
@@ -2720,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None:
)
_INTEL_MACOS_REMOVAL = "2027.6.0"
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
def _warn_if_intel_macos() -> None:
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
if (
not IS_MACOS
or platform.machine() != "x86_64"
or _INTEL_MACOS_WARNED_ENV in os.environ
):
return
_LOGGER.warning(
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
"so future releases will not install on this machine. On an Apple "
"Silicon Mac, switch to a native arm64 Python.",
_INTEL_MACOS_REMOVAL,
)
def run_esphome(argv):
from esphome.address_cache import AddressCache
@@ -2739,6 +2658,7 @@ def run_esphome(argv):
setup_log(log_level=args.log_level)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
if args.command in PRE_CONFIG_ACTIONS:
try:
@@ -2782,6 +2702,7 @@ def run_esphome(argv):
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
CORE.skip_bootloader = getattr(args, "skip_bootloader", False)
if args.toolchain is not None:
# CLI toolchain wins over esp32.toolchain in YAML.
CORE.toolchain = args.toolchain
+9 -2
View File
@@ -148,6 +148,13 @@ class AddressCache:
continue
hostname, ips = arg.split("=", 1)
# Normalize hostname for consistent lookups
normalized = normalize_hostname(hostname)
cache[normalized] = [ip.strip() for ip in ips.split(",")]
normalized = normalize_hostname(hostname.strip())
addresses = [ip for value in ips.split(",") if (ip := value.strip())]
if not normalized or not addresses:
_LOGGER.warning(
"Invalid cache entry: %s (hostname and at least one address are required)",
arg,
)
continue
cache[normalized] = addresses
return cache
+1 -1
View File
@@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None:
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# Native ESP-IDF: the build writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
+105 -45
View File
@@ -3,12 +3,12 @@
Artifacts land in a machine-global cache (shared across projects, like the
ESP-IDF install in ``esphome.espidf.framework``):
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
<cache>/arduino8266/frameworks/<tag>/ the Arduino core
<cache>/arduino8266/toolchains/<tag>/ xtensa-lx106-elf gcc 10.3
Packages come from the PlatformIO registry (identical bits to the PlatformIO
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
from PATH or the ninja PyPI wheel.
Both come from esphome-libs releases pinned below;
``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs, with ``{VERSION}``
standing for the release tag. ninja comes from PATH or the ninja PyPI wheel.
"""
from __future__ import annotations
@@ -22,13 +22,69 @@ from esphome.build_helpers.ninja import find_ninja
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
from esphome.core import EsphomeError, Version
from esphome.framework_helpers import str_to_lst_of_str
from esphome.platformio.registry import install_package, prefetch_packages
from esphome.platformio.registry import (
Download,
PackageSpec,
Resolver,
get_systype,
install_packages,
prefetch_packages,
)
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
FRAMEWORK_PACKAGE = "arduino-esp8266"
_FRAMEWORK_RELEASES = "https://github.com/esphome-libs/arduino-esp8266/releases/"
class FrameworkRelease(NamedTuple):
tag: str
sha256: str
size: int
def download(self) -> Download:
archive = f"{FRAMEWORK_PACKAGE}-{self.tag}.tar.gz"
url = f"{_FRAMEWORK_RELEASES}download/{self.tag}/{archive}"
return Download(url, self.sha256, self.size)
# Arduino core version -> its build in esphome-libs/arduino-esp8266
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
Version(3, 1, 2): FrameworkRelease(
"3.1.2-esphome.3",
"3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db",
37185650,
),
}
TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf"
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
# generator's compile flags are tuned to it.
TOOLCHAIN_VERSION = "2.100300.220621"
TOOLCHAIN_VERSION = "10.3.0-esphome.2"
_TOOLCHAIN_RELEASES = (
"https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/"
)
# Registry system tag -> (sha256, size) of that host's archive
TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = {
"darwin_arm64": (
"849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201",
60830321,
),
"darwin_x86_64": (
"ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897",
64149487,
),
"linux_aarch64": (
"60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6",
67573397,
),
"linux_x86_64": (
"1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4",
68431336,
),
"windows_amd64": (
"af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b",
67664137,
),
}
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
@@ -44,36 +100,40 @@ def get_arduino8266_tools_path() -> Path:
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
def framework_package_version(ver: Version) -> str:
"""Map an Arduino core version to its registry package version (3.1.2 ->
3.30102.0; the leading 3 is the package major).
Exact registry names for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION.
"""
if ver.major > 3:
def framework_release(version: Version) -> FrameworkRelease:
if (release := FRAMEWORK_RELEASES.get(version)) is None:
raise EsphomeError(
f"Arduino core {ver} is not supported yet; "
"the newest known core series is 3.x"
f"'toolchain: arduino' has no build of Arduino core {version}; "
f"available: {', '.join(str(v) for v in FRAMEWORK_RELEASES)}. "
"Use one of those or 'toolchain: platformio'"
)
if ver.major < 3:
raise EsphomeError(
f"Arduino core {ver} is not supported; ESPHome requires core 3.x"
)
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
return release
def get_framework_path(package_version: str) -> Path:
return get_arduino8266_tools_path() / "frameworks" / package_version
def get_framework_path(tag: str) -> Path:
return get_arduino8266_tools_path() / "frameworks" / tag
def get_toolchain_path() -> Path:
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
def toolchain_download() -> Download:
"""The toolchain archive for the current host."""
systype = get_systype()
if (build := TOOLCHAIN_BUILDS.get(systype)) is None:
raise EsphomeError(
f"There is no ESP8266 toolchain for this system ({systype}); "
f"supported systems are {', '.join(sorted(TOOLCHAIN_BUILDS))}. "
"Either set 'toolchain: platformio' under 'esp8266:', or point "
"ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS at a toolchain archive"
)
sha256, size = build
archive = f"{TOOLCHAIN_PACKAGE}-{TOOLCHAIN_VERSION}-{systype}.tar.gz"
url = f"{_TOOLCHAIN_RELEASES}download/{TOOLCHAIN_VERSION}/{archive}"
return Download(url, sha256, size)
class InstalledPaths(NamedTuple):
"""Locations of the installed framework, toolchain, and ninja binary."""
@@ -84,29 +144,22 @@ class InstalledPaths(NamedTuple):
def check_and_install(framework_version: Version) -> InstalledPaths:
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
if framework_version < MIN_FRAMEWORK_VERSION:
# Config validation enforces this too; keep the module honest when
# called directly.
raise EsphomeError(
f"The native toolchain requires the Arduino core "
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
)
release = framework_release(framework_version)
# Probe the cheap local dependency before ~110 MB of downloads
ninja_path = find_ninja()
package_version = framework_package_version(framework_version)
framework_path = get_framework_path(package_version)
framework_path = get_framework_path(release.tag)
downloads_dir = get_arduino8266_tools_path() / "downloads"
toolchain_path = get_toolchain_path()
# One spec per package: the prefetch and the installs must agree
specs = (
(
PackageSpec(
FRAMEWORK_PACKAGE,
package_version,
release.tag,
framework_path,
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
("cores/esp8266", "tools/sdk", "libraries"),
),
(
PackageSpec(
TOOLCHAIN_PACKAGE,
TOOLCHAIN_VERSION,
toolchain_path,
@@ -115,10 +168,17 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
("bin", "xtensa-lx106-elf"),
),
)
# Fetch both archives at once; the installs below verify and extract
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
for name, version, dest, mirrors, expect in specs:
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
# Resolved only when a download is needed, so an installed toolchain
# keeps working on a host without a build; a mirror override wins
resolvers: dict[str, Resolver] = {}
if not ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS:
resolvers[FRAMEWORK_PACKAGE] = release.download
if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS:
resolvers[TOOLCHAIN_PACKAGE] = toolchain_download
# Fetch both archives at once; the installs verify and extract them.
# One spec list for both, so the two phases cannot drift.
prefetch_packages(specs, downloads_dir, resolvers)
install_packages(specs, downloads_dir, resolvers)
return InstalledPaths(
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
)
+33 -52
View File
@@ -21,7 +21,7 @@ from esphome.const import (
CONF_TYPE_ID,
CONF_UPDATE_INTERVAL,
)
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
from esphome.cpp_generator import (
FlashStringLiteral,
LambdaExpression,
@@ -35,6 +35,32 @@ from esphome.types import ConfigType, SafeExpType
from esphome.util import Registry
def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj:
"""Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr."""
if not data:
return cg.nullptr
return cg.shared_progmem_array(
name, cg.uint8, cg.ArrayInitializer(*(HexInt(x) for x in data))
)
async def templatable_bytes(
value: Any,
args: TemplateArgsType,
set_template: MockObj,
set_static: MockObj,
table_name: str,
) -> None:
"""Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static."""
if cg.is_template(value):
fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8))
cg.add(set_template(fn))
elif len(value) > 0xFFFF:
raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535")
else:
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
def maybe_simple_id(*validators):
"""Allow a raw ID to be specified in place of a config block.
If the value that's being validated is a dictionary, it's passed as-is to the specified validators. Otherwise, it's
@@ -646,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition)
@register_condition("and", AndCondition, validate_condition_list)
async def and_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
async def or_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("all", AndCondition, validate_condition_list)
async def all_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("or", OrCondition, validate_condition_list)
@register_condition("any", OrCondition, validate_condition_list)
async def any_condition_to_code(
@register_condition("xor", XorCondition, validate_condition_list)
async def condition_group_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
# A group of one is that condition for and, or and xor; skip the wrapper.
if len(conditions) == 1:
return conditions[0]
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@@ -708,19 +702,6 @@ async def not_condition_to_code(
return cg.new_Pvariable(condition_id, template_arg, condition)
@register_condition("xor", XorCondition, validate_condition_list)
async def xor_condition_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
)
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
async def lambda_condition_to_code(
config: ConfigType,
+102 -41
View File
@@ -25,6 +25,7 @@ import subprocess
from typing import TYPE_CHECKING, NamedTuple
from esphome.arduino8266.framework import toolchain_tool
from esphome.build_helpers.idedata import is_joined_include
from esphome.build_helpers.ninja import (
escape as _e,
quote_path as _q,
@@ -36,8 +37,11 @@ from esphome.build_helpers.ninja_gen import (
compile_edges,
compile_rule_lines,
library_edges,
pch_edges,
pch_rule_lines,
tool_lines,
)
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import (
BOARDS,
@@ -50,6 +54,7 @@ from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_FLASH_SIZE,
KEY_SCANF_FLOAT,
THROW_STUBS_HEADER,
)
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_project_cxx_compile_flags
@@ -1049,24 +1054,22 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
+ ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"]
)
cxx = (toolchain_tool(paths.toolchain, "g++"),)
lines = [
*tool_lines(
(toolchain_tool(paths.toolchain, "gcc"),),
(toolchain_tool(paths.toolchain, "g++"),),
ccache,
),
*compile_rule_lines(),
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(ccache),
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group",
" rspfile = $out.rsp",
" rspfile_content = $in_newline",
" description = LINK $out",
"rule elf2bin",
# --flash_size deliberately stays board-derived, as under
# PlatformIO (which reads upload.maximum_size, not the ldscript).
# --flash_size follows PlatformIO: ldscript filename first, then
# upload.maximum_size, so the header always matches the layout.
# -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+.
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out",
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_elf2bin_flash_size(board, flash_ld_name)} --path {_q(toolchain_bin)} --out $out",
" description = BIN $out",
"rule copy",
" command = $python $buildtool copy $in $out",
@@ -1085,33 +1088,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines:
core_exclude |= _CORE_EXCLUDE_WAVEFORM
archives = []
# variant_dir existence was already enforced with the include dirs
variant_sources = collect_sources(variant_dir)
if variant_sources:
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
archives.append("libFrameworkArduinoVariant.a")
core_objs = compile_edges(
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
)
if not core_objs:
# An empty archive would link into a wall of undefined references
# (app_entry, the exception vectors) far from the cause
raise EsphomeError(
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
)
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
archives.append("libFrameworkArduino.a")
lib_archives, direct_objs = library_edges(lines, libraries)
archives += lib_archives
# One source of truth with the PlatformIO path: esp8266/__init__ pins
# build_src_flags (the throw_stubs force-include); -include paths
# resolve against the source root
src_parts: list[str] = []
# esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on
# the PlatformIO path; -include paths resolve against the source root
src_other: list[str] = []
src_includes: list[str] = []
src_it = iter(
lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags")
)
@@ -1122,15 +1102,71 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
raise EsphomeError(
"build_src_flags has a trailing '-include' with no header"
)
src_parts.append(f"-include {_q(src_dir / header)}")
src_includes.append(header)
elif is_joined_include(tok):
# Left in src_other it would precede the pch include
src_includes.append(tok[len("-include") :])
else:
src_parts.append(_shell_token(tok))
src_extra = " ".join(src_parts)
src_other.append(_shell_token(tok))
# The throw stubs abort: none anywhere with exceptions on, else the core and
# libraries get them too (src keeps them after its pch include)
if config.exceptions:
src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER]
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
framework_flags = ""
if THROW_STUBS_HEADER in src_includes:
stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)]
lines.append(f"frameworkflags = {stubs_flag}")
framework_flags = "$frameworkflags"
archives = []
# variant_dir existence was already enforced with the include dirs
variant_sources = collect_sources(variant_dir)
if variant_sources:
objs = compile_edges(
lines, variant_sources, variant_dir, "variant", flags=framework_flags
)
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
archives.append("libFrameworkArduinoVariant.a")
core_objs = compile_edges(
lines,
collect_sources(core_dir, core_exclude),
core_dir,
"core",
flags=framework_flags,
)
if not core_objs:
# An empty archive would link into a wall of undefined references
# (app_entry, the exception vectors) far from the cause
raise EsphomeError(
f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}"
)
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
archives.append("libFrameworkArduino.a")
lib_archives, direct_objs = library_edges(lines, libraries, framework_flags)
archives += lib_archives
# One shared variable instead of repeating the flags line on every src
# edge (hundreds of edges in a real project)
lines.append(f"srcflags = {src_extra}")
lines.append(f"srcflags = {' '.join(src_other + include_flags)}")
src_cxx_override = pch_edges(
lines,
build_dir,
src_dir,
(*src_includes, *PCH_DEFAULT_HEADERS),
flag_sets.cxxflags,
src_other,
(str(paths.framework), str(paths.toolchain)),
cxx,
)
src_objs = compile_edges(
lines, collect_sources(src_dir), src_dir, "src", flags="$srcflags"
lines,
collect_sources(src_dir),
src_dir,
"src",
flags="$srcflags",
cxx_override=src_cxx_override,
)
ld_deps = [f"ld/{_COMMON_LD_NAME}"]
@@ -1168,3 +1204,28 @@ def _flash_size_str(flash_size: int) -> str:
"""Flash size argument for elf2bin (e.g. ``4M``, ``512K``)."""
mb = 1024 * 1024
return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K"
# Same pattern PlatformIO's _get_flash_size applies to the ldscript path
_LD_FLASH_SIZE_RE = re.compile(r"\.flash\.(\d+[mk]).*\.ld")
# The framework elf2bin.py's --flash_size choices
_ELF2BIN_FLASH_SIZES = frozenset({"256K", "512K", "1M", "2M", "4M", "8M", "16M"})
def _elf2bin_flash_size(board: str, flash_ld_name: str) -> str:
"""Image-header flash size as PlatformIO derives it: ldscript filename,
else board_upload.maximum_size, else the board table. The SDK clamps the
chip to the header size at boot, so a header smaller than the linked
layout breaks OTA writes on the running device."""
if match := _LD_FLASH_SIZE_RE.search(flash_ld_name):
token = match.group(1)
base = 1024 if token[-1] == "k" else 1024 * 1024
return _flash_size_str(int(token[:-1]) * base)
if max_size := _pio_option("board_upload.maximum_size", ""):
if (
not max_size.isdigit()
or (size := _flash_size_str(int(max_size))) not in _ELF2BIN_FLASH_SIZES
):
raise EsphomeError(f"Invalid board_upload.maximum_size value {max_size!r}")
return size
return _flash_size_str(BOARDS[board][KEY_FLASH_SIZE])
+250 -1
View File
@@ -3,7 +3,10 @@
import json
import logging
from pathlib import Path
import re
import textwrap
from esphome.build_helpers import pch
from esphome.components.esp32 import (
get_esp32_variant,
get_excluded_builtin_components,
@@ -18,7 +21,7 @@ from esphome.framework_helpers import (
get_project_cxx_compile_flags,
get_project_link_flags,
)
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
_LOGGER = logging.getLogger(__name__)
@@ -33,6 +36,83 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
list(APPEND esphome_cxx_compile_options "-std={standard}")
idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
# Drops the app archive from ldgen's inputs so app-only edits skip the
# sections.ld regeneration. Safe: no mapping fragment references it
# (run_compile re-checks each build). Filters only the top-level call;
# the prior definition stays reachable with an underscore prefix.
_LDGEN_OVERRIDE = """\
if(COMMAND __ldgen_get_lib_deps_of_target)
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1)
function(__ldgen_get_lib_deps_of_target target out_list_var)
if(NOT COMMAND ___ldgen_get_lib_deps_of_target)
message(FATAL_ERROR "ESPHome ldgen override lost the original "
"implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.")
endif()
___ldgen_get_lib_deps_of_target(${target} ${out_list_var})
if(out_list_var STREQUAL "ldgen_libraries")
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1)
list(LENGTH ${out_list_var} esphome_ldgen_before)
list(REMOVE_ITEM ${out_list_var} idf::src __idf_src)
list(LENGTH ${out_list_var} esphome_ldgen_after)
if(esphome_ldgen_before EQUAL esphome_ldgen_after)
message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched "
"nothing; app edits will regenerate sections.ld.")
endif()
endif()
set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)
endfunction()
else()
message(@MISSING@ "ESPHome ldgen override target not found; "
"app edits will regenerate sections.ld.")
endif()"""
# lwip sources that compile to empty objects with the option off (their own
# #if guard). (option, regex valid for both Python and CMake); a source is
# only dropped when its option is defined and off, so a renamed option
# keeps it.
LWIP_EMPTY_SOURCES: tuple[tuple[str, str], ...] = (
("CONFIG_LWIP_PPP_SUPPORT", "/netif/ppp/"),
("CONFIG_LWIP_IPV6", "/core/ipv6/"),
("CONFIG_LWIP_AUTOIP", "/core/ipv4/autoip[.]c$"),
("CONFIG_LWIP_STATS", "/core/stats[.]c$"),
)
# Drift guard only: keep every lwip source.
LWIP_FULL_SOURCES_ENV = "ESPHOME_LWIP_FULL_SOURCES"
# Drops the empty objects after project(), once the lwip target exists.
_LWIP_EMPTY_SOURCES_FILTER = f"""\
idf_build_get_property(esphome_build_components BUILD_COMPONENTS)
if(lwip IN_LIST esphome_build_components AND NOT DEFINED ENV{{{LWIP_FULL_SOURCES_ENV}}})
idf_component_get_property(esphome_lwip_lib lwip COMPONENT_LIB)
get_target_property(esphome_lwip_srcs ${{esphome_lwip_lib}} SOURCES)
@FILTERS@
set_property(TARGET ${{esphome_lwip_lib}} PROPERTY SOURCES ${{esphome_lwip_srcs}})
endif()"""
def lwip_empty_source_gate(option: str, regex: str) -> str:
return (
f" if(DEFINED {option} AND NOT {option})\n"
f' list(FILTER esphome_lwip_srcs EXCLUDE REGEX "{regex}")\n'
" endif()"
)
def _lwip_empty_sources_filter() -> str:
gates = "\n".join(lwip_empty_source_gate(*entry) for entry in LWIP_EMPTY_SOURCES)
return _LWIP_EMPTY_SOURCES_FILTER.replace("@FILTERS@", gates)
# Runs after project() so the walk has happened; catches the remaining
# silent path where the top-level out-var was renamed.
_LDGEN_OVERRIDE_CHECK = """\
get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED)
get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED)
if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered)
message(@SEVERITY@ "ESPHome ldgen override never filtered the app "
"archive; app edits will regenerate sections.ld.")
endif()"""
def get_available_components() -> list[str] | None:
"""List the built-in ESP-IDF components from ``project_description.json``.
@@ -78,6 +158,85 @@ def _cmake_quote(value: str) -> str:
return f'"{escaped}"'
# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be
# cleared at the CMake level (the same state IDF's RAM-app build type uses).
# The macro is IDF's __build_process_project_includes plus a few added lines;
# the flag is ignored and the bootloader builds as usual if IDF changes it.
IDF_BOOTLOADER_OVERRIDE = """\
# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py.
if(ESPHOME_SKIP_BOOTLOADER)
macro(__build_process_project_includes)
idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE)
include(${sdkconfig_cmake})
set(CONFIG_APP_BUILD_BOOTLOADER "")
# bt's CMakeLists reads the lowercase idf_target that the (now
# skipped) bootloader project_include leaks; keep it defined, or
# its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /.
idf_build_get_property(idf_target IDF_TARGET)
# partition_table's V1 ECDSA signing reads this key, which the
# skipped bootloader project_include also sets.
get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")
idf_build_get_property(build_properties __BUILD_PROPERTIES)
foreach(build_property ${build_properties})
idf_build_get_property(val ${build_property})
set(${build_property} "${val}")
endforeach()
idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS)
foreach(component_target ${build_component_targets})
__component_get_property(dir ${component_target} COMPONENT_DIR)
__component_get_property(_name ${component_target} COMPONENT_NAME)
set(COMPONENT_NAME ${_name})
set(COMPONENT_DIR ${dir})
set(COMPONENT_PATH ${dir})
if(EXISTS ${COMPONENT_DIR}/project_include.cmake)
include(${COMPONENT_DIR}/project_include.cmake)
endif()
endforeach()
endmacro()
endif()
"""
# The lines the override adds to IDF's macro; idf_macro_matches() below
# strips them before comparing with the live macro.
BOOTLOADER_OVERRIDE_ADDED_LINES = (
'set(CONFIG_APP_BUILD_BOOTLOADER "")',
"idf_build_get_property(idf_target IDF_TARGET)",
(
"get_filename_component(SECURE_BOOT_SIGNING_KEY"
' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")'
),
)
_MACRO = re.compile(
r"macro\(__build_process_project_includes\)(.*?)endmacro\(\)", re.DOTALL
)
def _normalized_macro(text: str) -> list[str] | None:
"""The macro body as comment-free, whitespace-collapsed lines."""
if (match := _MACRO.search(text)) is None:
return None
return [
re.sub(r"\s+", " ", line)
for raw in match.group(1).splitlines()
if (line := raw.split("#", 1)[0].strip())
]
_EXPECTED_MACRO = [
line
for line in _normalized_macro(IDF_BOOTLOADER_OVERRIDE)
if line not in BOOTLOADER_OVERRIDE_ADDED_LINES
]
def idf_macro_matches(idf_path: Path) -> bool:
"""Whether IDF's macro still matches the copy the override replays."""
build_cmake = idf_path / "tools" / "cmake" / "build.cmake"
live = _normalized_macro(build_cmake.read_text(encoding="utf-8"))
return live == _EXPECTED_MACRO
def get_project_cmakelists(
minimal: bool = False, builtin_components: list[str] | None = None
) -> str:
@@ -123,6 +282,22 @@ def get_project_cmakelists(
else ""
)
# Stops the ~3s sections.ld regeneration on app-only edits; see
# _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior;
# ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump
# breaks the override instead of degrading to stock deps.
if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
ldgen_override = ""
ldgen_override_check = ""
else:
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
severity = "FATAL_ERROR" if strict else "WARNING"
missing = "FATAL_ERROR" if strict else "STATUS"
ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace(
"@MISSING@", missing
)
ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity)
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
@@ -200,6 +375,9 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{IDF_BOOTLOADER_OVERRIDE}
{ldgen_override}
{cpp_standard_options}
{cxx_compile_options}
@@ -212,14 +390,22 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
project({CORE.name})
{ldgen_override_check}
{_lwip_empty_sources_filter()}
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size
# target depends on the map and can then be built in the same run as all.
# IDF's cmakev2 declares the map itself, so drop this line on that switch.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
VERBATIM
)
@@ -282,9 +468,72 @@ idf_component_register(
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
)
{_pch_cmake_block()}"""
# Where CMake puts the .gch of the src component; ccache reads the checksum
# next to it in place of the .gch
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
# Where the Windows gate records its choice
_PCH_CHOICE_VAR = "ESPHOME_PCH"
def _pch_cmake_block() -> str:
"""The CMake block that precompiles the core headers for the C++ sources
of the src component; empty when disabled."""
if not pch.pch_enabled():
return ""
headers = "\n".join(
f' "$<$<COMPILE_LANGUAGE:CXX>:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"'
for header in pch.PCH_DEFAULT_HEADERS
)
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
{headers}
)"""
if not pch.pch_needs_gcc_check():
return f"\n# ESPHome precompiled header\n{block}\n"
# Before the first configure only CMake knows the compiler version
return f"""
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
else()
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
{textwrap.indent(block, " ")}
endif()
"""
def _read_if_exists(path: Path) -> str:
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_pch_checksum() -> None:
"""Write the checksum ccache uses in place of the .gch: the core headers,
the framework version, the sdkconfig and the managed component versions."""
from esphome.espidf.toolchain import get_cmake_cache_value
if not pch.pch_enabled():
return
# The gate's choice, cached by configure
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
return
pch.log_pch_in_use()
checksum = pch.pch_checksum(
CORE.relative_src_path(),
pch.PCH_DEFAULT_HEADERS,
(
str(idf_version()),
_read_if_exists(CORE.relative_build_path(f"sdkconfig.{CORE.name}")),
_read_if_exists(CORE.relative_build_path("dependencies.lock")),
),
)
path = CORE.relative_build_path(_PCH_SUM_PATH)
path.parent.mkdir(parents=True, exist_ok=True)
write_file_if_changed(path, checksum + "\n")
def write_project(
minimal: bool = False, builtin_components: list[str] | None = None
) -> None:
+33 -2
View File
@@ -15,6 +15,7 @@ from collections.abc import Iterable
import logging
import os
from pathlib import Path
import subprocess
import sys
from typing import TYPE_CHECKING
@@ -28,8 +29,11 @@ from esphome.build_helpers.ninja_gen import (
compile_edges,
compile_rule_lines,
library_edges,
pch_edges,
pch_rule_lines,
tool_lines,
)
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_enabled
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_project_cxx_compile_flags
from esphome.helpers import mkdir_p, write_file_if_changed
@@ -189,6 +193,15 @@ def _file_macro_maps(build_dir: Path) -> list[str]:
]
def _compiler_version(cxx: tuple[str, ...]) -> str:
"""What the compiler says it is: its path can stay the same across an
update (the macOS shims in /usr/bin)."""
result = subprocess.run(
[*cxx, "--version"], capture_output=True, text=True, check=False
)
return result.stdout
def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
"""Write the ninja build for the current configuration.
@@ -227,7 +240,8 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
" rspfile = $out.rsp",
@@ -251,7 +265,24 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
archives, direct_objs = library_edges(lines, libraries)
src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src")
src_cxx_override = pch_edges(
lines,
build_dir,
src_dir,
PCH_DEFAULT_HEADERS,
# The arguments of a CXX override come before the flags
[*compilers.cxx[1:], *cxxflags],
(),
(compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (),
compilers.cxx,
)
src_objs = compile_edges(
lines,
collect_sources(src_dir),
src_dir,
"src",
cxx_override=src_cxx_override,
)
if not src_objs:
raise EsphomeError(f"No source files found under {src_dir}")
+6
View File
@@ -1,6 +1,8 @@
from esphome.build_helpers.pch import pch_script_enabled
from esphome.const import __version__
from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed
from esphome.platformio.toolchain import copy_pch_script
from esphome.writer import find_begin_end
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
@@ -62,6 +64,8 @@ def get_ini_content():
# Add extra script for C++ flags
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
if pch_script_enabled():
CORE.add_platformio_option("extra_scripts", ["post:pch.py"])
# Add CMake args. A user-supplied value (str or list) is deliberately
# replaced; this option was always overwritten at FINAL priority.
@@ -106,6 +110,8 @@ def write_project():
# Write extra script for C++ specific flags
write_cxx_flags_script()
if pch_script_enabled():
copy_pch_script()
CXX_FLAGS_FILE_NAME = "cxx_flags.py"
+17 -2
View File
@@ -102,15 +102,30 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
return {k: v for k, v in defaults.items() if k not in os.environ}
def effective_ccache_basedir() -> str:
"""The prefix ccache strips from hashed paths: a usable user
CCACHE_BASEDIR, else the resolved build path."""
from esphome.core import CORE
raw = os.environ.get("CCACHE_BASEDIR")
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
return raw
return str(Path(CORE.build_path).resolve())
def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]:
"""The ccache settings for a build subprocess (not os.environ).
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when
disabled; ``tools_cache`` is the backend's tools cache spec, which holds
its ccache dir.
its ccache dir. The pch settings include ``time_macros``: a cached
object can keep an older ``__DATE__`` or ``__TIME__``.
"""
if ccache is None:
return {}
from esphome.build_helpers.pch import ccache_pch_env
from esphome.build_helpers.tools_cache import tools_cache_path
return ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache")
env = ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache")
env.update(ccache_pch_env())
return env
+9
View File
@@ -152,6 +152,15 @@ def _is_launcher(token: str) -> bool:
return Path(token).stem.lower() in _LAUNCHER_STEMS
def is_joined_include(tok: str) -> bool:
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
return (
tok.startswith("-include")
and tok != "-include"
and not tok.startswith("-include-")
)
def parse_entry(
entry: dict, launcher: str | None = None
) -> tuple[str, list[str], list[str], list[str]]:
+99 -15
View File
@@ -13,12 +13,23 @@ from pathlib import Path
import sys
from typing import TYPE_CHECKING
from esphome.build_helpers.idedata import is_joined_include
from esphome.build_helpers.ninja import (
escape as _e,
quote_path as _q,
shell_token as _shell_token,
)
from esphome.build_helpers.pch import (
PCH_GCH_NAME,
PCH_SUM_NAME,
log_pch_in_use,
pch_consumer_flags,
pch_identity,
pch_usable,
write_pch_headers,
)
from esphome.framework_helpers import strip_win_long_path_prefix
from esphome.helpers import write_file_if_changed
from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX
if TYPE_CHECKING:
@@ -63,37 +74,102 @@ def tool_lines(
# path gets
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
f"buildtool = {_q(_BUILD_TOOL)}",
f"ccache = {_q(ccache) if ccache else ''}",
*([f"ccache = {_q(ccache)}"] if ccache else []),
"",
]
def compile_rule_lines() -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
def compile_rule_lines(ccache: str | None) -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
``ccache`` is the launcher ``tool_lines`` was given; the same test here
keeps the variable and the rules in step. It is spliced in rather than
left as an empty variable: a leading space on the command line makes
CreateProcess fail on Windows.
"""
launcher = "$ccache " if ccache else ""
return [
"rule c",
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CC $out",
"rule cxx",
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CXX $out",
"rule aspp",
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = AS $out",
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
# depfile and no $flags (defines/includes) either
"rule asm",
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
" description = AS $out",
]
def pch_rule_lines() -> list[str]:
"""The precompiled header rule, for a generator that emits one."""
return [
# No $ccache: the .gch embeds build dir paths
"rule pch",
" command = $cxx -MMD -MF $out.d -x c++-header $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = PCH $out",
]
def pch_edges(
lines: list[str],
build_dir: Path,
src_dir: Path,
headers: Sequence[str],
cxxflags: Sequence[str],
src_flags: Sequence[str],
identity: Sequence[str],
cxx: Sequence[Path | str],
) -> tuple[str, str] | None:
"""Emit the precompiled header for the C++ src edges.
``headers`` are folded into one prefix header, ``src_flags`` are the
flags every src edge carries, ``identity`` names what else the compile
depends on, the compiler included, and ``cxx`` is what the host rule
asks. Returns the ``cxx_override`` for ``compile_edges``, or None
without a pch.
"""
if not pch_usable(cxx):
return None
if any(
tok == "-include" or tok.startswith("--include") or is_joined_include(tok)
for tok in cxxflags
):
# $cxxflags expands first and GCC only loads a .gch for the first
# -include
_LOGGER.warning(
"A -include in the compiler flags prevents the precompiled header from "
"loading; compiling without it"
)
return None
log_pch_in_use()
source = write_pch_headers(build_dir, headers)
write_file_if_changed(
build_dir / PCH_SUM_NAME,
pch_identity([*cxxflags, *src_flags], src_dir, tuple(headers), identity) + "\n",
)
gch = _e(PCH_GCH_NAME)
# The checksum file changes with anything the .gch depends on
lines.append(f"build {gch}: pch {_e(source)} | {_e(PCH_SUM_NAME)}")
if src_flags:
lines.append(f" flags = {' '.join(src_flags)}")
lines.append(f"srccxxflags = {' '.join([*src_flags, *pch_consumer_flags()])}")
return ("$srccxxflags", gch)
def ar_rule_lines(ar: Path | str) -> list[str]:
return [
"rule ar",
@@ -111,20 +187,26 @@ def compile_edges(
group: str,
flags: str = "",
own_includes: str = "",
cxx_override: tuple[str, str] | None = None,
) -> list[str]:
"""Emit compile edges for ``sources``; return the object paths."""
"""Emit compile edges for ``sources``; return the object paths.
``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges
only, replacing ``flags`` (used for the precompiled header).
"""
objects = []
for src in sources:
rel = src.relative_to(root).as_posix()
obj = f"obj/{group}/{rel}.o"
escaped_obj = _e(obj)
lines.append(
f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}"
)
kind = SOURCE_KIND_FOR_SUFFIX[src.suffix]
override = cxx_override if kind == "cxx" else None
implicit = f" | {override[1]}" if override else ""
lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}")
if own_includes:
lines.append(f" own_includes = {own_includes}")
if flags:
lines.append(f" flags = {flags}")
if edge_flags := override[0] if override else flags:
lines.append(f" flags = {edge_flags}")
# Escaped once here: the returned paths only ever appear in build
# statements (archive/link inputs), which use ninja escaping
objects.append(escaped_obj)
@@ -132,10 +214,12 @@ def compile_edges(
def library_edges(
lines: list[str], libraries: list[ArduinoLibrary]
lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = ""
) -> tuple[list[str], list[str]]:
"""Emit every library's compile and archive edges.
``extra_flags`` (raw ninja text) precede each library's own flags.
Returns the archive names and the objects that link directly. A
library's own include dirs lead its compile lines, as PlatformIO searched
them first: the include path is one global list, so another library's
@@ -157,7 +241,7 @@ def library_edges(
lib.sources,
common_parent(lib.sources),
f"lib/{lib.name}",
flags=" ".join(_shell_token(f) for f in lib.flags),
flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])),
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
)
if not lib.lib_archive:
+248
View File
@@ -0,0 +1,248 @@
"""Shared precompiled header policy for the build backends."""
from __future__ import annotations
from collections.abc import Iterable, Sequence
import hashlib
import logging
import os
from pathlib import Path
import posixpath
import re
import subprocess
import sys
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.const import PLATFORM_NRF52
from esphome.helpers import write_file_if_changed
_LOGGER = logging.getLogger(__name__)
# The header and its sidecars live in the build directory
PCH_HEADER_NAME = "esphome_pch.h"
PCH_GCH_NAME = f"{PCH_HEADER_NAME}.gch"
# ccache hashes this instead of the .gch; also the freshness stamp
PCH_SUM_NAME = f"{PCH_GCH_NAME}.sum"
# The include list the .gch is compiled from
PCH_SOURCE_NAME = "esphome_pch_src.h"
# GCC can skip a .gch without a diagnostic and read the header of the same
# name, so that header is an error. Other tools get the include list.
PCH_GUARD_TEXT = f"""\
#if defined(__GNUC__) && !defined(__clang__) && !defined(__INTELLISENSE__)
#error "The precompiled header was not loaded"
#else
#include "{PCH_SOURCE_NAME}"
#endif
"""
# The cc1plus wrapper the PlatformIO script writes on arm64 macOS
PCH_CC1_DIR = "pch_cc1"
# What the PlatformIO script leaves in the project root, for cleanup
PCH_ARTIFACT_NAMES = (PCH_HEADER_NAME, PCH_GCH_NAME, PCH_SUM_NAME, PCH_SOURCE_NAME)
PCH_ARTIFACT_DIRS = (PCH_CC1_DIR,)
# The core headers every backend precompiles
PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",)
# PlatformIO platforms that do not take the pch script
PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset(
{
PLATFORM_NRF52,
}
)
# What ccache needs to cache compiles that load a .gch
_CCACHE_PCH_SLOPPINESS = ("pch_defines", "time_macros")
# Both include forms: an angle include resolving under src/ enters the digest
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
def pch_enabled() -> bool:
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def pch_forced() -> bool:
"""``ESPHOME_PCH_ENABLE=1``: wanted even where the host rule says no."""
return parse_enable_env("ESPHOME_PCH_ENABLE") is True
# GCC bug 14940: before these releases the Windows loader maps a .gch only
# at its saved address. First fixed release per major, 16 on always fixed;
# PCH_WINDOWS_CMAKE_OLD_GCC and the pch_usable message spell the same table
PCH_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)}
PCH_WINDOWS_GCC_FIXED_DEFAULT = (16, 0)
# The same rule for CMake, which alone knows the version before configure
PCH_WINDOWS_CMAKE_OLD_GCC = (
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR "
"(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND "
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)"
)
def gcc_relocates_pch_on_windows(version: Sequence[int]) -> bool:
"""Whether a GCC of this version loads a .gch on Windows."""
if not version:
return False
fixed = PCH_WINDOWS_GCC_FIXED.get(version[0], PCH_WINDOWS_GCC_FIXED_DEFAULT)
return tuple(version[:2]) >= fixed
# GCC ends the first --version line with its version; clang names itself
_VERSION_RE = re.compile(r"\d+(?:\.\d+)+")
def gcc_version(cxx: Sequence[Path | str]) -> tuple[int, ...] | None:
"""The GCC version from ``--version``: () when it cannot be read, None
for a compiler that is not GCC."""
try:
result = subprocess.run(
[*cxx, "--version"], capture_output=True, text=True, check=False
)
except OSError as err:
_LOGGER.debug("Cannot run %s: %s", cxx[0], err)
return ()
banner = result.stdout.partition("\n")[0]
if "clang" in banner.lower():
return None
found = _VERSION_RE.findall(banner)
return tuple(int(part) for part in found[-1].split(".")) if found else ()
def pch_needs_gcc_check() -> bool:
"""Windows host with the knob unset: the compiler version decides."""
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
def pch_usable(cxx: Sequence[Path | str]) -> bool:
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
if not pch_enabled():
return False
if not pch_needs_gcc_check():
return True
version = gcc_version(cxx)
if version is None or gcc_relocates_pch_on_windows(version):
return True
_LOGGER.info(
"GCC %s cannot load a precompiled header on Windows (GCC bug 14940, "
"fixed in 14.4, 15.3 and 16); compiling without it "
"(set ESPHOME_PCH_ENABLE=1 to force)",
".".join(map(str, version)) or "of unknown version",
)
return False
def pch_consumer_flags() -> list[str]:
"""Flags a C++ src compile loads the pch with. The -include stays
relative: an absolute path would enter the ccache key."""
return ["-Winvalid-pch", "-Werror=invalid-pch", "-include", PCH_HEADER_NAME]
def ccache_pch_env() -> dict[str, str]:
"""What ccache needs to cache compiles that load a .gch, added to what
the user already set."""
if not pch_enabled():
return {}
sloppiness = [
item.strip()
for item in os.environ.get("CCACHE_SLOPPINESS", "").split(",")
if item.strip()
]
sloppiness += [item for item in _CCACHE_PCH_SLOPPINESS if item not in sloppiness]
env = {"CCACHE_SLOPPINESS": ",".join(sloppiness)}
if "CCACHE_PCH_EXTSUM" not in os.environ:
env["CCACHE_PCH_EXTSUM"] = "true"
return env
def pch_script_enabled() -> bool:
"""Whether this PlatformIO build takes the pch script."""
from esphome.core import CORE
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
def pch_header_text(include_headers: Iterable[str]) -> str:
"""The prefix-header source: exactly these includes, in order."""
return "".join(f'#include "{name}"\n' for name in include_headers)
def write_pch_headers(build_dir: Path, include_headers: Iterable[str]) -> Path:
"""Write the guard header and the include list; return the latter,
which is what the .gch compiles from."""
write_file_if_changed(build_dir / PCH_HEADER_NAME, PCH_GUARD_TEXT)
source = build_dir / PCH_SOURCE_NAME
write_file_if_changed(source, pch_header_text(include_headers))
return source
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
"""Include closure of ``roots``: src-relative name -> contents.
Resolution mirrors the compiler (includer's dir, then src root). No
#ifdef evaluation: including too much is the safe direction. Headers
outside ``src_dir`` are covered by the version strings of the caller.
"""
seen: dict[str, bytes] = {}
stack: list[tuple[str, str]] = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and (src_dir / rel).is_file():
break
else:
continue
if rel in seen:
continue
data = seen[rel] = (src_dir / rel).read_bytes()
parent = posixpath.dirname(rel)
stack.extend((inc.decode(), parent) for inc in _INCLUDE_RE.findall(data))
return seen
def pch_checksum(
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
) -> str:
"""Digest of the prefix header's include closure plus ``extra``."""
digest = hashlib.sha256()
closure = _include_closure(src_dir, include_headers)
for name in sorted(closure):
digest.update(name.encode())
digest.update(closure[name])
digest.update(b"\0")
for item in extra:
digest.update(item.encode())
digest.update(b"\0")
return digest.hexdigest()
def pch_identity(
tokens: Iterable[str],
src_dir: Path,
include_headers: tuple[str, ...],
extra: Iterable[str],
) -> str:
"""The .sum digest: include closure, header text, ``extra`` and the
compile flags with the build path stripped, as ccache does."""
from esphome.core import CORE
flags = (
" ".join(tokens)
.replace(str(CORE.build_path), "")
.replace(effective_ccache_basedir(), "")
)
# The closure is sorted, so header order only enters via the text
return pch_checksum(
src_dir, include_headers, (pch_header_text(include_headers), *extra, flags)
)
_DISABLE_HINT = " (set ESPHOME_PCH_ENABLE=0 to disable)"
def log_pch_in_use() -> None:
_LOGGER.info("Compiling with a precompiled header%s", _DISABLE_HINT)
+180
View File
@@ -0,0 +1,180 @@
"""Run a native build tool (cmake, ninja) and relay its output.
Output is read from a pipe so it can be filtered here: a child that inherits
our stdout writes straight to the file descriptor, past any Python wrapper.
"""
from __future__ import annotations
import codecs
from contextlib import suppress
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, TextIO
from esphome.util import ANSI_ESCAPE, RedirectText, shlex_quote
_LOGGER = logging.getLogger(__name__)
# Windows code page identifier for UTF-8, as used by ``chcp 65001``.
UTF8_CODEPAGE = 65001
# Same pattern idf.py uses to spot ninja status lines (``is_progression``).
_PROGRESS = re.compile(r"^\[\d+/\d+\]|.*\(\d+ \%\)$")
_READ_SIZE = 65536
def _get_kernel32() -> Any | None:
"""Return the Windows kernel32 module, or None on any other platform."""
if sys.platform != "win32":
return None
import ctypes
return ctypes.windll.kernel32
class Utf8Console:
"""Keep an attached Windows console on UTF-8 while a build tool runs.
esp_idf_size draws its table with Unicode box characters, and CMake
re-decodes a child's output with the console code page, which garbles the
table on any page but UTF-8. A console already on UTF-8 is left alone so
an overlapping build never records UTF-8 as the page to go back to.
"""
def __init__(self, kernel32: Any | None) -> None:
self._kernel32 = kernel32
self._codepages: tuple[int, int] | None = None
def __enter__(self) -> None:
kernel32 = self._kernel32
if kernel32 is None:
return
old_in = kernel32.GetConsoleCP()
old_out = kernel32.GetConsoleOutputCP()
# Both calls return 0 when no console is attached.
if not old_in or not old_out:
return
if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE:
return
# Record first so a switch that fails part way is still undone.
self._codepages = (old_in, old_out)
kernel32.SetConsoleCP(UTF8_CODEPAGE)
kernel32.SetConsoleOutputCP(UTF8_CODEPAGE)
def __exit__(self, *exc_info: object) -> None:
if self._codepages is None:
return
old_in, old_out = self._codepages
self._codepages = None
self._kernel32.SetConsoleCP(old_in)
self._kernel32.SetConsoleOutputCP(old_out)
def _fit_terminal(text: str) -> str:
"""Elide the middle of ``text`` to fit the terminal, as idf.py does.
A width of 0 (a pipe, the dashboard) leaves the text whole.
"""
width = shutil.get_terminal_size((0, 0)).columns
if not width:
return text
if width <= 3:
return "." * width
if len(text) >= width:
keep = (width - 3) // 2
return f"{text[:keep]}...{text[len(text) - keep :]}"
return text
class ToolOutput(RedirectText):
"""RedirectText that can collapse ninja status lines into one line.
With ``progress`` each ``[n/m]`` line overwrites the previous one, the
way idf.py shows a build.
"""
def __init__(
self, out: TextIO, filter_lines: list[str] | None, progress: bool
) -> None:
super().__init__(out, filter_lines=filter_lines)
self._progress = progress
self._on_progress_line = False
def _splits_lines(self) -> bool:
return self._progress or super()._splits_lines()
def _emit_line(self, line: str) -> None:
if self._progress and _PROGRESS.match(line):
if not self._is_filtered(line):
text = _fit_terminal(line.strip("\r\n"))
self._write_color_replace(f"\r{text}\x1b[K")
self._on_progress_line = True
return
self._end_progress_line()
super()._emit_line(line)
def _end_progress_line(self) -> None:
if self._on_progress_line:
self._on_progress_line = False
self._write_color_replace(os.linesep)
def drain(self) -> None:
super().drain()
# Called from cleanup, so a broken stream must not hide the exit code.
with suppress(OSError, ValueError):
self._end_progress_line()
self._out.flush()
def run_build_tool(
cmd: list[str],
*,
cwd: Path,
env: dict[str, str],
filter_lines: list[str] | None = None,
progress: bool = False,
log_path: Path | None = None,
) -> int:
"""Run ``cmd`` and relay stdout and stderr, merged, to our stdout.
``log_path`` also gets the full, unfiltered output without color codes, as
idf.py wrote its logs (its hint patterns expect plain text). Returns the
exit code.
"""
_LOGGER.debug("Running: %s", " ".join(shlex_quote(arg) for arg in cmd))
_LOGGER.debug(" in directory: %s", cwd)
output = ToolOutput(sys.stdout, filter_lines, progress)
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
if log_path is not None:
log_path.parent.mkdir(parents=True, exist_ok=True)
with (
Path(log_path or os.devnull).open("w", encoding="utf-8", newline="") as log,
Utf8Console(_get_kernel32()),
subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=False,
) as proc,
):
try:
# read1 returns as soon as anything is available, so output
# streams while the tool runs.
while chunk := proc.stdout.read1(_READ_SIZE):
text = decoder.decode(chunk)
log.write(ANSI_ESCAPE.sub("", text))
output.write(text)
if tail := decoder.decode(b"", final=True):
log.write(ANSI_ESCAPE.sub("", tail))
output.write(tail)
finally:
output.drain()
return proc.returncode
+5 -2
View File
@@ -16,8 +16,8 @@ def tools_cache_path(env_var: str, subdir: str) -> Path:
from esphome.helpers import get_str_env
if prefix := get_str_env(env_var, "").strip():
# resolve(): symlinked prefixes otherwise trip idf.py's
# venv-mismatch warning on every build
# resolve(): a symlinked prefix would otherwise record a second
# spelling of the same paths in the build tree
return Path(prefix).expanduser().resolve()
# appauthor=False keeps the Windows path short (no vendor segment);
# deep IDF trees run into MAX_PATH otherwise
@@ -35,9 +35,12 @@ SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
# The host backend installs nothing; the entry only holds its ccache dir
HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host")
# PlatformIO installs into its own dirs; the entry is its ccache dir itself
PLATFORMIO_CCACHE = ("ESPHOME_PLATFORMIO_CCACHE_DIR", "platformio-ccache")
TOOLS_CACHE_SPECS = (
IDF_TOOLS_CACHE,
SDK_NRF_TOOLS_CACHE,
ARDUINO8266_TOOLS_CACHE,
HOST_TOOLS_CACHE,
PLATFORMIO_CCACHE,
)
+2
View File
@@ -0,0 +1,2 @@
"""Commands of the esphome command line, one module each, imported by
__main__ only when they run so that startup stays light."""
+164
View File
@@ -0,0 +1,164 @@
"""``esphome rename``."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
from esphome import yaml_edit, yaml_util
from esphome.const import (
ALLOWED_NAME_CHARS,
CONF_ESPHOME,
CONF_NAME,
CONF_SUBSTITUTIONS,
)
from esphome.core import CORE, EsphomeError
from esphome.log import AnsiFore, color
from esphome.types import ConfigType
from esphome.util import ESPHOME_COMMAND, run_external_process, safe_print
def _revert(new_path: Path, why: str) -> int:
"""Say why the rename stopped and take the new file back; an orphan the
next attempt would trip over is reported."""
safe_print(color(AnsiFore.BOLD_RED, f"Rename failed: {why}"))
try:
new_path.unlink(missing_ok=True)
except OSError as err:
safe_print(color(AnsiFore.BOLD_RED, f"Could not remove {new_path}: {err}"))
return 1
def command_rename(args: argparse.Namespace, config: ConfigType) -> int | None:
"""Rename the device: a new file with the name line rewritten, validated
and installed, then the old file removed."""
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
)
)
return 1
yaml = yaml_util.load_yaml(CORE.config_path)
def name_edit() -> tuple[str, yaml_edit.LineEdit]:
"""The name and the line to rewrite: the name's own line, or the
substitution's line it comes from, as a plain value in this file."""
esphome_conf = yaml.get(CONF_ESPHOME)
if not isinstance(esphome_conf, dict) or CONF_NAME not in esphome_conf:
raise EsphomeError(f"no '{CONF_ESPHOME}: {CONF_NAME}:' in the file")
old_name = str(esphome_conf[CONF_NAME])
mapping, field = esphome_conf, CONF_NAME
if match := re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name):
mapping, field = yaml.get(CONF_SUBSTITUTIONS), match.group(1)
if not isinstance(mapping, dict) or field not in mapping:
raise EsphomeError(f"the substitution '{field}' is not in the file")
old_name = str(mapping[field])
# Only read here; the rewritten text goes to a new file, so the
# source may live anywhere the config path points to
source = yaml_edit.source_of(mapping, field)
if source is None or source[0].resolve() != CORE.config_path.resolve():
raise EsphomeError(f"'{field}' was not read from {CORE.config_path}")
doc, line_no = source
text = yaml_edit.line_at(doc, line_no)
if (line_match := yaml_edit.field_line_re(field, old_name).match(text)) is None:
raise EsphomeError(f"'{field}' is not a plain value on {doc}:{line_no + 1}")
# The new value is always quoted, whatever the old line had
return old_name, yaml_edit.LineEdit(
doc, line_no, text, yaml_edit.rewrite(line_match, new_name, '"')
)
try:
old_name, edit = name_edit()
except EsphomeError as err:
safe_print(
color(
AnsiFore.BOLD_RED,
f"Complex YAML files cannot be automatically renamed: {err}",
)
)
return 1
# ``new_name == old_name`` (after substitution resolution) is
# a no-op rewrite that would still queue a pointless re-flash.
# Catch it before the path-equality check below — covers the
# case where the config filename doesn't match the device name
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
if new_path.exists():
safe_print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
"Refusing to overwrite an existing configuration.",
)
)
return 1
safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
try:
yaml_edit.write_keeping_mode(
new_path,
yaml_edit.rewritten_text(yaml_edit.read_text(CORE.config_path), [edit]),
like=CORE.config_path,
)
except EsphomeError as err:
return _revert(new_path, str(err))
if run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) != 0:
return _revert(new_path, "the new configuration does not validate")
cli_args = [
"run",
str(new_path),
"--no-logs",
"--device",
CORE.address,
]
if args.dashboard:
cli_args.insert(0, "--dashboard")
try:
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
except KeyboardInterrupt:
rc = 1
if rc != 0:
return _revert(
new_path,
"the install did not finish; the device may already run the new name",
)
CORE.config_path.unlink()
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
+2
View File
@@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401
add_global,
add_library,
add_platformio_option,
extern_progmem_array,
get_variable,
get_variable_with_full_id,
is_template,
@@ -40,6 +41,7 @@ from esphome.cpp_generator import ( # noqa: F401
progmem_array,
safe_exp,
set_cpp_standard,
shared_progmem_array,
statement,
static_const_array,
static_function,
+4 -10
View File
@@ -62,16 +62,10 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
sens = await sensor.new_sensor(config[CONF_TVOC])
cg.add(var.set_tvoc(sens))
if version_config := config.get(CONF_VERSION):
sens = await sensor.new_sensor(version_config)
cg.add(var.set_version(sens))
if resistance_config := config.get(CONF_RESISTANCE):
sens = await sensor.new_sensor(resistance_config)
cg.add(var.set_resistance(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TVOC, var.set_tvoc)
await sensors(CONF_VERSION, var.set_version)
await sensors(CONF_RESISTANCE, var.set_resistance)
AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
+3 -7
View File
@@ -57,10 +57,6 @@ async def to_code(config: ConfigType) -> None:
await i2c.register_i2c_device(var, config)
cg.add(var.set_variant(config[CONF_VARIANT]))
if temperature := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature)
cg.add(var.set_temperature_sensor(sens))
if humidity := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+7 -23
View File
@@ -74,26 +74,10 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
if flow_config := config.get(CONF_FLOW):
sens = await sensor.new_sensor(flow_config)
cg.add(var.set_flow_sensor(sens))
if head_config := config.get(CONF_HEAD):
sens = await sensor.new_sensor(head_config)
cg.add(var.set_head_sensor(sens))
if power_config := config.get(CONF_POWER):
sens = await sensor.new_sensor(power_config)
cg.add(var.set_power_sensor(sens))
if current_config := config.get(CONF_CURRENT):
sens = await sensor.new_sensor(current_config)
cg.add(var.set_current_sensor(sens))
if speed_config := config.get(CONF_SPEED):
sens = await sensor.new_sensor(speed_config)
cg.add(var.set_speed_sensor(sens))
if voltage_config := config.get(CONF_VOLTAGE):
sens = await sensor.new_sensor(voltage_config)
cg.add(var.set_voltage_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_FLOW, var.set_flow_sensor)
await sensors(CONF_HEAD, var.set_head_sensor)
await sensors(CONF_POWER, var.set_power_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_SPEED, var.set_speed_sensor)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
+3 -7
View File
@@ -46,10 +46,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
if battery_level_config := config.get(CONF_BATTERY_LEVEL):
sens = await sensor.new_sensor(battery_level_config)
cg.add(var.set_battery(sens))
if illuminance_config := config.get(CONF_ILLUMINANCE):
sens = await sensor.new_sensor(illuminance_config)
cg.add(var.set_illuminance(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_BATTERY_LEVEL, var.set_battery)
await sensors(CONF_ILLUMINANCE, var.set_illuminance)
+222 -84
View File
@@ -1,3 +1,4 @@
from ipaddress import IPv4Address, IPv6Address
import logging
import re
from typing import Any
@@ -5,7 +6,7 @@ from typing import Any
from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.const import CONF_DESCRIPTION
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
from esphome.components.logger import request_log_listener
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
@@ -13,6 +14,7 @@ from esphome.components.logger import request_log_listener
from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
enable_spare_ephemeral,
encryption_schema,
new_psk_progmem,
validate_encryption_key,
@@ -25,6 +27,8 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DELAY,
CONF_ENABLE_IPV6,
CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
@@ -47,10 +51,14 @@ from esphome.const import (
CONF_VARIABLES,
)
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.helpers import fnv1_hash
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigFragmentType, ConfigType
from . import wizard
# Compat alias: downstream consumers (e.g. device-builder) referenced the
# schema by its old private name before it moved to the noise component
_encryption_schema = encryption_schema
@@ -85,6 +93,7 @@ APIServer = api_ns.class_("APIServer", cg.Component)
HomeAssistantServiceCallAction = api_ns.class_(
"HomeAssistantServiceCallAction", automation.Action
)
HomeAssistantField = api_ns.struct("HomeAssistantField")
ActionResponse = api_ns.class_("ActionResponse")
HomeAssistantActionResponseTrigger = api_ns.class_(
"HomeAssistantActionResponseTrigger", automation.Trigger
@@ -134,6 +143,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
CONF_HOMEASSISTANT_STATES = "homeassistant_states"
CONF_LISTEN_BACKLOG = "listen_backlog"
CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_OUTGOING_CONNECTION = "outgoing_connection"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
# Schema defaults that also match the C++ initializers in api_server.h; codegen
@@ -141,6 +151,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
DEFAULT_PORT = 6053
DEFAULT_REBOOT_TIMEOUT = "15min"
DEFAULT_BATCH_DELAY = "100ms"
DEFAULT_LISTEN_BACKLOG = 4
def _register_provisioning_source(config: ConfigType) -> ConfigType:
@@ -291,9 +302,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
# (not max_connections, which is the upper limit rarely reached)
socket.consume_sockets(3, "api")(config)
socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config)
if CONF_OUTGOING_CONNECTION in config:
socket.consume_sockets(1, "api_outgoing_connection")(config)
return config
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None:
return config
if CONF_ENCRYPTION not in config:
raise cv.Invalid(
"outgoing_connection requires 'encryption' so the peer is verified by key",
path=[CONF_OUTGOING_CONNECTION],
)
# A device with no client reboots once reboot_timeout passes, so a delay that
# reaches it would reboot the device before it ever dials
reboot_timeout = config[CONF_REBOOT_TIMEOUT]
delay = outgoing[CONF_DELAY]
if reboot_timeout.total_milliseconds and delay >= reboot_timeout:
raise cv.Invalid(
f"delay must be shorter than reboot_timeout ({reboot_timeout}), "
"otherwise the device reboots before it dials",
path=[CONF_OUTGOING_CONNECTION, CONF_DELAY],
)
return config
def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address:
"""Only accept an address the device itself can parse.
Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton`
takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build.
"""
address = cv.ipaddress(value)
if isinstance(address, IPv6Address):
if address.scope_id is not None:
raise cv.Invalid(
f"{value} carries a scope id, which the device cannot parse; "
"give the address without the '%' part"
)
if (mapped := address.ipv4_mapped) is not None:
return mapped
return address
_OUTGOING_CONNECTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_HOST): _validate_outgoing_host,
cv.Optional(CONF_PORT, default=6054): cv.port,
# Bounded against reboot_timeout in _validate_outgoing_connection
cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds,
}
)
@schema_extractor("schema")
def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType:
# A bare `outgoing_connection:` block is valid; without a host the device
# dials the remembered last dial-back client
if config is SCHEMA_EXTRACT:
# Let the language-schema dumper walk host, port and delay
return _OUTGOING_CONNECTION_SCHEMA
if config is None:
config = {}
return _OUTGOING_CONNECTION_SCHEMA(config)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -318,6 +392,8 @@ CONFIG_SCHEMA = cv.All(
): ACTIONS_SCHEMA,
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA,
cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema,
cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
@@ -373,6 +449,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_validate_outgoing_connection,
_consume_api_sockets,
_register_provisioning_source,
)
@@ -429,7 +506,34 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
if (
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
or (host := outgoing.get(CONF_HOST)) is None
or host.version != 6
):
return config
network_conf = fv.full_config.get().get("network") or {}
if not network_conf.get(CONF_ENABLE_IPV6):
raise cv.Invalid(
"outgoing_connection host is an IPv6 address but IPv6 is not "
"enabled; set 'network: enable_ipv6: true'",
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
)
return config
def _validate_wizard(config: ConfigType) -> ConfigType:
wizard.final_validate(config)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_esp8266_action_strings,
_validate_outgoing_host_ipv6,
_validate_wizard,
)
def _add_action_strings(
@@ -476,8 +580,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_reboot_timeout(reboot_timeout))
if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY):
cg.add(var.set_batch_delay(batch_delay))
if CONF_LISTEN_BACKLOG in config:
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
if (
listen_backlog := config.get(CONF_LISTEN_BACKLOG)
) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG:
cg.add(var.set_listen_backlog(listen_backlog))
cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
@@ -582,6 +688,9 @@ async def to_code(config: ConfigType) -> None:
# Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action
cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1))
if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None:
await wizard.to_code(wizard_config)
if CONF_ON_CLIENT_CONNECTED in config:
cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER")
await automation.build_automation(
@@ -600,7 +709,7 @@ async def to_code(config: ConfigType) -> None:
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key)))
cg.add(var.set_noise_psk(new_psk_progmem(key)))
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
@@ -613,9 +722,17 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
enable_spare_ephemeral()
else:
cg.add_define("USE_API_PLAINTEXT")
if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None:
cg.add_define("USE_API_OUTGOING_CONNECTION")
if (host := outgoing.get(CONF_HOST)) is not None:
cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host))
cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT])
cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY])
cg.add_define("USE_API")
cg.add_global(api_ns.using)
@@ -656,6 +773,11 @@ VARIABLES_SCHEMA = cv.Schema(
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
)
# The action stores each map's entry count in a uint8_t
_FIELD_MAP_MAX = 255
DATA_FIELDS_SCHEMA = cv.All(KEY_VALUE_SCHEMA, cv.Length(max=_FIELD_MAP_MAX))
VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX))
def _validate_response_config(config: ConfigType) -> ConfigType:
# Validate dependencies:
@@ -690,9 +812,9 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.templatable(
cv.string
),
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA,
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
@@ -705,6 +827,62 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
)
def _field_string(value: str) -> Expression:
# ESP8266 can only keep a string in flash as its own PROGMEM array
literal = cg.RawExpression(cpp_string_escape(value))
if CORE.is_esp8266:
return cg.shared_progmem_array("ha_field_str", cg.char, literal)
return literal
async def _new_service_call_action(
server_id: ID,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
is_event: bool,
service: Any,
data: dict[str, Any],
data_template: dict[str, Any],
variables: dict[str, Any],
) -> MockObj:
"""Create the action with its name and fields in one shared flash table."""
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(server_id)
field_type = HomeAssistantField.template(template_arg)
groups = [data, data_template, variables]
# A lambda may keep static state, so a table with lambdas is never shared
has_lambda = cg.is_template(service) or any(
cg.is_template(value) for group in groups for value in group.values()
)
async def field(key: str | None, value: Any, output_type: Any = None) -> Expression:
key_exp = cg.nullptr if key is None else _field_string(key)
if cg.is_template(value):
# output_type=None lets lambdas return numbers or char pointers; C++ converts them
lam = await cg.process_lambda(value, args, return_type=output_type)
return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})")
return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr)
entries = [await field(None, service, cg.std_string)]
for group in groups:
for key, value in group.items():
entries.append(await field(key, value))
table = cg.shared_progmem_array(
"ha_action_fields",
field_type,
cg.ArrayInitializer(*entries, multiline=True),
share=not has_lambda,
)
return cg.new_Pvariable(
action_id, template_arg, serv, is_event, table, *(len(g) for g in groups)
)
def _service_call_fields(config: ConfigType) -> tuple[dict[str, Any], ...]:
return config[CONF_DATA], config[CONF_DATA_TEMPLATE], config[CONF_VARIABLES]
# synchronous=False: when on_success/on_error is configured, play() stores the
# trigger args until the HomeassistantActionResponse arrives, so non-owning args
# (StringRef into the API receive buffer) must not be used.
@@ -726,36 +904,15 @@ async def homeassistant_service_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
# output_type=None because lambdas can return non-string types (int,
# float, char*) that TemplatableStringValue converts via to_string.
# Static strings are manually wrapped for PROGMEM on ESP8266.
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
var = await _new_service_call_action(
config[CONF_ID],
action_id,
template_arg,
args,
False,
config[CONF_ACTION],
*_service_call_fields(config),
)
if on_error := config.get(CONF_ON_ERROR):
cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES")
@@ -807,9 +964,9 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(APIServer),
cv.Required(CONF_EVENT): validate_homeassistant_event,
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA,
}
)
@@ -828,38 +985,15 @@ async def homeassistant_event_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string)
cg.add(var.set_service(templ))
# Initialize FixedVectors with exact sizes from config
cg.add(var.init_data(len(config[CONF_DATA])))
for key, value in config[CONF_DATA].items():
# output_type=None because lambdas can return non-string types (int,
# float, char*) that TemplatableStringValue converts via to_string.
# Static strings are manually wrapped for PROGMEM on ESP8266.
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data(cg.FlashStringLiteral(key), templ))
cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE])))
for key, value in config[CONF_DATA_TEMPLATE].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ))
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
return var
return await _new_service_call_action(
config[CONF_ID],
action_id,
template_arg,
args,
True,
config[CONF_EVENT],
*_service_call_fields(config),
)
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
@@ -883,15 +1017,17 @@ async def homeassistant_tag_scanned_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned")))
# Initialize FixedVector with exact size (1 data field)
cg.add(var.init_data(1))
templ = await cg.templatable(config[CONF_TAG], args, cg.std_string)
cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ))
return var
return await _new_service_call_action(
config[CONF_ID],
action_id,
template_arg,
args,
True,
"esphome.tag_scanned",
{"tag_id": config[CONF_TAG]},
{},
{},
)
CONF_SUCCESS = "success"
@@ -1002,6 +1138,8 @@ _define_filter = filter_source_files_from_defines(
"user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
"api_frame_helper_noise.cpp": "USE_API_NOISE",
"api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
"api_wizard.cpp": "USE_API_WIZARD",
"api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION",
}
)
+227 -3
View File
@@ -20,6 +20,8 @@ service APIConnection {
option (needs_authentication) = false;
}
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {}
rpc wizard_input_set (WizardInputSetRequest) returns (void) {}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -76,8 +78,11 @@ service APIConnection {
rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {}
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {}
}
@@ -113,6 +118,11 @@ message HelloRequest {
string client_info = 1;
uint32 api_version_major = 2;
uint32 api_version_minor = 3;
// Set by clients that can accept connections the device opens to them
// (see api: outgoing_connection:). The device remembers this client's
// address as the target to dial when no such client is connected.
bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
}
// Confirmation of successful connection request.
@@ -228,6 +238,7 @@ enum SerialProxyPortType {
SERIAL_PROXY_PORT_TYPE_TTL = 0;
SERIAL_PROXY_PORT_TYPE_RS232 = 1;
SERIAL_PROXY_PORT_TYPE_RS485 = 2;
SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3; // since API 1.18
}
message SerialProxyInfo {
@@ -332,6 +343,10 @@ message DeviceInfoResponse {
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
// Device is built with the api outgoing_connection option and can open
// the TCP connection to a dial-back target itself
bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"];
}
// ==================== DEVICE CAPABILITIES ====================
@@ -380,6 +395,16 @@ message ZWaveProxyCapabilities {
uint32 home_id = 2;
}
message WizardCapabilities {
// True when the device has a wizard configured, so DeviceWizardRequest will be answered
bool configured = 1;
}
message SendspinCapabilities {
// Bitmask of the Sendspin features this device supports
uint32 feature_flags = 1;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
@@ -389,6 +414,102 @@ message DeviceCapabilitiesResponse {
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"];
SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"];
}
// ==================== DEVICE WIZARD ====================
// Asks the device for the onboarding wizard that Home Assistant presents when the
// device is added.
//
// The wizard is read once, when the device is added, whereas capabilities are read
// on every connect, so it is a separate message rather than part of
// DeviceCapabilitiesResponse. It is only served on an authenticated connection.
//
// A device without a wizard does not have this message at all and never answers it,
// as a device ignores messages it does not know. Clients therefore check
// DeviceCapabilitiesResponse.wizard.configured before sending it.
message DeviceWizardRequest {
option (id) = 156;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_API_WIZARD";
// Empty
}
// The wizard, as UTF-8 JSON compressed into a single zstd frame. The device builds it
// when it is compiled and sends it unchanged, so its size varies with the wizard and
// its format can grow without new fields here. Clients decompress it, then read:
//
// {"version": 1,
// "pages": [{"title": "...", "description": "...",
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
// "entity_filters": [{"integration": "...", "domain": ["..."],
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
//
// Values that are empty or unset are left out, and so are empty lists. Strings may be
// Home Assistant translation placeholders such as "[%key:component::domain::name%]",
// passed through verbatim. The pages are shown in the order listed. A client that does
// not know the version must not use the wizard.
//
// An entity is an entity of this device that the page shows to the user (for example to
// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its
// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0.
//
// An input is filled in by the user with the id of a Home Assistant entity. It either
// stands for one entity of the device's homeassistant platform (sensor, binary_sensor,
// text_sensor, number, switch, text, select or button) that sets no entity_id of its own,
// as Home Assistant supplies it, or it is a standalone input that only stores the entity
// id for the device's own automations. Its key is the FNV-1 hash
// of the input's ESPHome id, not an entity key. The client sends the chosen entity id to
// the device with a WizardInputSetRequest. entity_filters says which Home Assistant
// entities the user may choose, and mirrors Home Assistant's EntityFilterSelectorConfig:
// an entity matches a filter when it satisfies every field that is set, and an entity
// that matches any one of the filters is accepted.
//
// An input with "multiple": true lets the user pick several entities. The client sends
// them in one WizardInputSetRequest as a comma separated list with no spaces
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
// longest list in bytes the device accepts. The version stays 1: a client that does not
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
// multiple input accepts.
//
// The device keeps the entity id of an input in RAM only and does not store it across
// restarts. The client therefore stores the choice itself and, after every connection
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
// for each input. It sends one again whenever the user changes the choice.
message DeviceWizardResponse {
option (id) = 157;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_API_WIZARD";
// The data is in flash, and the device sends it from there
bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true];
}
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
// reply. The device ignores a request whose key matches no input. A single input is
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
// spaces, the device trims spaces around each id and stores the list without them. The
// whole request is ignored when any id is empty or has no '.', or when the list without
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
// or list in RAM,
// not across restarts, and when the input belongs to a homeassistant platform entity
// it subscribes to that entity's state. Clients send this for every input right
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
// arrives later still works: the device then sends the affected state subscriptions
// again.
message WizardInputSetRequest {
option (id) = 158;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_API_WIZARD_INPUTS";
fixed32 key = 1;
string entity_id = 2 [(max_data_length) = 4095];
}
message ListEntitiesRequest {
@@ -1439,7 +1560,7 @@ message ListEntitiesSelectResponse {
reserved 4; // Deprecated: was string unique_id
string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
repeated string options = 6 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string options = 6 [(container_pointer_no_template) = "std::span<const char *const>"];
bool disabled_by_default = 7;
EntityCategory entity_category = 8;
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
@@ -2683,7 +2804,7 @@ message ListEntitiesInfraredResponse {
message InfraredRFTransmitRawTimingsRequest {
option (id) = 136;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
option (ifdef) = "USE_IR_RF";
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
@@ -2697,7 +2818,7 @@ message InfraredRFTransmitRawTimingsRequest {
message InfraredRFReceiveEvent {
option (id) = 137;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
option (ifdef) = "USE_IR_RF";
option (no_delay) = true;
option (speed_optimized) = true;
@@ -2706,6 +2827,28 @@ message InfraredRFReceiveEvent {
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
}
// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the
// transmitter reports that the transmission (all repeats) has finished, or immediately with
// success=false if it was refused before reaching the transmitter (unknown key, no transmitter,
// no or invalid timings) or the transmitter could not send it (not set up, hardware error).
// success=false also answers a request superseded by a newer request on the same entity, from
// any client, and a transmit that reported nothing within 30 s past its expected air time, so a
// client should not retry on it blindly. The device serializes transmits per transmitter and
// several entities may share one, so a client should keep at most one transmit outstanding per
// device, not per entity. A reply for an unknown key or a refused request is sent once and can be lost
// when the device's send buffer is full, so a client should also stop waiting on its own after the
// expected air time plus a margin. Lets clients pace requests instead of estimating durations (since API 1.18)
message InfraredRFTransmitCompleteResponse {
option (id) = 153;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_IR_RF";
option (no_delay) = true;
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request
bool success = 3; // false if the request was refused, not sent, superseded, or never reported
}
// ==================== RADIO FREQUENCY ====================
// Lists available radio frequency entity instances
@@ -2876,6 +3019,56 @@ message SerialProxySetModeRequest {
SerialProxyMode mode = 2;
}
// Subscribe to the identity of every serial proxy port. The device answers with one
// SerialProxyIdentity per port, then sends another whenever a port's identity changes,
// for the life of the connection (since API 1.18).
message SubscribeSerialProxyIdentityRequest {
option (id) = 154;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
}
// Where a port's identity comes from
enum SerialProxyIdentitySource {
SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity
SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet
SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port;
// changes when a device is attached or removed
}
enum SerialProxyIdentityFlag {
SERIAL_PROXY_IDENTITY_FLAG_NONE = 0;
SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port
SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and
// IDs below are empty
}
// The descriptor fields of a USB device as seen by the host stack
message UsbDeviceDescriptor {
uint32 vendor_id = 1;
uint32 product_id = 2;
uint32 bcd_device = 3;
uint32 interface_number = 4; // bInterfaceNumber the host driver binds to
}
// The identity of the device behind a port. Identifies one physical device among others of
// the same kind, so a client matches on manufacturer and product regardless of source. The
// strings are empty for source NONE, and for source USB while nothing is connected
// (since API 1.18).
message SerialProxyIdentity {
option (id) = 155;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyIdentitySource source = 2;
uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag
string manufacturer = 4;
string product = 5;
string serial_number = 6;
UsbDeviceDescriptor usb = 7; // Only sent for source USB
}
// ==================== BLUETOOTH CONNECTION PARAMS ====================
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
@@ -2897,3 +3090,34 @@ message BluetoothSetConnectionParamsResponse {
uint64 address = 1;
int32 error = 2;
}
// ==================== SENDSPIN ====================
enum SendspinPairingTokenStatus {
// The Sendspin client has not started yet, so the token does not exist yet. Ask again later.
SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0;
SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1;
// The device has no API encryption key, so it will not send the token. Ask again once a key is set.
SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2;
// Sendspin is turned off and has not started since boot, so the token does not exist yet.
SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3;
// The Sendspin client failed to start, and will not start until the device reboots.
SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4;
}
// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device
message SendspinPairingTokenRequest {
option (id) = 159;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SENDSPIN";
// Empty
}
message SendspinPairingTokenResponse {
option (id) = 160;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENDSPIN";
SendspinPairingTokenStatus status = 1;
// The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK.
string token = 2 [(max_data_length) = 127];
}
+136 -7
View File
@@ -48,6 +48,9 @@
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
#include "esphome/components/sendspin/sendspin_hub.h"
#endif
#ifdef USE_WATER_HEATER
#include "esphome/components/water_heater/water_heater.h"
#endif
@@ -198,6 +201,17 @@ APIConnection::~APIConnection() {
proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE);
}
}
#endif
// entities holding a transmit reply for this client must not answer into a freed connection
#ifdef USE_INFRARED
for (auto *infrared : App.get_infrareds()) {
infrared->on_api_connection_closed(this);
}
#endif
#ifdef USE_RADIO_FREQUENCY
for (auto *radio_frequency : App.get_radio_frequencies()) {
radio_frequency->on_api_connection_closed(this);
}
#endif
}
@@ -990,7 +1004,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection
uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *select = static_cast<select::Select *>(entity);
ListEntitiesSelectResponse msg;
msg.options = &select->traits.get_options();
const auto &opts = select->traits.get_options();
const std::span<const char *const> options(opts.data(), opts.size());
msg.options = &options;
return fill_and_encode_entity_info(select, msg, conn, remaining_size);
}
void APIConnection::on_select_command_request(const SelectCommandRequest &msg) {
@@ -1389,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
bool APIConnection::send_sendspin_pairing_token_response_() {
SendspinPairingTokenResponse resp;
#ifdef USE_API_NOISE
// has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions
// never reach hello, and activating a key closes every session opened before it.
if (!this->parent_->get_noise_ctx().has_psk()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
return this->send_message(resp);
}
auto token = sendspin_::global_sendspin_hub->get_pairing_token();
if (token.has_value()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK;
resp.token = StringRef(*token);
} else if (sendspin_::global_sendspin_hub->status_has_error()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED;
} else if (sendspin_::global_sendspin_hub->is_disabled()) {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED;
} else {
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY;
}
#else
resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED;
#endif
return this->send_message(resp);
}
void APIConnection::on_sendspin_pairing_token_request() {
if (!this->send_sendspin_pairing_token_response_()) {
this->on_fatal_error();
}
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
@@ -1515,8 +1565,10 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
// Clients on API 1.18+ are told when the frame has left the transmitter; the entity owns that reply
const bool want_reply = this->client_supports_api_version(1, 18);
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
#ifdef USE_INFRARED
@@ -1526,6 +1578,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
call.set_carrier_frequency(msg.carrier_frequency);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.set_repeat_count(msg.repeat_count);
call.set_api_connection(want_reply ? this : nullptr);
call.perform();
return;
}
@@ -1538,13 +1591,38 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
call.set_repeat_count(msg.repeat_count);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.set_api_connection(want_reply ? this : nullptr);
call.perform();
return;
}
#endif
ESP_LOGW(TAG, "IR/RF transmit for unknown key %" PRIu32, msg.key);
if (want_reply) {
// nothing will ever report for an unknown key, so answer as not started right away
#ifdef USE_DEVICES
const uint32_t device_id = msg.device_id;
#else
const uint32_t device_id = 0;
#endif
if (!this->send_infrared_rf_transmit_complete(device_id, msg.key, false)) {
API_LOG_MSG_DROPPED(TAG, "IR/RF reply");
}
}
}
bool APIConnection::send_infrared_rf_transmit_complete([[maybe_unused]] uint32_t device_id, uint32_t key,
bool success) {
InfraredRFTransmitCompleteResponse resp{};
#ifdef USE_DEVICES
resp.device_id = device_id;
#endif
resp.key = key;
resp.success = success;
return this->send_message(resp);
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
if (!this->send_message(msg)) {
// V: fires per decoded frame with no subscription gate, so a warning
@@ -1552,6 +1630,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
}
}
#endif
#ifdef USE_SERIAL_PROXY
@@ -1643,6 +1722,22 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
}
}
void APIConnection::on_subscribe_serial_proxy_identity_request() {
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
// Only USB ports change identity after this snapshot
this->flags_.serial_proxy_identity_subscription = true;
#endif
for (auto *proxy : App.get_serial_proxies()) {
proxy->send_identity(this);
}
}
void APIConnection::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
if (!this->send_message(msg)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy identity");
}
}
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
@@ -1814,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 17;
resp.api_version_minor = 19;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
@@ -1837,6 +1932,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
#ifdef USE_API_OUTGOING_CONNECTION
// With a PSK set only key-verified transports reach hello: plaintext and
// zero-PSK are rejected, and pre-activation sessions are force-closed
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) {
if (this->parent_->get_noise_ctx().has_psk()) {
this->flags_.outgoing_connection_target = true;
this->parent_->on_outgoing_target_client(this);
} else {
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active"));
}
}
#endif
return this->send_message(resp);
}
@@ -1959,6 +2067,9 @@ bool APIConnection::send_device_info_response_() {
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#ifdef USE_API_OUTGOING_CONNECTION
resp.api_outgoing_connection_supported = true;
#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
@@ -2011,6 +2122,12 @@ bool APIConnection::send_device_capabilities_response_() {
info.port_type = proxy->get_port_type();
info.configured_line_states = proxy->get_configured_modem_pins();
}
#endif
#ifdef USE_API_WIZARD
resp.wizard.configured = true;
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags();
#endif
return this->send_message(resp);
}
@@ -2225,7 +2342,13 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet
}
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; }
void APIConnection::on_subscribe_home_assistant_states_request() {
#ifdef USE_API_WIZARD_LINKED_INPUTS
// Remember it, as a client that subscribed also gets the subscriptions again when a wizard input is set
this->flags_.home_assistant_states = true;
#endif
state_subs_at_ = 0;
}
#endif
bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
delay(0);
@@ -2269,8 +2392,7 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type,
#endif
// Capacity reserved above, cannot fail
(void) shared_buf.resize(write_start + payload_size);
ProtoWriteBuffer buffer{&shared_buf, write_start};
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
uint8_t *end = encode_fn(msg, shared_buf.data() + write_start PROTO_ENCODE_DEBUG_INIT(&shared_buf));
#ifdef ESPHOME_DEBUG_API
proto_check_encode_end(end, shared_buf.data() + shared_buf.size());
#else
@@ -2668,6 +2790,13 @@ void APIConnection::process_state_subscriptions_() {
}
const auto &it = subs[this->state_subs_at_];
#ifdef USE_API_WIZARD_LINKED_INPUTS
// An entity id that is not set yet (a wizard input) has nothing to subscribe to; it is sent once it is set
if (it.entity_id[0] == '\0') {
this->state_subs_at_++;
return;
}
#endif
SubscribeHomeAssistantStateResponse resp;
resp.entity_id = StringRef(it.entity_id);
+59 -4
View File
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
void on_sendspin_pairing_token_request();
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
@@ -233,9 +237,12 @@ class APIConnection final : public APIServerConnectionBase {
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
// Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is
// full, the entity that owns the reply retries it then
[[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success);
#endif
#ifdef USE_SERIAL_PROXY
@@ -243,6 +250,9 @@ class APIConnection final : public APIServerConnectionBase {
void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg);
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
void on_subscribe_serial_proxy_identity_request();
/// Send a port identity to this client
void send_serial_proxy_identity(const SerialProxyIdentity &msg);
void on_serial_proxy_request(const SerialProxyRequest &msg);
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
@@ -273,6 +283,12 @@ class APIConnection final : public APIServerConnectionBase {
void on_ping_request();
void on_device_info_request();
void on_device_capabilities_request();
#ifdef USE_API_WIZARD
void on_device_wizard_request();
#endif
#ifdef USE_API_WIZARD_INPUTS
void on_wizard_input_set_request(const WizardInputSetRequest &msg);
#endif
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
this->flags_.state_subscription = true;
@@ -302,6 +318,10 @@ class APIConnection final : public APIServerConnectionBase {
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
void on_subscribe_home_assistant_states_request();
#ifdef USE_API_WIZARD_LINKED_INPUTS
/// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed
void resend_state_subscriptions(const char *entity_id);
#endif
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
void on_execute_service_request(const ExecuteServiceRequest &msg);
@@ -317,9 +337,15 @@ class APIConnection final : public APIServerConnectionBase {
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
#endif
// How long a new connection holds off the spare ephemeral refill
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
bool is_authenticated() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
// An older unauthenticated connection is a stale half open client and does not count
bool is_still_connecting(uint32_t now) {
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
}
bool is_connection_setup() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
@@ -339,7 +365,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_no_setup_connection();
// Function pointer type for type-erased message encoding
using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM);
using MessageEncodeFn = ProtoEncodeFn;
// Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *);
@@ -372,6 +398,23 @@ class APIConnection final : public APIServerConnectionBase {
return this->helper_->get_peername_to(buf);
}
#ifdef USE_API_OUTGOING_CONNECTION
/// Get the peer address itself, for remembering a dial-back target
int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); }
/// Outgoing connection: send our server hello immediately so the peer can
/// pick the matching key. Outgoing connections are only dialed when a PSK
/// is set, so the helper is always the noise helper. Call after start().
void mark_outgoing() {
if (this->flags_.remove) {
return; // start() failed; the connection is already being torn down
}
APIError err = static_cast<APINoiseFrameHelper *>(this->helper_.get())->send_server_hello_first();
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Server hello failed"), err);
}
}
#endif
protected:
bool try_to_clear_buffer_slow_(bool log_out_of_space);
@@ -393,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_VOICE_ASSISTANT
bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg);
#endif
#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF)
bool send_sendspin_pairing_token_response_();
#endif
#ifdef USE_CAMERA
void try_send_camera_image_();
@@ -728,12 +774,21 @@ class APIConnection final : public APIServerConnectionBase {
uint8_t batch_first_message : 1; // For batch buffer allocation
uint8_t should_try_send_immediately : 1; // True after initial states are sent
uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check
#ifdef USE_API_WIZARD_LINKED_INPUTS
uint8_t home_assistant_states : 1; // Client subscribed to Home Assistant states
#endif
#ifdef USE_API_OUTGOING_CONNECTION
uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello
#endif
#ifdef HAS_PROTO_MESSAGE_DUMP
uint8_t log_only_mode : 1;
#endif
} flags_{}; // 2 bytes total
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
uint8_t serial_proxy_identity_subscription : 1;
#endif
} flags_{}; // 2 bytes; 3 with HAS_PROTO_MESSAGE_DUMP + USE_API_OUTGOING_CONNECTION + USE_SERIAL_PROXY_USB_IDENTITY
// 2-byte type immediately after flags_ (no padding between them)
// 2-byte type immediately after flags_ (one padding byte when flags_ is 3 bytes)
uint16_t batch_message_type_{0}; // Current message type during batch encoding
// 1-byte types to fill remaining space before next 4-byte boundary
// Client API versions are clamped to 255 on receive (see send_hello_response_)
@@ -45,8 +45,8 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
conn->fatal_out_of_memory_();
return 0;
}
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
uint8_t *end =
encode_fn(msg, shared_buf.data() + shared_buf.size() - calculated_size PROTO_ENCODE_DEBUG_INIT(&shared_buf));
#ifdef ESPHOME_DEBUG_API
// A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes
proto_check_encode_end(end, shared_buf.data() + shared_buf.size());
+2 -1
View File
@@ -285,7 +285,8 @@ class APIFrameHelper {
DATA = 5,
CLOSED = 6,
FAILED = 7,
EXPLICIT_REJECT = 8, // Noise only
EXPLICIT_REJECT = 8, // Noise only
CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn)
};
// Fast inline state check for read_packet/write_protobuf_messages hot path.
@@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
#ifdef USE_API_OUTGOING_CONNECTION
APIError APINoiseFrameHelper::send_server_hello_first() {
// The peer needs our name and MAC to pick the key before its first message
this->state_ = State::CLIENT_HELLO_OUTGOING;
return this->send_server_hello_frame_();
}
#endif
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
@@ -242,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() {
HELPER_LOG("Bad state for method: %d", (int) this->state_);
return APIError::BAD_STATE;
case State::CLIENT_HELLO:
#ifdef USE_API_OUTGOING_CONNECTION
case State::CLIENT_HELLO_OUTGOING:
#endif
return this->state_action_client_hello_();
case State::SERVER_HELLO:
return this->state_action_server_hello_();
@@ -274,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
std::memcpy(dst + 2, this->rx_buf_.data(), rx_size);
}
#ifdef USE_API_OUTGOING_CONNECTION
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
// Server hello already went out at handoff
return this->start_handshake_();
}
#endif
state_ = State::SERVER_HELLO;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
// send server hello
APIError APINoiseFrameHelper::send_server_hello_frame_() {
const auto &name = App.get_name();
char mac[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac);
@@ -302,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
// node mac, terminated by null byte
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
APIError aerr = write_frame_(msg, total_size);
return write_frame_(msg, total_size);
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
APIError aerr = this->send_server_hello_frame_();
if (aerr != APIError::OK)
return aerr;
// start handshake
aerr = init_handshake_();
return this->start_handshake_();
}
APIError APINoiseFrameHelper::start_handshake_() {
APIError aerr = init_handshake_();
if (aerr != APIError::OK)
return aerr;
state_ = State::HANDSHAKE;
return APIError::OK;
}
@@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
// Send the server hello immediately so the peer can pick the key before
// its PSK-mixed message. Call after init(); the mode is tracked in state_
// so the helper does not grow.
APIError send_server_hello_first();
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
@@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
APIError send_server_hello_frame_();
APIError start_handshake_();
APIError state_action_handshake_();
APIError state_action_handshake_read_();
APIError state_action_handshake_write_();
+5
View File
@@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions {
// to true when the field arrives, so an all-default submessage can be told apart
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
optional bool track_presence = 50020 [default=false];
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
// cannot read flash with ordinary loads.
optional bool progmem = 50021 [default=false];
}
@@ -0,0 +1,318 @@
#include "api_outgoing_connection.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#include "api_connection.h"
#include "api_server.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cerrno>
#include <cinttypes>
#include <cstdint>
#include <cstring>
namespace esphome::api {
static const char *const TAG = "api.outgoing";
#ifndef API_OUTGOING_CONNECTION_HOST
static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL;
#endif
#ifndef API_OUTGOING_CONNECTION_HOST
// Read the connection's peer address into target; false when unavailable or
// of a family this build cannot dial
static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
// Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched
struct sockaddr_storage peer = {};
socklen_t peer_len = sizeof(peer);
if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) {
return false;
}
const sa_family_t family = ((struct sockaddr *) &peer)->sa_family;
#if USE_NETWORK_IPV6
if (family == AF_INET6) {
const auto *addr6 = reinterpret_cast<const struct sockaddr_in6 *>(&peer);
const auto *bytes = reinterpret_cast<const uint8_t *>(&addr6->sin6_addr);
uint32_t prefix[3];
memcpy(prefix, bytes, sizeof(prefix));
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) {
target.family = AF_INET;
memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr));
return true;
}
// A link-local target is only reachable through the interface it came in
// on. Device platforms number interfaces from one; a host build can hand
// out an index too large to store, and a truncated one dials the wrong
// interface, so that target is not remembered at all.
if (addr6->sin6_scope_id > UINT8_MAX) {
return false;
}
target.family = AF_INET6;
memcpy(target.addr, bytes, sizeof(target.addr));
target.scope_id = static_cast<uint8_t>(addr6->sin6_scope_id);
return true;
}
#endif
if (family != AF_INET) {
return false;
}
const auto *addr4 = reinterpret_cast<const struct sockaddr_in *>(&peer);
target.family = AF_INET;
memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr));
return true;
}
#endif
socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const {
#ifdef API_OUTGOING_CONNECTION_HOST
// Validation only lets through a literal both inet_pton and inet6_aton
// accept, so this cannot fail
return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST,
API_OUTGOING_CONNECTION_PORT);
#else
#if USE_NETWORK_IPV6
if (this->saved_.family == AF_INET6) {
auto *addr6 = reinterpret_cast<struct sockaddr_in6 *>(addr);
memset(addr6, 0, sizeof(*addr6));
addr6->sin6_family = AF_INET6;
addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT);
memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr));
addr6->sin6_scope_id = this->saved_.scope_id;
return sizeof(*addr6);
}
#endif
if (this->saved_.family != AF_INET) {
return 0;
}
auto *addr4 = reinterpret_cast<struct sockaddr_in *>(addr);
memset(addr4, 0, sizeof(*addr4));
addr4->sin_family = AF_INET;
addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT);
memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr));
return sizeof(*addr4);
#endif
}
#ifndef API_OUTGOING_CONNECTION_HOST
void OutgoingConnectionManager::format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const {
struct sockaddr_storage addr;
socklen_t addr_len = this->target_sockaddr_(&addr);
if (addr_len == 0) {
buf[0] = '\0';
return;
}
// Clears buf itself if it cannot format the address
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf);
}
#endif
void OutgoingConnectionManager::setup() {
#ifndef API_OUTGOING_CONNECTION_HOST
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(OUTGOING_TARGET_PREF_HASH, true);
struct sockaddr_storage addr;
// dump_config() prints whichever target this leaves in place
if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) {
this->host_persisted_ = true;
} else {
// Never saved, failed its size or CRC check, or holds an unknown family
this->saved_ = {};
}
#endif
}
void OutgoingConnectionManager::loop(APIServer *server) {
if (server->has_outgoing_target_client_()) {
return; // on_target_client() already reset the dial state
}
if (this->dialed_conn_ != nullptr) {
// A live dialed session (flagged or not, e.g. a host: peer) is the
// target; a silent one dies on the handshake timeout
return;
}
const uint32_t now = App.get_loop_component_start_time();
switch (this->state_) {
case DialState::DIAL_STATE_IDLE:
// Target went away; give it the configured delay to reconnect first
this->schedule_wait_(now, IDLE_WAIT_MS);
break;
case DialState::DIAL_STATE_WAITING:
if (now - this->state_ts_ >= this->wait_) {
this->try_dial_(server, now);
}
break;
case DialState::DIAL_STATE_CONNECTING:
this->poll_connect_(server, now);
break;
}
}
void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) {
if (!network::is_connected()) {
// Flips within seconds of boot; recheck fast so a deep sleep wake
// window is not spent waiting
this->schedule_wait_(now, NETWORK_RETRY_MS);
return;
}
struct sockaddr_storage addr;
socklen_t addr_len = this->target_sockaddr_(&addr);
const bool at_limit = server->at_client_limit_();
// No target is the steady state until a dial-back client has ever connected
if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) {
// Repeats for as long as the reason holds, so keep it out of debug logs
ESP_LOGV(TAG, "Not dialing: %s",
addr_len == 0 ? LOG_STR_LITERAL("no target")
: (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")));
// Not a dial failure; retry without escalating the backoff
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP);
if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) {
ESP_LOGW(TAG, "Socket %s failed: errno %d",
this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno);
this->schedule_retry_(now);
return;
}
#ifdef API_OUTGOING_CONNECTION_HOST
ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT);
#else
char host[socket::SOCKADDR_STR_LEN];
socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host);
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT);
#endif
int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len);
if (err == 0) {
// Immediate success (possible for localhost)
this->handoff_(server, now);
return;
}
if (errno != EINPROGRESS) {
ESP_LOGW(TAG, "Connect failed: %d", errno);
this->schedule_retry_(now);
return;
}
this->state_ = DialState::DIAL_STATE_CONNECTING;
this->state_ts_ = now;
this->last_poll_ = now;
}
void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Connect timeout");
this->schedule_retry_(now);
return;
}
if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) {
return;
}
this->last_poll_ = now;
int err = 0;
switch (socket::poll_connect(*this->dial_socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
this->handoff_(server, now);
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
ESP_LOGW(TAG, "Connect failed: %d", err);
this->schedule_retry_(now);
break;
}
}
void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) {
this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_));
if (this->dialed_conn_ == nullptr) {
// Only preconditions (slot limit, key cleared) refuse the handoff; the
// peer is reachable, so do not escalate the backoff
this->schedule_wait_(now, PRECONDITION_RETRY_MS);
return;
}
// Connected; dialed_conn_ gates further dialing until the session settles
this->state_ = DialState::DIAL_STATE_IDLE;
}
void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) {
this->dial_socket_.reset(); // no-op when the socket was handed off
this->state_ = DialState::DIAL_STATE_WAITING;
this->state_ts_ = now;
this->wait_ = wait;
}
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
// +/-20% jitter so a fleet of devices does not retry one server in lockstep
const uint32_t jitter_span = this->backoff_ / 5;
this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)));
this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS);
}
void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) {
if (conn != this->dialed_conn_) {
return;
}
this->dialed_conn_ = nullptr;
if (was_authenticated) {
// A working peer (e.g. a host: target that never sends the flag)
// disconnected normally; state is IDLE, so loop() applies the delay
this->backoff_ = BACKOFF_MIN_MS;
} else {
this->schedule_retry_(App.get_loop_component_start_time());
}
}
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
// The target is connected; stop any dial in flight and reset the backoff.
// A dialed connection stays tracked unless it is this one: an inbound
// target must not orphan a still-open dial.
this->dial_socket_.reset();
if (conn == this->dialed_conn_) {
this->dialed_conn_ = nullptr;
}
this->state_ = DialState::DIAL_STATE_IDLE;
this->backoff_ = BACKOFF_MIN_MS;
#ifndef API_OUTGOING_CONNECTION_HOST
SavedOutgoingTarget target{};
if (!peer_to_target(conn, target)) {
ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed");
return;
}
if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) {
return; // unchanged and already on flash; avoid flash wear
}
// Use the fresh address this boot even if the flash write fails; a failed
// write is retried on the next flagged hello via host_persisted_
this->saved_ = target;
if (!this->persist_target_()) {
ESP_LOGW(TAG, "Failed to save target");
return;
}
char host[socket::SOCKADDR_STR_LEN];
this->format_target_(host);
ESP_LOGD(TAG, "Remembered %s as the dial target", host);
#endif
}
void OutgoingConnectionManager::dump_config() const {
// The boot delay differs from delay: on deep sleep builds, so print the
// value that actually applies
ESP_LOGCONFIG(TAG,
" Outgoing connection port: %u\n"
" Outgoing connection boot delay: %" PRIu32 "ms",
API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS);
// Both forms keep their text out of RAM on ESP8266: in the format string,
// or through LOG_STR_LITERAL
#ifdef API_OUTGOING_CONNECTION_HOST
ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST);
#else
char buf[socket::SOCKADDR_STR_LEN];
this->format_target_(buf);
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf);
#endif
}
} // namespace esphome::api
#endif // USE_API && USE_API_OUTGOING_CONNECTION
@@ -0,0 +1,120 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#ifndef USE_API_NOISE
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
#endif
#include "esphome/components/socket/socket.h"
#include "esphome/core/preferences.h"
#include <memory>
namespace esphome::api {
class APIServer;
class APIConnection;
// Room for an IPv6 address in every build, so a remembered IPv4 target is
// still dialed after enable_ipv6 is turned on. A size that followed the build
// would also shift every preference registered after this one on ESP8266,
// where slots are positional. An IPv6 target on a build without IPv6 is
// dropped by target_sockaddr_() and relearned.
// Bytes in an IPv6 address
static constexpr size_t TARGET_ADDR_LEN = 16;
struct SavedOutgoingTarget {
// 0 when none is remembered, else AF_INET or AF_INET6
uint8_t family;
// Network order, IPv4 in the first four bytes and the rest zero
uint8_t addr[TARGET_ADDR_LEN];
// Interface a link-local IPv6 target is reachable on, 0 when it needs none.
// Free in flash: the record still rounds up to the same five words.
uint8_t scope_id;
} PACKED; // NOLINT
/// Dials out when no dial-back target client is connected. Only the TCP
/// direction flips: the device stays the Noise responder, so both sides
/// still verify by key. Targets the YAML host or the last remembered client.
class OutgoingConnectionManager {
public:
void setup();
void loop(APIServer *server);
/// A key-verified client declared itself a dial-back target; last one wins
void on_target_client(APIConnection *conn);
/// Clears the dialed-connection gate; dying unauthenticated escalates the backoff
void on_client_removed(APIConnection *conn, bool was_authenticated);
void on_shutdown() { this->dial_socket_.reset(); }
void dump_config() const;
protected:
enum class DialState : uint8_t {
DIAL_STATE_IDLE,
DIAL_STATE_WAITING,
DIAL_STATE_CONNECTING,
};
static constexpr uint32_t BACKOFF_MIN_MS = 5000;
static constexpr uint32_t BACKOFF_MAX_MS = 300000;
static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000;
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
static constexpr uint32_t NETWORK_RETRY_MS = 500;
static constexpr uint32_t PRECONDITION_RETRY_MS = 5000;
// A deep sleep wake window is too short to spend on the delay, so those
// builds dial out as soon as the target is gone
#ifdef USE_DEEP_SLEEP
static constexpr uint32_t BOOT_WAIT_MS = 0;
static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS;
#else
static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY;
#endif
void try_dial_(APIServer *server, uint32_t now);
void poll_connect_(APIServer *server, uint32_t now);
// Hand the connected socket to the server and gate on the new connection
void handoff_(APIServer *server, uint32_t now);
// Close any half-open dial and wait a jittered backoff before retrying
void schedule_retry_(uint32_t now);
// Wait without escalating the backoff (used for unmet preconditions)
void schedule_wait_(uint32_t now, uint32_t wait);
/// Fill addr with the target and return its length, or 0 when there is none
socklen_t target_sockaddr_(struct sockaddr_storage *addr) const;
#ifndef API_OUTGOING_CONNECTION_HOST
// Write saved_ to flash, tracking success in host_persisted_
bool persist_target_() {
this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync();
return this->host_persisted_;
}
/// Format the remembered target for a log line; empty when there is none
void format_target_(std::span<char, socket::SOCKADDR_STR_LEN> buf) const;
#endif
// Pointers first (4 bytes each on 32-bit)
std::unique_ptr<socket::Socket> dial_socket_;
// Compared only, never dereferenced
APIConnection *dialed_conn_{nullptr};
#ifndef API_OUTGOING_CONNECTION_HOST
ESPPreferenceObject target_pref_;
#endif
// 4-byte types
uint32_t backoff_{BACKOFF_MIN_MS};
uint32_t wait_{BOOT_WAIT_MS};
uint32_t state_ts_{0};
uint32_t last_poll_{0};
// Byte-aligned types last
#ifndef API_OUTGOING_CONNECTION_HOST
SavedOutgoingTarget saved_{};
// False while saved_ holds a value the flash write failed for; retried on
// the next flagged hello
bool host_persisted_{false};
#endif
DialState state_{DialState::DIAL_STATE_WAITING};
};
} // namespace esphome::api
#endif // USE_API && USE_API_OUTGOING_CONNECTION
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+142 -3
View File
@@ -114,15 +114,26 @@ template<typename T> static void dump_field(DumpBuffer &out, const char *field_n
out.append("\n");
}
// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer
static constexpr size_t DUMP_BYTES_MAX = 160;
// Helper for bytes fields - uses stack buffer to avoid heap allocation
// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer
// field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms)
static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) {
char hex_buf[format_hex_pretty_size(160)];
char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)];
append_field_prefix(out, field_name, indent);
format_hex_pretty_to(hex_buf, data, len);
out.append(hex_buf).append("\n");
}
// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first
static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len,
int indent = 2) {
uint8_t data_buf[DUMP_BYTES_MAX];
len = std::min(len, sizeof(data_buf));
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
@@ -143,6 +154,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::S
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232");
case enums::SERIAL_PROXY_PORT_TYPE_RS485:
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485");
case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL:
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -890,6 +903,49 @@ template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::Seria
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyIdentitySource>(enums::SerialProxyIdentitySource value) {
switch (value) {
case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE");
case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED");
case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
template<> const char *proto_enum_to_string<enums::SerialProxyIdentityFlag>(enums::SerialProxyIdentityFlag value) {
switch (value) {
case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE");
case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED");
case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR:
return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#ifdef USE_SENDSPIN
template<>
const char *proto_enum_to_string<enums::SendspinPairingTokenStatus>(enums::SendspinPairingTokenStatus value) {
switch (value) {
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED");
case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED:
return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
@@ -897,6 +953,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("client_info"), this->client_info);
dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major);
dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor);
#ifdef USE_API_OUTGOING_CONNECTION
dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target);
#endif
return out.c_str();
}
const char *HelloResponse::dump_to(DumpBuffer &out) const {
@@ -1020,6 +1079,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
#ifdef USE_API_OUTGOING_CONNECTION
dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported);
#endif
return out.c_str();
}
@@ -1046,6 +1108,20 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#ifdef USE_API_WIZARD
const char *WizardCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities"));
dump_field(out, ESPHOME_PSTR("configured"), this->configured);
return out.c_str();
}
#endif
#ifdef USE_SENDSPIN
const char *SendspinCapabilities::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities"));
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
return out.c_str();
}
#endif
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
#ifdef USE_BLUETOOTH_PROXY
@@ -1069,9 +1145,34 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
it.dump_to(out);
out.append("\n");
}
#endif
#ifdef USE_API_WIZARD
out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": ");
this->wizard.dump_to(out);
out.append("\n");
#endif
#ifdef USE_SENDSPIN
out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": ");
this->sendspin.dump_to(out);
out.append("\n");
#endif
return out.c_str();
}
#ifdef USE_API_WIZARD
const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse"));
dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
#ifdef USE_API_WIZARD_INPUTS
const char *WizardInputSetRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("WizardInputSetRequest"));
dump_field(out, ESPHOME_PSTR("key"), this->key);
dump_field(out, ESPHOME_PSTR("entity_id"), this->entity_id);
return out.c_str();
}
#endif
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
return out.c_str();
@@ -2714,7 +2815,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
#ifdef USE_DEVICES
@@ -2743,6 +2844,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
}
return out.c_str();
}
const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse"));
#ifdef USE_DEVICES
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("key"), this->key);
dump_field(out, ESPHOME_PSTR("success"), this->success);
return out.c_str();
}
#endif
#ifdef USE_RADIO_FREQUENCY
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
@@ -2826,6 +2936,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
return out.c_str();
}
const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor"));
dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id);
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
return out.c_str();
}
const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("source"), static_cast<enums::SerialProxyIdentitySource>(this->source));
dump_field(out, ESPHOME_PSTR("flags"), this->flags);
dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer);
dump_field(out, ESPHOME_PSTR("product"), this->product);
dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number);
out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": ");
this->usb.dump_to(out);
out.append("\n");
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
@@ -2844,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
return out.c_str();
}
#endif
#ifdef USE_SENDSPIN
const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse"));
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SendspinPairingTokenStatus>(this->status));
dump_field(out, ESPHOME_PSTR("token"), this->token);
return out.c_str();
}
#endif
} // namespace esphome::api
@@ -28,6 +28,7 @@
// Standard library includes that might be needed
#include <set>
#include <span>
#include <vector>
#include <string>
+39 -1
View File
@@ -628,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
InfraredRFTransmitRawTimingsRequest msg;
msg.decode(msg_data, msg_size);
@@ -722,6 +722,44 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_serial_proxy_set_mode_request(msg);
break;
}
#endif
#ifdef USE_SERIAL_PROXY
case 154 /* SubscribeSerialProxyIdentityRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_serial_proxy_identity_request"));
#endif
this->on_subscribe_serial_proxy_identity_request();
break;
}
#endif
#ifdef USE_API_WIZARD
case 156 /* DeviceWizardRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_wizard_request"));
#endif
this->on_device_wizard_request();
break;
}
#endif
#ifdef USE_API_WIZARD_INPUTS
case WizardInputSetRequest::MESSAGE_TYPE: {
WizardInputSetRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_wizard_input_set_request"), msg);
#endif
this->on_wizard_input_set_request(msg);
break;
}
#endif
#ifdef USE_SENDSPIN
case 159 /* SendspinPairingTokenRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request"));
#endif
this->on_sendspin_pairing_token_request();
break;
}
#endif
default:
break;
+16 -1
View File
@@ -29,6 +29,13 @@ class APIServerConnectionBase {
void on_device_capabilities_request(){};
#ifdef USE_API_WIZARD
void on_device_wizard_request(){};
#endif
#ifdef USE_API_WIZARD_INPUTS
void on_wizard_input_set_request(const WizardInputSetRequest &value){};
#endif
void on_list_entities_request(){};
void on_subscribe_states_request(){};
@@ -213,7 +220,7 @@ class APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_IR_RF
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
#endif
@@ -238,9 +245,17 @@ class APIServerConnectionBase {
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
#endif
#ifdef USE_SERIAL_PROXY
void on_subscribe_serial_proxy_identity_request(){};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
#ifdef USE_SENDSPIN
void on_sendspin_pairing_token_request(){};
#endif
};
} // namespace esphome::api
+147 -51
View File
@@ -56,7 +56,12 @@ APIServer::APIServer() { global_api_server = this; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
#ifdef USE_API_OUTGOING_CONNECTION
// Dial-out needs no listener; degrade instead of stopping the component
this->status_set_error(LOG_STR("listen socket failed"));
#else
this->mark_failed();
#endif
}
void APIServer::setup() {
@@ -72,43 +77,6 @@ void APIServer::setup() {
#endif
#endif
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->socket_ == nullptr) {
this->socket_failed_(LOG_STR("creation"));
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
// we can still continue
}
err = this->socket_->setblocking(false);
if (err != 0) {
this->socket_failed_(LOG_STR("nonblocking"));
return;
}
struct sockaddr_storage server;
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
if (sl == 0) {
this->socket_failed_(LOG_STR("set sockaddr"));
return;
}
err = this->socket_->bind((struct sockaddr *) &server, sl);
if (err != 0) {
this->socket_failed_(LOG_STR("bind"));
return;
}
err = this->socket_->listen(this->listen_backlog_);
if (err != 0) {
this->socket_failed_(LOG_STR("listen"));
return;
}
#ifdef USE_LOGGER
if (logger::global_logger != nullptr) {
logger::global_logger->add_log_callback(
@@ -154,6 +122,47 @@ void APIServer::setup() {
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.setup();
#endif
// Listener last: on failure socket_failed_() returns early, and an
// outgoing_connection build keeps dialing out without one
this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->socket_ == nullptr) {
this->socket_failed_(LOG_STR("creation"));
return;
}
int enable = 1;
int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (err != 0) {
ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno);
// we can still continue
}
err = this->socket_->setblocking(false);
if (err != 0) {
this->socket_failed_(LOG_STR("nonblocking"));
return;
}
struct sockaddr_storage server;
socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_);
if (sl == 0) {
this->socket_failed_(LOG_STR("set sockaddr"));
return;
}
err = this->socket_->bind((struct sockaddr *) &server, sl);
if (err != 0) {
this->socket_failed_(LOG_STR("bind"));
return;
}
err = this->socket_->listen(this->listen_backlog_);
if (err != 0) {
this->socket_failed_(LOG_STR("listen"));
}
}
void APIServer::loop() {
@@ -162,6 +171,19 @@ void APIServer::loop() {
this->accept_new_connections_();
}
const bool connected = network::is_connected();
#ifdef USE_NOISE_SPARE_EPHEMERAL
if (connected && !noise::has_spare_ephemeral()) {
this->refill_spare_ephemeral_();
}
#endif
#ifdef USE_API_OUTGOING_CONNECTION
if (!this->shutting_down_) {
this->outgoing_conn_.loop(this);
}
#endif
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time).
@@ -178,8 +200,7 @@ void APIServer::loop() {
}
// Process clients and remove disconnected ones in a single pass
// Check network connectivity once for all clients
if (!network::is_connected()) {
if (!connected) {
// Network is down - disconnect all clients
for (auto &client : this->active_clients()) {
client->on_fatal_error();
@@ -207,6 +228,19 @@ void APIServer::loop() {
}
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
// An OTA handshake is not visible here and just pays the refill it triggered
void APIServer::refill_spare_ephemeral_() {
const uint32_t now = App.get_loop_component_start_time();
for (auto &client : this->active_clients()) {
if (client->is_still_connecting(now)) {
return;
}
}
noise::prepare_spare_ephemeral();
}
#endif
void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
@@ -222,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) {
std::string client_peername(client->get_peername_to(peername_buf));
#endif
// Read before the swap-and-reset below destroys the connection
const bool was_authenticated = client->is_authenticated();
#ifdef USE_API_OUTGOING_CONNECTION
if (client->flags_.outgoing_connection_target) {
this->outgoing_target_count_--;
}
this->outgoing_conn_.on_client_removed(client.get(), was_authenticated);
#endif
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
@@ -240,9 +283,15 @@ void APIServer::remove_client_(uint8_t client_index) {
// Last client disconnected - set warning and start tracking for reboot timeout
// (suppressed while provisioning is pending - see loop()).
// Refresh on every authenticated removal, not just the last one, so an
// unauthenticated straggler removed later (e.g. a port scan, or a dial to
// a host that accepts TCP but never speaks the API) cannot discard a
// healthy session's timestamp and trigger a spurious reboot
if (was_authenticated) {
this->last_connected_ = App.get_loop_component_start_time();
}
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER
@@ -264,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
sock->getpeername_to(peername);
// Check if we're at the connection limit
if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
if (this->at_client_limit_()) {
ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
@@ -273,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
this->add_client_(std::move(sock));
}
}
APIConnection *APIServer::add_client_(std::unique_ptr<socket::Socket> sock) {
auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory)
this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning. The reboot watchdog timestamp is
// refreshed when an authenticated client is removed (see remove_client_),
// never on bare TCP connects.
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
}
return conn;
}
#ifdef USE_API_OUTGOING_CONNECTION
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
// Re-check at the handoff: inbound clients may have taken the last slot and
// the PSK may have been cleared since the dial started (mark_outgoing()
// needs the noise helper)
const bool at_limit = this->at_client_limit_();
if (at_limit || !this->noise_ctx_.has_psk()) {
ESP_LOGW(TAG, "Dropping outgoing connection (%s)",
at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"));
return nullptr;
}
auto *conn = this->add_client_(std::move(sock));
// After start(): sends our server hello first so the peer can pick the key
conn->mark_outgoing();
return conn;
}
void APIServer::on_outgoing_target_client(APIConnection *conn) {
this->outgoing_target_count_++;
this->outgoing_conn_.on_target_client(conn);
}
#endif
void APIServer::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
@@ -301,6 +379,9 @@ void APIServer::dump_config() {
#else
ESP_LOGCONFIG(TAG, " Noise encryption: NO");
#endif
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.dump_config();
#endif
}
void APIServer::handle_disconnect(APIConnection *conn) {}
@@ -423,7 +504,16 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
}
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) {
for (auto &c : this->active_clients()) {
if (c->flags_.serial_proxy_identity_subscription)
c->send_serial_proxy_identity(msg);
}
}
#endif
#ifdef USE_IR_RF
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
const std::vector<int32_t> *timings) {
InfraredRFReceiveEvent resp{};
@@ -436,6 +526,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
for (auto &c : this->active_clients())
c->send_infrared_rf_receive_event(resp);
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
@@ -597,6 +688,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
// Force it: a session from before the key was active must not survive
c->flags_.next_close = true;
}
});
}
@@ -698,6 +791,9 @@ void APIServer::on_shutdown() {
// Close the listening socket to prevent new connections
this->destroy_socket_();
#ifdef USE_API_OUTGOING_CONNECTION
this->outgoing_conn_.on_shutdown();
#endif
// Change batch delay to 5ms for quick flushing during shutdown
this->batch_delay_ = 5;
+34 -5
View File
@@ -5,12 +5,13 @@
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#ifdef USE_API_NOISE
#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL)
// Only present in the build when the noise component is loaded
#include "esphome/components/noise/noise.h"
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "api_outgoing_connection.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -90,6 +91,10 @@ class APIServer final : public Component
void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); }
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
#endif // USE_API_NOISE
#ifdef USE_API_OUTGOING_CONNECTION
// Called by APIConnection when a client declares itself a dial-back target in its hello
void on_outgoing_target_client(APIConnection *conn);
#endif
void handle_disconnect(APIConnection *conn);
#ifdef USE_BINARY_SENSOR
@@ -198,7 +203,11 @@ class APIServer final : public Component
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
#ifdef USE_SERIAL_PROXY_USB_IDENTITY
/// Tell every subscribed client that a serial proxy port's identity changed
void send_serial_proxy_identity(const SerialProxyIdentity &msg);
#endif
#ifdef USE_IR_RF
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
#endif
@@ -267,6 +276,16 @@ class APIServer final : public Component
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
/// Takes the socket into a new connection and starts it; callers must have
/// checked at_client_limit_() first
APIConnection *add_client_(std::unique_ptr<socket::Socket> sock);
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
#ifdef USE_API_OUTGOING_CONNECTION
// Returns the new connection, or nullptr (socket dropped) when at the limit
APIConnection *add_outgoing_client_(std::unique_ptr<socket::Socket> sock);
bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; }
friend class OutgoingConnectionManager;
#endif
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
@@ -307,6 +326,8 @@ class APIServer final : public Component
delete this->socket_;
this->socket_ = nullptr;
}
/// Log the failure, drop the listen socket, and mark the component failed
/// unless this build can still dial out
void socket_failed_(const LogString *msg);
// Pointers and pointer-like types first (4 bytes each)
socket::ListenSocket *socket_{nullptr};
@@ -357,16 +378,21 @@ class APIServer final : public Component
// Group smaller types together
uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py
uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py
// Connection limits - these defaults will be overridden by config values
// from cv.SplitDefault in __init__.py which sets platform-specific defaults.
uint8_t listen_backlog_{4};
uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
#ifdef USE_API_OUTGOING_CONNECTION
// Connected clients whose hello declared them a dial-back target
uint8_t outgoing_target_count_{0};
#endif
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
#endif
#ifdef USE_NOISE_SPARE_EPHEMERAL
void refill_spare_ephemeral_();
#endif
#ifdef USE_API_NOISE
noise::NoiseContext noise_ctx_;
#ifndef USE_API_NOISE_PSK_FROM_YAML
@@ -374,6 +400,9 @@ class APIServer final : public Component
#endif
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
#ifdef USE_API_OUTGOING_CONNECTION
OutgoingConnectionManager outgoing_conn_;
#endif
};
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+128
View File
@@ -0,0 +1,128 @@
#include "api_wizard.h"
#ifdef USE_API_WIZARD
#include <cstring>
#include "api_connection.h"
#include "api_pb2.h"
#include "api_server.h"
#include "esphome/core/log.h"
namespace esphome::api {
static const char *const TAG = "api.wizard";
#ifdef USE_API_WIZARD_INPUTS
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
size_t joined = 0;
const char *end = value + length;
while (true) {
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
const char *id_end = comma != nullptr ? comma : end;
while (value < id_end && *value == ' ')
value++;
while (id_end > value && id_end[-1] == ' ')
id_end--;
size_t id_length = id_end - value;
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
return 0;
if (joined != 0) {
if (out != nullptr)
out[joined] = ',';
joined++;
}
if (out != nullptr)
memcpy(out + joined, value, id_length);
joined += id_length;
if (comma == nullptr)
return joined;
value = comma + 1;
}
}
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
}
const char *wizard_set_input(const WizardInputSetRequest &msg) {
const char *value = msg.entity_id.c_str();
const size_t length = msg.entity_id.size();
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
// The table is in flash, which ESP8266 can only read through progmem_memcpy
WizardInputEntry entry;
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
if (entry.key != msg.key)
continue;
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
if (entry.size != 0) {
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
if (joined == 0 || joined >= entry.size)
break;
wizard_join_entity_ids(value, length, entry.entity_id);
entry.entity_id[joined] = '\0';
return entry.entity_id;
}
#endif
if (!wizard_entity_id_valid(value, length))
break;
memcpy(entry.entity_id, value, length);
entry.entity_id[length] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
void APIConnection::on_device_wizard_request() {
DeviceWizardResponse resp;
resp.data = API_WIZARD_DATA;
resp.data_len = API_WIZARD_DATA_SIZE;
if (!this->send_message(resp)) {
this->on_fatal_error();
}
}
#ifdef USE_API_WIZARD_INPUTS
void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg) {
const char *entity_id = wizard_set_input(msg);
if (entity_id == nullptr)
return;
#ifdef USE_API_WIZARD_LINKED_INPUTS
// Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now
for (auto &client : this->parent_->active_clients()) {
client->resend_state_subscriptions(entity_id);
}
#endif
}
#endif
#ifdef USE_API_WIZARD_LINKED_INPUTS
void APIConnection::resend_state_subscriptions(const char *entity_id) {
if (!this->flags_.home_assistant_states)
return;
for (const auto &it : this->parent_->get_state_subs()) {
if (it.entity_id != entity_id)
continue;
SubscribeHomeAssistantStateResponse resp;
resp.entity_id = StringRef(it.entity_id);
resp.attribute = it.attribute != nullptr ? StringRef(it.attribute) : StringRef("");
resp.once = it.once;
if (!this->send_message(resp)) {
// Could not send now: send every subscription again from the loop
this->state_subs_at_ = 0;
return;
}
}
}
#endif
} // namespace esphome::api
#endif // USE_API_WIZARD
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API_WIZARD
#include <cstddef>
#include <cstdint>
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
#include <cstring>
#endif
#include "esphome/core/hal.h"
#include "proto.h"
#include "esphome/core/string_ref.h"
namespace esphome::api {
class WizardInputSetRequest;
/// Size of the buffer holding the entity id of a wizard input. Home Assistant entity ids are at most 255 bytes.
static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256;
/// The wizard as zstd compressed JSON (see DeviceWizardResponse), built by the generated code
/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long.
extern const uint8_t API_WIZARD_DATA[] PROGMEM;
#ifdef USE_API_WIZARD_INPUTS
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
uint32_t key; // FNV-1 hash of the ESPHome id of the input
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
uint16_t size; // Buffer size of a multiple input, 0 for a single input
#endif
};
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
extern const WizardInputEntry API_WIZARD_INPUTS[] PROGMEM;
/// Apply a WizardInputSetRequest: validate it and copy the entity id into the input's buffer. Nothing is stored
/// across restarts, so the client sends the choices again after every connection.
/// Returns the buffer, or nullptr when the request was ignored.
const char *wizard_set_input(const WizardInputSetRequest &msg);
#endif
#ifdef USE_API_WIZARD_STANDALONE_INPUTS
/// An input of the wizard that is not tied to an entity of the device. The entity ID the user picks is only ever read
/// by lambdas, for example `id(input).entity_id()`. It lives in the same RAM buffer a linked input uses.
class WizardInput {
public:
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
/// comma separated list, which homeassistant.action data can use as it is.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
/// The number of entity IDs in the list, 0 until the wizard sets one.
size_t entity_id_count() const {
if (!this->has_entity_id())
return 0;
size_t count = 1;
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
count++;
return count;
}
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
/// not null terminated, so print it with "%.*s".
template<typename F> void for_each_entity_id(F &&callback) const {
// The device stores the list without spaces or empty entries
const char *start = this->entity_id_;
while (*start != '\0') {
const char *end = strchr(start, ',');
if (end == nullptr)
end = start + strlen(start);
callback(StringRef(start, end - start));
start = *end == ',' ? end + 1 : end;
}
}
#endif
protected:
const char *entity_id_;
};
#endif
} // namespace esphome::api
#endif // USE_API_WIZARD
+98 -169
View File
@@ -4,64 +4,40 @@
#ifdef USE_API
#ifdef USE_API_HOMEASSISTANT_SERVICES
#include <functional>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
#include "api_pb2.h"
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
#include "esphome/components/json/json_util.h"
#endif
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
namespace esphome::api {
template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> {
// Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation)
// rather than being wrapped in a lambda. The base class constructor for const char* is more
// specialized than the templated constructor here, so it should be selected.
static_assert(std::is_constructible_v<TemplatableValue<std::string, X...>, const char *>,
"Base class must have const char* constructor for STATIC_STRING optimization");
// Converts a lambda result to the string sent to Home Assistant
template<typename T>
requires(!std::is_pointer_v<std::remove_cvref_t<T>>) std::string field_to_string(T &&val) {
return to_string(std::forward<T>(val)); // NOLINT
}
inline std::string field_to_string(const char *val) { return val ? std::string(val) : std::string(); }
inline std::string field_to_string(std::string val) { return val; }
inline std::string field_to_string(StringRef val) { return val.str(); }
private:
// Helper to convert value to string - handles the case where value is already a string
template<typename T> static std::string value_to_string(T &&val) {
return to_string(std::forward<T>(val)); // NOLINT
/// A key and value from codegen; on ESP8266 the table and its strings are in flash.
/// The value is the constant `value`, or the result of `fn` when it is set.
template<typename... Ts> struct HomeAssistantField {
const char *key;
const char *value;
std::string (*fn)(const Ts &...);
template<typename F> static constexpr HomeAssistantField from_lambda(const char *key, F /*lambda*/) {
return {key, nullptr, &call_lambda<F>};
}
// Overloads for string types - needed because std::to_string doesn't support them
static std::string value_to_string(char *val) {
return val ? std::string(val) : std::string();
} // For lambdas returning char* (e.g., itoa)
static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
static std::string value_to_string(const std::string &val) { return val; }
static std::string value_to_string(std::string &&val) { return std::move(val); }
static std::string value_to_string(const StringRef &val) { return val.str(); }
static std::string value_to_string(StringRef &&val) { return val.str(); }
public:
TemplatableStringValue() : TemplatableValue<std::string, X...>() {}
template<typename F, enable_if_t<!is_invocable<F, X...>::value, int> = 0>
TemplatableStringValue(F value) : TemplatableValue<std::string, X...>(value) {}
template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
TemplatableStringValue(F f)
: TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {}
};
template<typename... Ts> class TemplatableKeyValuePair {
public:
// Default constructor needed for FixedVector::emplace_back()
TemplatableKeyValuePair() = default;
// Keys are always string literals from YAML dictionary keys (e.g., "code", "event")
// and never templatable values or lambdas. Only the value parameter can be a lambda/template.
// Using const char* avoids std::string heap allocation - keys remain in flash.
template<typename T> TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {}
const char *key{nullptr};
TemplatableStringValue<Ts...> value;
template<typename F> static std::string call_lambda(const Ts &...x) { return field_to_string(F{}(x...)); }
};
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -106,45 +82,20 @@ template<typename... Ts> using ActionResponseCallback = std::function<void(const
template<typename... Ts> class HomeAssistantServiceCallAction final : public Action<Ts...> {
public:
explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) {
using Field = HomeAssistantField<Ts...>;
/// `fields` is a codegen table: the action or event name (no key), then the data, data_template
/// and variables entries.
HomeAssistantServiceCallAction(APIServer *parent, bool is_event, const Field *fields, uint8_t data_count,
uint8_t data_template_count, uint8_t variables_count)
: parent_(parent),
fields_(fields),
data_count_(data_count),
data_template_count_(data_template_count),
variables_count_(variables_count) {
this->flags_.is_event = is_event;
}
template<typename T> void set_service(T service) { this->service_ = service; }
// Initialize FixedVector members - called from Python codegen with compile-time known sizes.
// Must be called before any add_* methods; capacity must match the number of subsequent add_* calls.
void init_data(size_t count) { this->data_.init(count); }
void init_data_template(size_t count) { this->data_template_.init(count); }
void init_variables(size_t count) { this->variables_.init(count); }
// Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))).
// The value parameter can be a lambda/template, but keys are never templatable.
// Using const char* for keys avoids std::string heap allocation - keys remain in flash.
template<typename V> void add_data(const char *key, V &&value) {
this->add_kv_(this->data_, key, std::forward<V>(value));
}
template<typename V> void add_data_template(const char *key, V &&value) {
this->add_kv_(this->data_template_, key, std::forward<V>(value));
}
template<typename V> void add_variable(const char *key, V &&value) {
this->add_kv_(this->variables_, key, std::forward<V>(value));
}
#ifdef USE_ESP8266
// On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer).
// Store as const char* — populate_service_map copies from PROGMEM at play() time.
template<typename V> void add_data(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->data_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
template<typename V> void add_data_template(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->data_template_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
template<typename V> void add_variable(const __FlashStringHelper *key, V &&value) {
this->add_kv_(this->variables_, reinterpret_cast<const char *>(key), std::forward<V>(value));
}
#endif
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
template<typename T> void set_response_template(T response_template) {
this->response_template_ = response_template;
@@ -162,19 +113,61 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
void play(const Ts &...x) override {
const Field *fields = this->fields_;
const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_;
// Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send
size_t lambda_count = 0;
#ifdef USE_ESP8266
size_t flash_len = 0;
#endif
for (size_t i = 0; i < total; i++) {
lambda_count += fields[i].fn != nullptr;
#ifdef USE_ESP8266
if (fields[i].fn == nullptr)
flash_len += ESPHOME_strlen_P(fields[i].value);
if (fields[i].key != nullptr)
flash_len += ESPHOME_strlen_P(fields[i].key);
#endif
}
FixedVector<std::string> results;
results.init(lambda_count);
#ifdef USE_ESP8266
SmallBufferWithHeapFallback<128, char> flash_copy(flash_len);
char *cursor = flash_copy.get();
#endif
auto string_ref = [&](const char *str) {
#ifdef USE_ESP8266
size_t len = ESPHOME_strlen_P(str);
memcpy_P(cursor, str, len);
StringRef ref(cursor, len);
cursor += len;
return ref;
#else
return StringRef(str);
#endif
};
auto value_ref = [&](const Field &field) {
if (field.fn == nullptr)
return string_ref(field.value);
results.push_back(field.fn(x...));
return StringRef(results.back());
};
auto fill = [&](FixedVector<HomeassistantServiceMap> &dest, uint8_t count) {
dest.init(count);
for (uint8_t i = 0; i < count; i++, fields++) {
auto &kv = dest.emplace_back();
kv.key = string_ref(fields->key);
kv.value = value_ref(*fields);
}
};
HomeassistantActionRequest resp;
std::string service_value = this->service_.value(x...);
resp.service = StringRef(service_value);
resp.service = value_ref(*fields++);
resp.is_event = this->flags_.is_event;
// Local storage for lambda-evaluated strings - lives until after send
FixedVector<std::string> data_storage;
FixedVector<std::string> data_template_storage;
FixedVector<std::string> variables_storage;
this->populate_service_map(resp.data, this->data_, data_storage, x...);
this->populate_service_map(resp.data_template, this->data_template_, data_template_storage, x...);
this->populate_service_map(resp.variables, this->variables_, variables_storage, x...);
fill(resp.data, this->data_count_);
fill(resp.data_template, this->data_template_count_);
fill(resp.variables, this->variables_count_);
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
@@ -223,90 +216,26 @@ template<typename... Ts> class HomeAssistantServiceCallAction final : public Act
}
protected:
// Helper to add key-value pairs to FixedVectors
// Keys are always string literals (const char*), values can be lambdas/templates
template<typename V> void add_kv_(FixedVector<TemplatableKeyValuePair<Ts...>> &vec, const char *key, V &&value) {
auto &kv = vec.emplace_back();
kv.key = key;
kv.value = std::forward<V>(value);
}
template<typename VectorType, typename SourceType>
static void populate_service_map(VectorType &dest, SourceType &source, FixedVector<std::string> &value_storage,
Ts... x) {
dest.init(source.size());
#ifdef USE_ESP8266
// On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM),
// so is_static_string() is always false — the zero-copy STATIC_STRING fast
// path from the non-ESP8266 branch cannot trigger. We copy all keys and
// values unconditionally: keys via _P functions (may be in PROGMEM), values
// via value() which handles FLASH_STRING internally.
value_storage.init(source.size() * 2);
for (auto &it : source) {
auto &kv = dest.emplace_back();
// Key: copy from possible PROGMEM
{
size_t key_len = strlen_P(it.key);
value_storage.push_back(std::string(key_len, '\0'));
memcpy_P(value_storage.back().data(), it.key, key_len);
kv.key = StringRef(value_storage.back());
}
// Value: value() handles FLASH_STRING via _P functions internally
value_storage.push_back(it.value.value(x...));
kv.value = StringRef(value_storage.back());
}
#else
// On non-ESP8266, strings are directly readable from flash-mapped memory.
// Count non-static strings to allocate exact storage needed.
size_t lambda_count = 0;
for (const auto &it : source) {
if (!it.value.is_static_string()) {
lambda_count++;
}
}
value_storage.init(lambda_count);
for (auto &it : source) {
auto &kv = dest.emplace_back();
kv.key = StringRef(it.key);
if (it.value.is_static_string()) {
// Static string — pointer directly readable, zero allocation
kv.value = StringRef(it.value.get_static_string());
} else {
// Lambda — evaluate and store result
value_storage.push_back(it.value.value(x...));
kv.value = StringRef(value_storage.back());
}
}
#endif
}
APIServer *parent_;
TemplatableStringValue<Ts...> service_{};
FixedVector<TemplatableKeyValuePair<Ts...>> data_;
FixedVector<TemplatableKeyValuePair<Ts...>> data_template_;
FixedVector<TemplatableKeyValuePair<Ts...>> variables_;
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
TemplatableStringValue<Ts...> response_template_{""};
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
Trigger<Ts...> success_trigger_;
Trigger<std::string, Ts...> error_trigger_;
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
const Field *fields_;
uint8_t data_count_;
uint8_t data_template_count_;
uint8_t variables_count_;
struct Flags {
uint8_t is_event : 1;
uint8_t wants_status : 1;
uint8_t wants_response : 1;
uint8_t has_response_template : 1;
uint8_t reserved : 5;
uint8_t reserved : 4;
} flags_{0};
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
TemplatableValue<std::string, Ts...> response_template_{};
Trigger<JsonObjectConst, Ts...> success_trigger_with_response_;
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
Trigger<Ts...> success_trigger_;
Trigger<std::string, Ts...> error_trigger_;
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
};
} // namespace esphome::api
+28 -42
View File
@@ -119,7 +119,7 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
}
// Single-pass encode for repeated submessage elements (non-template core).
// Writes field tag, reserves 1 byte for length varint, encodes the submessage body,
// Reserves 1 byte for length varint, encodes the submessage body,
// then backpatches the actual length. For the common case (body < 128 bytes), this is
// just a single byte write with no memmove — all current repeated submessage types
// (BLE advertisements at ~47B, GATT descriptors at ~24B, service args, etc.) take
@@ -143,51 +143,34 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size
//
// After writing 2-byte varint at len_pos:
// [tag][v1][v2][body ..... body]
// ^-- pos_ = element end, within buffer
void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value,
uint8_t *(*encode_fn)(const void *,
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
this->encode_field_raw(field_id, 2);
// Reserve 1 byte for length varint (optimistic: submessage < 128 bytes)
uint8_t *len_pos = this->pos_;
this->debug_check_bounds_(1);
this->pos_++;
uint8_t *body_start = this->pos_;
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
uint32_t body_size = static_cast<uint32_t>(this->pos_ - body_start);
if (body_size < 128) [[likely]] {
// ^-- returned cursor = element end, within buffer
uint8_t *ProtoEncode::encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *value,
ProtoEncodeFn encode_fn) {
// Reserve 1 byte for the length varint (optimistic: submessage < 128 bytes)
uint8_t *len_pos = pos;
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
uint8_t *body_start = pos + 1;
uint8_t *after_body = encode_fn(value, body_start PROTO_ENCODE_DEBUG_ARG);
uint32_t body_size = static_cast<uint32_t>(after_body - body_start);
if (body_size < VARINT_MAX_1_BYTE) [[likely]] {
// Common case: 1-byte varint, just backpatch
*len_pos = static_cast<uint8_t>(body_size);
return;
return after_body;
}
// Compute extra bytes needed for varint beyond the 1 already reserved
// Shift the body forward to make room for the extra length varint bytes
uint8_t extra = ProtoSize::varint(body_size) - 1;
// Shift body forward to make room for the extra varint bytes
this->debug_check_bounds_(extra);
PROTO_ENCODE_CHECK_BOUNDS(after_body, extra);
std::memmove(body_start + extra, body_start, body_size);
uint8_t *end = this->pos_ + extra;
// Write the full varint at len_pos
this->pos_ = len_pos;
this->encode_varint_raw(body_size);
this->pos_ = end;
(void) encode_varint_raw_loop(len_pos PROTO_ENCODE_DEBUG_ARG, body_size);
return after_body + extra;
}
// Non-template core for encode_optional_sub_message.
void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
uint8_t *(*encode_fn)(const void *,
ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) {
if (nested_size == 0)
return;
this->encode_field_raw(field_id, 2);
this->encode_varint_raw(nested_size);
#ifdef ESPHOME_DEBUG_API
uint8_t *start = this->pos_;
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
if (static_cast<uint32_t>(this->pos_ - start) != nested_size)
this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start);
#else
this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_));
#endif
uint8_t *ProtoEncode::encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t nested_size, const void *value, ProtoEncodeFn encode_fn) {
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, nested_size);
return encode_fn(value, pos PROTO_ENCODE_DEBUG_ARG);
}
#ifdef ESPHOME_DEBUG_API
@@ -201,6 +184,14 @@ void proto_check_encode_end(const uint8_t *end, const uint8_t *expected) {
ESP_LOGE(TAG, "Proto encode ended %td bytes off the calculated size", end - expected);
abort();
}
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end) {
ptrdiff_t actual = end - (len_pos + ProtoSize::varint(expected));
if (actual == static_cast<ptrdiff_t>(expected))
return;
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
expected, actual);
abort();
}
void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
if (this->pos_ + bytes > this->buffer_->data() + this->buffer_->size()) {
ESP_LOGE(TAG, "ProtoWriteBuffer bounds check failed in %s: bytes=%zu offset=%td buf_size=%zu", caller, bytes,
@@ -208,11 +199,6 @@ void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) {
abort();
}
}
void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual) {
ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id,
expected, actual);
abort();
}
#endif
+63 -55
View File
@@ -3,6 +3,7 @@
#include "api_pb2_defines.h"
#include "api_buffer.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -56,22 +57,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) {
return (value & 1) ? static_cast<int64_t>(~(value >> 1)) : static_cast<int64_t>(value >> 1);
}
/// Count number of varints in a packed buffer
inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
uint16_t count = 0;
while (len > 0) {
// Skip varint bytes until we find one without continuation bit
while (len > 0 && (*data & 0x80)) {
data++;
len--;
}
if (len > 0) {
data++;
len--;
count++;
/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time.
/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host.
template<typename Word = size_t> inline uint16_t count_packed_varints(const uint8_t *data, size_t len) {
constexpr size_t word_size = sizeof(Word);
constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01
const uint8_t *end = data + len;
size_t continuations = 0;
while (data != end) {
// Unaligned word loads fault on Xtensa
if ((reinterpret_cast<uintptr_t>(data) & (word_size - 1)) == 0 && static_cast<size_t>(end - data) >= word_size) {
Word word;
memcpy(&word, __builtin_assume_aligned(data, word_size), word_size);
continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8);
data += word_size;
} else {
continuations += *data++ >> 7;
}
}
return count;
return static_cast<uint16_t>(len - continuations);
}
/// Encode a varint directly into a pre-allocated buffer.
@@ -228,6 +232,8 @@ void proto_check_bounds_failed(const uint8_t *pos, size_t bytes, const uint8_t *
/// Aborts unless an encode body ended exactly where calculate_size() promised. A plain check rather than
/// assert(), so NDEBUG cannot switch it off.
void proto_check_encode_end(const uint8_t *end, const uint8_t *expected);
/// Aborts unless a sized sub-message (length prefix at len_pos) ended where its calculated size said.
void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end);
#else
#define PROTO_ENCODE_DEBUG_PARAM
#define PROTO_ENCODE_DEBUG_ARG
@@ -260,21 +266,6 @@ class ProtoWriteBuffer {
* Following https://protobuf.dev/programming-guides/encoding/#structure
*/
void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); }
/// Single-pass encode for repeated submessage elements.
/// Thin template wrapper; all buffer work is in the non-template core.
template<typename T> void encode_sub_message(uint32_t field_id, const T &value);
/// Encode an optional singular submessage field — skips if empty.
/// Thin template wrapper; all buffer work is in the non-template core.
template<typename T> void encode_optional_sub_message(uint32_t field_id, const T &value);
// NOLINTBEGIN(readability-identifier-naming)
// Non-template core for encode_sub_message — backpatch approach.
void encode_sub_message(uint32_t field_id, const void *value,
uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM));
// Non-template core for encode_optional_sub_message.
void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value,
uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM));
// NOLINTEND(readability-identifier-naming)
APIBuffer *get_buffer() const { return buffer_; }
uint8_t *get_pos() const { return pos_; }
void set_pos(uint8_t *pos) { pos_ = pos; }
@@ -285,7 +276,6 @@ class ProtoWriteBuffer {
#ifdef ESPHOME_DEBUG_API
void debug_check_bounds_(size_t bytes, const char *caller = __builtin_FUNCTION());
void debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual);
#else
void debug_check_bounds_([[maybe_unused]] size_t bytes) {}
#endif
@@ -310,6 +300,9 @@ class ProtoWriteBuffer {
constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
/// Generated encode body: writes the fields at pos, returns the cursor past them.
using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM);
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
/// stack slot. Helpers without a _force suffix skip fields holding the proto3 default.
@@ -473,6 +466,21 @@ class ProtoEncode {
uint32_t field_id, const uint8_t *data, size_t len) {
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
}
/// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy.
[[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const uint8_t *data, size_t len) {
if (len == 0)
return pos;
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len);
}
[[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const uint8_t *data, size_t len) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len);
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
progmem_memcpy(pos, data, len);
return pos + len;
}
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t value) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
@@ -572,22 +580,36 @@ class ProtoEncode {
uint32_t field_id, int64_t value) {
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value));
}
/// Sub-message encoding: sync pos to buffer, delegate, read the cursor back.
/// Repeated sub-message element; the constant tag is written inline.
template<typename T>
[[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) {
buffer.set_pos(pos);
buffer.encode_sub_message(field_id, value);
return buffer.get_pos();
uint32_t field_id, const T &value) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2);
return encode_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, &value, &T::encode_msg);
}
/// Singular sub-message field, skipped when it encodes to nothing.
template<typename T>
[[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
ProtoWriteBuffer &buffer, uint32_t field_id,
const T &value) {
buffer.set_pos(pos);
buffer.encode_optional_sub_message(field_id, value);
return buffer.get_pos();
uint32_t field_id, const T &value) {
uint32_t nested_size = T::calc_size_msg(&value);
if (nested_size == 0)
return pos;
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2);
#ifdef ESPHOME_DEBUG_API
uint8_t *end = encode_sized_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, nested_size, &value, &T::encode_msg);
proto_check_sub_message_size(field_id, nested_size, pos, end);
return end;
#else
return encode_sized_sub_message_body(pos, nested_size, &value, &T::encode_msg);
#endif
}
/// Length and body, length backpatched after the body is written.
[[nodiscard]] static uint8_t *encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
const void *value, ProtoEncodeFn encode_fn);
/// Length and body for a precomputed size.
[[nodiscard]] static uint8_t *encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t nested_size, const void *value,
ProtoEncodeFn encode_fn);
private:
/// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM
@@ -701,9 +723,7 @@ class ProtoMessage {
public:
// Non-virtual defaults for messages with no fields; generated classes hide all four. The
// static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk.
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
return buffer.get_pos();
}
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) { return pos; }
static uint32_t calc_size_msg(const void *self) { return 0; }
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); }
uint32_t calculate_size() const { return 0; }
@@ -957,18 +977,6 @@ class ProtoSize {
}
};
// Implementation of methods that depend on ProtoSize being fully defined
// Thin template wrapper; delegates to non-template core in proto.cpp.
template<typename T> inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) {
this->encode_sub_message(field_id, &value, &T::encode_msg);
}
// Thin template wrapper; delegates to non-template core.
template<typename T> inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) {
this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg);
}
template<typename T> const char *proto_enum_to_string(T value);
// ProtoService removed — its methods were inlined into APIConnection.
+498
View File
@@ -0,0 +1,498 @@
"""The device wizard of the api component: schema, validation and code generation.
Home Assistant shows the wizard when the device is added. See api_wizard.h for the C++ side.
"""
from collections.abc import Callable
import importlib
import json
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_DESCRIPTION
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_ID,
CONF_INTERNAL,
CONF_MAX_LENGTH,
CONF_MULTIPLE,
CONF_NAME,
CONF_PAGES,
CONF_PLATFORM,
CONF_TARGET,
)
from esphome.core import CORE, ID
import esphome.final_validate as fv
from esphome.helpers import (
fnv1_hash,
fnv1_hash_object_id,
fnv1a_32bit_hash,
zstd_module,
)
from esphome.types import ConfigType
API_DOMAIN = "api"
CONF_ENTITIES = "entities"
CONF_ENTITY = "entity"
CONF_INPUTS = "inputs"
CONF_INTEGRATION = "integration"
CONF_SUPPORTED_FEATURES = "supported_features"
CONF_TITLE = "title"
CONF_WIZARD = "wizard"
_API = cg.esphome_ns.namespace("api")
WizardInput = _API.class_("WizardInput")
WIZARD_ENTITY_ID_BUFFER_SIZE = (
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
)
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
# api.proto max_data_length documents the same value
WIZARD_MULTIPLE_MAX_LENGTH = 4095
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
# Noise) and footer (16 bytes, Noise MAC)
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
# Version of the JSON the wizard is sent as, see wizard_document()
WIZARD_JSON_VERSION = 1
# zstd level the JSON is compressed with. Output is deterministic for a given zstd version.
WIZARD_ZSTD_LEVEL = 19
# Wizard string limits; api.proto documents the same values
WIZARD_TITLE_MAX_LENGTH = 127
WIZARD_DESCRIPTION_MAX_LENGTH = 255
WIZARD_FILTER_MAX_LENGTH = 63
WIZARD_SUPPORTED_FEATURE_MAX_LENGTH = 127
def _wizard_text(max_length: int) -> Callable[[Any], str]:
"""A string passed to Home Assistant verbatim, so it may be a [%key:...%] translation placeholder."""
return cv.All(cv.string_strict, cv.Length(max=max_length))
def _wizard_strings(max_length: int) -> Callable[[Any], list[str]]:
"""A single string or a list of strings, always validated to a non-empty list."""
return cv.All(
cv.ensure_list(cv.All(cv.string_strict, cv.Length(min=1, max=max_length))),
cv.Length(min=1),
)
# Mirrors Home Assistant's EntityFilterSelectorConfig
WIZARD_ENTITY_FILTER_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_INTEGRATION): cv.All(
cv.string_strict, cv.Length(min=1, max=WIZARD_FILTER_MAX_LENGTH)
),
cv.Optional(CONF_DOMAIN): _wizard_strings(WIZARD_FILTER_MAX_LENGTH),
cv.Optional(CONF_DEVICE_CLASS): _wizard_strings(WIZARD_FILTER_MAX_LENGTH),
cv.Optional(CONF_SUPPORTED_FEATURES): _wizard_strings(
WIZARD_SUPPORTED_FEATURE_MAX_LENGTH
),
}
),
cv.has_at_least_one_key(
CONF_INTEGRATION, CONF_DOMAIN, CONF_DEVICE_CLASS, CONF_SUPPORTED_FEATURES
),
)
WIZARD_ENTITY_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
}
)
def _validate_multiple(conf: ConfigType) -> ConfigType:
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
raise cv.Invalid(
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
# entity_id of its own: Home Assistant sets it
WIZARD_INPUT_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
),
cv.Optional(CONF_TARGET): cv.Schema(
{
cv.Required(CONF_ENTITY): cv.All(
cv.ensure_list(WIZARD_ENTITY_FILTER_SCHEMA), cv.Length(min=1)
),
}
),
}
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
)
WIZARD_PAGE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_TITLE): _wizard_text(WIZARD_TITLE_MAX_LENGTH),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_ENTITIES): cv.All(
cv.ensure_list(WIZARD_ENTITY_SCHEMA), cv.Length(min=1)
),
cv.Optional(CONF_INPUTS): cv.All(
cv.ensure_list(WIZARD_INPUT_SCHEMA), cv.Length(min=1)
),
}
),
cv.has_at_least_one_key(CONF_ENTITIES, CONF_INPUTS),
)
def _wizard_inputs(wizard: ConfigType) -> list[ConfigType]:
return [conf for page in wizard[CONF_PAGES] for conf in page.get(CONF_INPUTS, [])]
def _wizard_input_id(conf: ConfigType) -> ID:
"""The ID that names an input: the one a standalone input declares, or its linked entity."""
return conf[CONF_ID] if CONF_ID in conf else conf[CONF_ENTITY]
def _validate_unique_wizard_inputs(wizard: ConfigType) -> ConfigType:
"""An input is identified on the wire by a hash of its ID, so IDs and hashes must be unique."""
seen: dict[int, str] = {}
for conf in _wizard_inputs(wizard):
input_id = _wizard_input_id(conf).id
if (key := fnv1_hash(input_id)) in seen:
if seen[key] == input_id:
raise cv.Invalid(f"Wizard input '{input_id}' is used more than once")
raise cv.Invalid(
f"Wizard inputs '{seen[key]}' and '{input_id}' have the same hash, rename one"
)
seen[key] = input_id
return wizard
WIZARD_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_PAGES): cv.All(
cv.ensure_list(WIZARD_PAGE_SCHEMA), cv.Length(min=1)
),
}
),
_validate_unique_wizard_inputs,
)
# Platforms of the homeassistant component that a wizard input can stand for
WIZARD_INPUT_DOMAINS = (
"binary_sensor",
"button",
"number",
"select",
"sensor",
"switch",
"text",
"text_sensor",
)
# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within
WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text")
def wizard_input_ids(api_config: ConfigType) -> set[str]:
"""The IDs of the entities that are linked inputs of the wizard in the given api config."""
if (wizard := api_config.get(CONF_WIZARD)) is None:
return set()
return {
conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf
}
def _wizard_buffer_name(entity_id: ID) -> str:
return f"api_wizard_input_{entity_id.id}"
def wizard_input_buffer(entity_id: ID) -> str | None:
"""Name of the RAM buffer holding the Home Assistant entity ID of a wizard input.
Returns None when the entity is not an input of the wizard. The buffer is defined by the api
codegen, and the homeassistant entity of the input is given it in place of a constant.
"""
if entity_id.id not in wizard_input_ids(CORE.config.get(API_DOMAIN, {})):
return None
return _wizard_buffer_name(entity_id)
def _wizard_input_declaration(
config: fv.FinalValidateConfig, entity_id: ID
) -> tuple[str, ConfigType]:
"""The domain (like sensor) and the config an input ID is declared in."""
path = config.get_path_for_id(entity_id)[:-1]
return path[0], config.get_config_for_path(path)
def _wizard_input_filters(
conf: ConfigType, config: fv.FinalValidateConfig
) -> list[ConfigType]:
"""The entity filters of an input, with the defaults of a domain limited linked platform."""
if (filters := conf.get(CONF_TARGET, {}).get(CONF_ENTITY)) is not None:
return filters
if CONF_ENTITY in conf:
domain, _ = _wizard_input_declaration(config, conf[CONF_ENTITY])
if domains := _wizard_default_domains(domain):
return [{CONF_DOMAIN: domains}]
return []
def _wizard_defines(wizard: ConfigType) -> set[str]:
"""The defines for the parts of the wizard the configuration uses, so the rest is not compiled."""
inputs = _wizard_inputs(wizard)
defines = {"USE_API_WIZARD"}
if inputs:
defines.add("USE_API_WIZARD_INPUTS")
if any(CONF_ENTITY in conf for conf in inputs):
defines.add("USE_API_WIZARD_LINKED_INPUTS")
if any(CONF_ID in conf for conf in inputs):
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
return defines
def _wizard_default_domains(domain: str) -> list[str] | None:
"""The domains Home Assistant entities can be picked from when the input sets no target."""
if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS:
platform = importlib.import_module(f"esphome.components.homeassistant.{domain}")
return list(platform.SUPPORTED_DOMAINS)
return None
def _validate_wizard_input(conf: ConfigType) -> ConfigType:
if CONF_ENTITY not in conf:
return conf
domain, declaration = _wizard_input_declaration(
fv.full_config.get(), conf[CONF_ENTITY]
)
if (
declaration.get(CONF_PLATFORM) != "homeassistant"
or domain not in WIZARD_INPUT_DOMAINS
):
raise cv.Invalid(
f"Wizard input '{conf[CONF_ENTITY].id}' must be a homeassistant "
f"{', '.join(WIZARD_INPUT_DOMAINS)} entity"
)
if CONF_ENTITY_ID in declaration:
# An entity_id in the configuration is a static entry, which is kept apart from the dynamic ones
raise cv.Invalid(
f"'{conf[CONF_ENTITY].id}' has an entity_id set in its configuration, so it cannot be a "
"wizard input. Remove entity_id to let Home Assistant set it through the wizard."
)
if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS:
supported = _wizard_default_domains(domain)
for entity_filter in conf.get(CONF_TARGET, {}).get(CONF_ENTITY, []):
if not (domains := entity_filter.get(CONF_DOMAIN)):
raise cv.Invalid(
f"Every filter of a homeassistant {domain} input must set domain"
)
if unsupported := [d for d in domains if d not in supported]:
raise cv.Invalid(
f"The homeassistant {domain} does not support the domain(s) "
f"{', '.join(unsupported)}. Supported: {', '.join(supported)}"
)
return conf
def _validate_wizard_entity_exposed(value: ID) -> ID:
"""Reject entities that are internal, as they are not exposed over the API, or have no name.
The key a client knows an entity by is a hash of its name. Without a name of its own, the
device works the name out at runtime from its friendly name, which can add the MAC address,
so the key cannot be known when the wizard is built.
"""
_, declaration = _wizard_input_declaration(fv.full_config.get(), value)
if declaration.get(CONF_INTERNAL, False):
raise cv.Invalid(
f"Entity '{value.id}' is internal, so it is not exposed over the API "
"and cannot be used in the wizard"
)
if not declaration.get(CONF_NAME):
raise cv.Invalid(
f"Entity '{value.id}' has no name of its own, so its key is not known "
"when the wizard is built. Give it a name to use it in the wizard"
)
return value
_WIZARD_FINAL_VALIDATE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WIZARD): {
cv.Optional(CONF_PAGES): [
{
cv.Optional(CONF_ENTITIES): [
{cv.Optional(CONF_ID): _validate_wizard_entity_exposed}
],
cv.Optional(CONF_INPUTS): [_validate_wizard_input],
}
]
}
},
extra=cv.ALLOW_EXTRA,
)
def final_validate(config: ConfigType) -> None:
"""Final validation of the wizard in the given api config, if it has one."""
_WIZARD_FINAL_VALIDATE_SCHEMA(config)
if (wizard := config.get(CONF_WIZARD)) is not None:
size = len(wizard_blob(wizard, fv.full_config.get()))
if size > WIZARD_RESPONSE_MAX_SIZE:
raise cv.Invalid(
f"The compressed wizard is {size} bytes, {size - WIZARD_RESPONSE_MAX_SIZE} "
f"bytes over the {WIZARD_RESPONSE_MAX_SIZE} bytes one API message can hold. "
"Shorten the texts or use fewer pages, entities or filters",
path=[CONF_WIZARD],
)
WIZARD_INPUT_IS_SET_SCHEMA = cv.maybe_simple_value(
{cv.Required(CONF_ID): cv.use_id(WizardInput)}, key=CONF_ID
)
# Only for standalone inputs: a linked input is read through its homeassistant entity
automation.register_apply_condition(
"api.wizard.input_is_set", WIZARD_INPUT_IS_SET_SCHEMA, "has_entity_id()"
)
def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
"""An entity of the device that the page shows, keyed as ListEntitiesResponse keys it."""
_, declaration = _wizard_input_declaration(config, conf[CONF_ID])
document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])}
if (device := declaration.get(CONF_DEVICE_ID)) is not None:
document["device_id"] = fnv1a_32bit_hash(device.id)
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
return document
def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
if conf.get(CONF_MULTIPLE):
document["multiple"] = True
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
document["max_length"] = max_length
if filters := _wizard_input_filters(conf, config):
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
return document
def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> ConfigType:
"""The wizard as the JSON document the device sends, before it is serialised.
This is the format Home Assistant reads, and api.proto documents it for clients. Version 1:
{"version": 1,
"pages": [{"title": "...", "description": "...",
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
"entity_filters": [{"integration": "...", "domain": ["..."],
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
Anything empty or unset, and every empty list, is left out. Strings are passed through as
written, so they may be Home Assistant translation placeholders.
- An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the
object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of
the device it belongs to (esphome/core/config.py), and is left out for the main device.
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
- multiple means the user may pick several entities. The client sends them in one
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
device trims spaces around each id and stores the list without them. The whole request is
ignored when any id is empty or has no '.', or when the list without spaces does not fit
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
accepts.
- entity_filters are the filters of the input, or the default filters of a linked switch,
number, text, select or button.
"""
pages: list[ConfigType] = []
for page in wizard[CONF_PAGES]:
document: ConfigType = {}
for key in (CONF_TITLE, CONF_DESCRIPTION):
if value := page.get(key):
document[key] = value
if entities := [
_entity_document(e, config) for e in page.get(CONF_ENTITIES, [])
]:
document[CONF_ENTITIES] = entities
if inputs := [_input_document(i, config) for i in page.get(CONF_INPUTS, [])]:
document[CONF_INPUTS] = inputs
pages.append(document)
return {"version": WIZARD_JSON_VERSION, CONF_PAGES: pages}
def wizard_blob(wizard: ConfigType, config: fv.FinalValidateConfig) -> bytes:
"""The wizard document as compact, sorted UTF-8 JSON in a single zstd frame."""
text = json.dumps(
wizard_document(wizard, config),
separators=(",", ":"),
sort_keys=True,
ensure_ascii=False,
)
return zstd_module().compress(text.encode("utf-8"), level=WIZARD_ZSTD_LEVEL)
async def to_code(wizard: ConfigType) -> None:
"""Emit the compressed wizard, the table of inputs and the defines.
The API reads both tables from its own sources, so they are externally linked PROGMEM arrays.
"""
blob = wizard_blob(wizard, CORE.config)
cg.extern_progmem_array("esphome::api::API_WIZARD_DATA", cg.uint8, list(blob))
cg.add_define("API_WIZARD_DATA_SIZE", len(blob))
for define in sorted(_wizard_defines(wizard)):
cg.add_define(define)
entries: list[cg.RawExpression] = []
for conf in _wizard_inputs(wizard):
input_id = _wizard_input_id(conf)
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
buffer = _wizard_buffer_name(input_id)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
if entries:
cg.extern_progmem_array(
"esphome::api::API_WIZARD_INPUTS",
cg.esphome_ns.namespace("api").struct("WizardInputEntry"),
entries,
)
cg.add_define("API_WIZARD_INPUT_COUNT", len(entries))
+3 -7
View File
@@ -35,10 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_AS3935_ID])
if distance_config := config.get(CONF_DISTANCE):
sens = await sensor.new_sensor(distance_config)
cg.add(hub.set_distance_sensor(sens))
if lightning_energy_config := config.get(CONF_LIGHTNING_ENERGY):
sens = await sensor.new_sensor(lightning_energy_config)
cg.add(hub.set_energy_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_DISTANCE, hub.set_distance_sensor)
await sensors(CONF_LIGHTNING_ENERGY, hub.set_energy_sensor)
+5 -15
View File
@@ -87,18 +87,8 @@ async def to_code(config: ConfigType) -> None:
if out_of_range_mode_config := config.get(CONF_OUT_OF_RANGE_MODE):
cg.add(var.set_out_of_range_mode(out_of_range_mode_config))
if raw_position_config := config.get(CONF_RAW_POSITION):
sens = await sensor.new_sensor(raw_position_config)
cg.add(var.set_raw_position_sensor(sens))
if gain_config := config.get(CONF_GAIN):
sens = await sensor.new_sensor(gain_config)
cg.add(var.set_gain_sensor(sens))
if magnitude_config := config.get(CONF_MAGNITUDE):
sens = await sensor.new_sensor(magnitude_config)
cg.add(var.set_magnitude_sensor(sens))
if status_config := config.get(CONF_STATUS):
sens = await sensor.new_sensor(status_config)
cg.add(var.set_status_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_RAW_POSITION, var.set_raw_position_sensor)
await sensors(CONF_GAIN, var.set_gain_sensor)
await sensors(CONF_MAGNITUDE, var.set_magnitude_sensor)
await sensors(CONF_STATUS, var.set_status_sensor)
+3 -6
View File
@@ -241,12 +241,9 @@ async def to_code(config: ConfigType) -> None:
if peak_current_config := conf.get(CONF_PEAK_CURRENT):
sens = await sensor.new_sensor(peak_current_config)
cg.add(var.set_peak_current_sensor(i, sens))
if frequency_config := config.get(CONF_FREQUENCY):
sens = await sensor.new_sensor(frequency_config)
cg.add(var.set_freq_sensor(sens))
if chip_temperature_config := config.get(CONF_CHIP_TEMPERATURE):
sens = await sensor.new_sensor(chip_temperature_config)
cg.add(var.set_chip_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_FREQUENCY, var.set_freq_sensor)
await sensors(CONF_CHIP_TEMPERATURE, var.set_chip_temperature_sensor)
cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY]))
cg.add(var.set_current_phases(config[CONF_CURRENT_PHASES]))
cg.add(var.set_pga_gain(config[CONF_GAIN_PGA]))
@@ -36,14 +36,13 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ID])
hub = await cg.get_variable(config[CONF_ID])
if phase_cfg := config.get(CONF_PHASE_STATUS):
for i, key in enumerate(PHASE_KEYS):
if sub_phase_cfg := phase_cfg.get(key):
sens = await text_sensor.new_text_sensor(sub_phase_cfg)
cg.add(parent.set_phase_status_text_sensor(i, sens))
cg.add(hub.set_phase_status_text_sensor(i, sens))
if freq_status_config := config.get(CONF_FREQUENCY_STATUS):
sens = await text_sensor.new_text_sensor(freq_status_config)
cg.add(parent.set_freq_status_text_sensor(sens))
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_FREQUENCY_STATUS, hub.set_freq_status_text_sensor)
+3 -4
View File
@@ -6,7 +6,7 @@ import esphome.codegen as cg
from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
request_http_client,
require_certificate_bundle,
)
import esphome.config_validation as cv
@@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N
async def to_code(config: ConfigType) -> None:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
request_http_client()
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
@@ -392,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
+1 -1
View File
@@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
return ESP_ERR_INVALID_STATE;
}
esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) {
esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) {
file_type = AudioFileType::NONE;
this->current_audio_file_ = audio_file;
+2 -2
View File
@@ -47,7 +47,7 @@ class AudioReader {
/// @param audio_file AudioFile struct containing the file.
/// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read.
/// @return ESP_OK
esp_err_t start(AudioFile *audio_file, AudioFileType &file_type);
esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type);
/// @brief Reads new file data from the source and sends to the ring buffer sink.
/// @return AudioReaderState
@@ -69,7 +69,7 @@ class AudioReader {
esp_http_client_handle_t client_{nullptr};
AudioFile *current_audio_file_{nullptr};
const AudioFile *current_audio_file_{nullptr};
AudioFileType audio_file_type_{AudioFileType::NONE};
const uint8_t *file_current_{nullptr};
};
+4
View File
@@ -14,6 +14,10 @@ class AudioDac {
virtual bool is_muted() = 0;
virtual float volume() = 0;
/// Called from the main loop by the speaker driving this DAC once its audio clocks are running.
/// DACs that can only be configured while clocked (e.g. DSP state lost across clock stops) override this.
virtual void on_audio_started() {}
protected:
bool is_muted_{false};
};
+30 -11
View File
@@ -19,7 +19,7 @@ from esphome.const import (
CONF_URL,
)
from esphome.core import CORE, ID, HexInt
from esphome.cpp_generator import MockObj
from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression
from esphome.external_files import download_web_files_in_config
from esphome.types import ConfigType
@@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
)
CONF_FILE_DATA_ID = "file_data_id"
MEDIA_FILE_TYPE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(audio.AudioFile),
cv.Required(CONF_FILE): _file_schema,
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile),
}
)
@@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All:
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
"""Generate the progmem data and a flash AudioFile for one file.
Returns the created Pvariable. Caller is responsible for any further
Returns a const pointer to the AudioFile. Caller is responsible for any further
registration (the audio_file component additionally registers each file in
its named C++ registry; other consumers may skip that).
"""
@@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj:
else:
data, media_file_type = read_audio_file_and_type(file_config)
rhs = [HexInt(x) for x in data]
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
media_files_struct = cg.StructInitializer(
# Global constants so the AudioFile lives in flash; the id stays a plain pointer
# because actions render id arguments as ``::<id>``.
data_id = file_config[CONF_RAW_DATA_ID]
cg.add_global(
ProgmemAssignmentExpression(
data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data])
)
)
media_file = cg.StructInitializer(
audio.AudioFile,
("data", prog_arr),
("length", len(rhs)),
("data", MockObj(data_id, ".")),
("length", len(data)),
("file_type", media_file_type),
)
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
file_var_id = file_config[CONF_ID]
storage = file_config[CONF_FILE_DATA_ID]
cg.add_global(
cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};")
)
cg.add_global(
cg.RawStatement(
f"static const {storage.type} *const {file_var_id} = &{storage};"
)
)
var = MockObj(file_var_id, "->")
CORE.register_variable(file_var_id, var)
return var
CONFIG_SCHEMA = cv.All(
+2 -2
View File
@@ -10,14 +10,14 @@
namespace esphome::audio_file {
struct NamedAudioFile {
audio::AudioFile *file;
const audio::AudioFile *file;
const char *file_id;
};
inline StaticVector<NamedAudioFile, AUDIO_FILE_MAX_FILES>
named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) {
inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) {
named_audio_files.push_back({file, file_id});
}
@@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component,
protected:
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
audio::AudioStreamInfo stream_info_;
audio::AudioFile *current_file_{nullptr};
const audio::AudioFile *current_file_{nullptr};
// Written from the main loop in handle_command(), read from the decoder task in
// on_audio_write(). Must be atomic to avoid a data race.
@@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() {
" Persistent Ring Buffer: %s\n"
" Decoder Task Stack in PSRAM: %s",
this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_));
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr));
#endif
}
void AudioHTTPMediaSource::setup() {
@@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() {
config.reader_stack_size = READER_TASK_STACK_SIZE;
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
// micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle.
if (this->http_ca_certificate_ != nullptr) {
config.http_ca_certificate = this->http_ca_certificate_;
}
#endif
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
if (this->decoder_ == nullptr) {
@@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component,
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; }
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
/// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs,
/// replacing the built-in certificate bundle.
void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; }
#endif
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
@@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component,
std::atomic<bool> pause_{false};
bool decoder_task_stack_in_psram_{false};
bool persistent_ring_buffer_{false};
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
const char *http_ca_certificate_{nullptr};
#endif
};
} // namespace esphome::audio_http
@@ -1,13 +1,22 @@
import logging
from pathlib import Path
import esphome.codegen as cg
from esphome.components import audio, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
CONF_CA_CERTIFICATE_PATH = "ca_certificate_path"
CONF_HTTP_REQUEST = "http_request"
CONF_VERIFY_SSL = "verify_ssl"
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
@@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
# Default to the CA certificate configured on the http_request component so
# HTTPS playback verifies against the same trust anchor without repeating
# the option on every source. Needed because audio_http sources are often
# declared by device packages and cannot be extended from the device config.
# The PEM replaces the built-in certificate bundle as the sole trust anchor,
# so make the inheritance visible in the log.
if CONF_CA_CERTIFICATE_PATH in config:
return config
fconf = fv.full_config.get()
if CONF_HTTP_REQUEST not in fconf:
return config
http_request_config = fconf[CONF_HTTP_REQUEST]
# Mirror http_request's own semantics: it only applies its CA when SSL
# verification is enabled, so neither does the inheritance.
if not http_request_config.get(CONF_VERIFY_SSL, True):
return config
if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH):
_LOGGER.info(
"audio_http source '%s' is inheriting ca_certificate_path from the "
"http_request component; HTTPS playback will verify against that PEM "
"instead of the built-in certificate bundle",
config[CONF_ID].id,
)
# Already validated by http_request's schema; the value is a resolved Path.
config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path
return config
FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioHTTPMediaSource,
@@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER]))
# Embed the certificate content, like http_request does. Passed to
# micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as
# the sole trust anchor for HTTPS playback URLs instead of the certificate
# bundle.
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE")
with Path(ca_cert_path).open(encoding="utf-8") as f:
ca_cert_content = f.read()
cg.add(var.set_http_ca_certificate(ca_cert_content))
+3 -7
View File
@@ -44,10 +44,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await register_bedjet_child(var, config)
if outlet_temperature_sensor := config.get(CONF_OUTLET_TEMPERATURE):
sensor_var = await sensor.new_sensor(outlet_temperature_sensor)
cg.add(var.set_outlet_temperature_sensor(sensor_var))
if ambient_temperature_sensor := config.get(CONF_AMBIENT_TEMPERATURE):
sensor_var = await sensor.new_sensor(ambient_temperature_sensor)
cg.add(var.set_ambient_temperature_sensor(sensor_var))
sensors = sensor.sub_sensors(config)
await sensors(CONF_OUTLET_TEMPERATURE, var.set_outlet_temperature_sensor)
await sensors(CONF_AMBIENT_TEMPERATURE, var.set_ambient_temperature_sensor)
@@ -11,29 +11,34 @@
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <initializer_list>
namespace esphome::ble_device_base {
/// True if `address` is in `table`, a list of MACs ended by 0.
inline bool mac_in_table(const uint64_t *table, uint64_t address) {
for (; *table != 0; table++) {
if (*table == address)
return true;
}
return false;
}
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
/// Table of MACs ended by 0; must outlive the trigger.
void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; }
bool parse_device(const ESPBTDevice &device) override {
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
device.address_uint64()) == this->addresses_.end()) {
if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64()))
return false;
}
this->trigger(device);
return true;
}
protected:
FixedVector<uint64_t> addresses_;
const uint64_t *addresses_{nullptr};
};
// on_ble_service_data_advertise: fires when an advertisement contains service
@@ -69,13 +69,24 @@ def uuid_trigger_schema(
)
def _filter_mac(value: Any) -> Any:
mac = cv.mac_address(value)
if not any(mac.parts):
# 0 ends the flash MAC table, and it is never a real device address.
raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter")
return mac
MAC_FILTER_LIST = cv.ensure_list(_filter_mac)
def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]:
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
uuid_trigger_schema() — pairs with advertise_trigger_to_code()."""
return automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST,
}
)
@@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
def mac_filter_table(macs: list) -> cg.MockObj:
"""Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer."""
return cg.shared_progmem_array(
"ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0]
)
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
if macs := conf.get(CONF_MAC_ADDRESS):
cg.add(trigger.set_addresses(mac_filter_table(macs)))
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
_count_listener()
+1
View File
@@ -62,6 +62,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
zephyr_add_prj_conf("BT_NUS", True)
zephyr_add_prj_conf("RING_BUFFER", True)
request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU
expose_log = config[CONF_TYPE] == CONF_LOGS
cg.add(var.set_expose_log(expose_log))
@@ -17,6 +17,13 @@
#include "esphome/core/log.h"
#include <cstring>
#include <esp_idf_version.h>
// cancel_open exists from ESP-IDF 5.5.5 and 6.0.1.
#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 5) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0)) || \
ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 1)
#define BLUEDROID_HAS_CANCEL_OPEN
#endif
namespace esphome::bluetooth_connection {
@@ -63,6 +70,12 @@ void BluedroidGattClient::loop() {
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
// teardown whose OPEN_EVT never arrives.
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
if (this->conn_id_ == UNSET_CONN_ID) {
this->cancel_pending_open_();
} else if (st == ClientState::CONNECTING) {
// CONNECT_EVT came but OPEN_EVT was lost, so our open still holds the ACL link.
this->check_and_log_error_("esp_ble_gap_disconnect", esp_ble_gap_disconnect(this->remote_bda_));
}
ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_);
// Release before idling: a lost completion must not leak the cache.
this->release_services();
@@ -140,6 +153,8 @@ void BluedroidGattClient::tracker_connect_() {
this->services_released_ = false;
this->seen_mtu_ = false;
this->mtu_failed_ = false;
this->cancel_open_sent_ = false;
this->conn_id_ = UNSET_CONN_ID;
this->enable_loop();
this->set_state(ClientState::CONNECTING);
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
@@ -183,12 +198,28 @@ int BluedroidGattClient::gatt_disconnect() {
// Arm the safety window: a lost OPEN_EVT must not leak the teardown.
this->disconnecting_started_ = millis();
this->enable_loop();
if (this->conn_id_ == UNSET_CONN_ID) {
// CANCEL_OPEN_EVT or a racing OPEN_EVT settles the slot.
this->cancel_pending_open_();
}
return 0;
}
this->unconditional_disconnect_();
return 0;
}
void BluedroidGattClient::cancel_pending_open_() {
#ifdef BLUEDROID_HAS_CANCEL_OPEN
esp_ble_gattc_cancel_open_params_t params{};
params.gattc_if = this->gattc_if_;
memcpy(params.remote_bda, this->remote_bda_, sizeof(esp_bd_addr_t));
// A refused cancel schedules no CANCEL_OPEN_EVT, so the teardown stays cancellable.
if (this->check_and_log_error_("esp_ble_gattc_cancel_open", esp_ble_gattc_cancel_open(&params)) == ESP_OK) {
this->cancel_open_sent_ = true;
}
#endif
}
void BluedroidGattClient::unconditional_disconnect_() {
ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_);
if (this->conn_id_ == UNSET_CONN_ID) {
@@ -214,6 +245,10 @@ bool BluedroidGattClient::cancel_gatt_disconnect() {
if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) {
return false;
}
if (this->cancel_open_sent_) {
// The cancel already went out, so CANCEL_OPEN_EVT ends it.
return false;
}
this->want_disconnect_ = false;
return true;
}
@@ -574,6 +609,8 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
this->listener_->on_connection_state(false, 0, param->open.status);
return;
}
// ALREADY_OPEN on an existing link sends no CONNECT_EVT, so take the id here.
this->conn_id_ = param->open.conn_id;
if (this->disconnect_pending()) {
// Open resolved with a teardown scheduled: close now (conn_id_ stays set
// so CLOSE_EVT still matches).
@@ -617,6 +654,11 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
return;
}
if (this->conn_id_ == UNSET_CONN_ID) {
// No CONNECT_EVT for this attempt yet, so the event belongs to an earlier
// link, such as one the teardown timeout forced down.
return;
}
// Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting
// earlier makes the controller reject with 133 or assert) and before
// reporting - the wrapper frees the slot on the report, and a freed slot
@@ -645,6 +687,10 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
case ESP_GATTC_CONNECT_EVT: {
if (!this->check_addr_(param->connect.remote_bda))
return false;
// Every client interface gets CONNECT_EVT for every new link; only an
// attempt in flight owns it. An idle slot must not adopt another slot's link.
if (this->state() != ClientState::CONNECTING)
break;
this->conn_id_ = param->connect.conn_id;
// MTU request here rather than OPEN_EVT, matching the IDF examples.
auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id);
@@ -656,8 +702,15 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
break;
}
case ESP_GATTC_OPEN_EVT: {
if (!this->check_addr_(param->open.remote_bda))
if (!this->check_addr_(param->open.remote_bda)) {
// Our interface (filtered above), stale address, so nobody tracks this link.
if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) {
ESP_LOGW(TAG, "[%d] Closing link left by an abandoned open", this->connection_index_);
this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id));
return true;
}
return false;
}
this->handle_open_evt_(param);
break;
}
@@ -748,6 +801,24 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
break;
}
#ifdef BLUEDROID_HAS_CANCEL_OPEN
case ESP_GATTC_CANCEL_OPEN_EVT: {
if (!this->check_addr_(param->cancel_open.remote_bda))
return false;
if (param->cancel_open.status != ESP_GATT_OK) {
// Too late to cancel: OPEN_EVT follows and settles the slot. The latch
// stays set because the event cannot be tied to this attempt's cancel.
break;
}
if (this->state() == ClientState::CONNECTING && this->disconnect_pending()) {
ESP_LOGD(TAG, "[%d] Pending open cancelled", this->connection_index_);
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_CONN_CANCEL);
}
break;
}
#endif
default:
break;
}
@@ -100,6 +100,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
int handle_search_cmpl_(esp_gatt_status_t status);
void deliver_pending_search_();
void unconditional_disconnect_();
void cancel_pending_open_();
void set_idle_();
void set_disconnecting_();
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
@@ -133,6 +134,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
bool seen_mtu_ : 1 {false};
// The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead.
bool mtu_failed_ : 1 {false};
// esp_ble_gattc_cancel_open() was accepted; CANCEL_OPEN_EVT or OPEN_EVT ends
// the attempt, so the scheduled teardown can no longer be cancelled.
bool cancel_open_sent_ : 1 {false};
// Search issued at OPEN_EVT overlaps the MTU exchange; discover_services()
// completes from it. Reset by set_idle_().
static_assert(static_cast<uint8_t>(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow");
+9 -18
View File
@@ -131,24 +131,15 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING]))
if gas_resistance_config := config.get(CONF_GAS_RESISTANCE):
sens = await sensor.new_sensor(gas_resistance_config)
cg.add(var.set_gas_resistance_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING]
cg.add(var.set_temperature_oversampling(oversampling))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING]))
if await sensors(CONF_HUMIDITY, var.set_humidity_sensor):
cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING]))
await sensors(CONF_GAS_RESISTANCE, var.set_gas_resistance_sensor)
cg.add(var.set_iir_filter(IIR_FILTER_OPTIONS[config[CONF_IIR_FILTER]]))
+8 -13
View File
@@ -88,16 +88,11 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
for d in ["x", "y", "z"]:
accel_key = f"acceleration_{d}"
if accel_key in config:
sens = await sensor.new_sensor(config[accel_key])
cg.add(getattr(var, f"set_accel_{d}_sensor")(sens))
accel_key = f"gyroscope_{d}"
if accel_key in config:
sens = await sensor.new_sensor(config[accel_key])
cg.add(getattr(var, f"set_gyro_{d}_sensor")(sens))
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor)
await sensors(CONF_GYROSCOPE_X, var.set_gyro_x_sensor)
await sensors(CONF_ACCELERATION_Y, var.set_accel_y_sensor)
await sensors(CONF_GYROSCOPE_Y, var.set_gyro_y_sensor)
await sensors(CONF_ACCELERATION_Z, var.set_accel_z_sensor)
await sensors(CONF_GYROSCOPE_Z, var.set_gyro_z_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
+3 -7
View File
@@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature(sens))
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature)
await sensors(CONF_PRESSURE, var.set_pressure)
+5 -6
View File
@@ -75,14 +75,13 @@ async def to_code_base(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
temperature_config = config[CONF_TEMPERATURE]
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
pressure_config = config[CONF_PRESSURE]
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
cg.add(var.set_iir_filter(config[CONF_IIR_FILTER]))
+5 -6
View File
@@ -80,18 +80,17 @@ async def to_code_base(config: ConfigType) -> MockObj:
await cg.register_component(var, config)
cg.add(var.set_iir_filter_config(config[CONF_IIR_FILTER]))
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
temperature_config = config[CONF_TEMPERATURE]
cg.add(
var.set_temperature_oversampling_config(
temperature_config[CONF_OVERSAMPLING]
)
)
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
pressure_config = config[CONF_PRESSURE]
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
return var
+5 -6
View File
@@ -137,9 +137,9 @@ CONFIG_SCHEMA_BASE = cv.Schema(
async def to_code_base(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
temperature_config = config[CONF_TEMPERATURE]
cg.add(
var.set_temperature_oversampling_config(
temperature_config[CONF_OVERSAMPLING]
@@ -149,9 +149,8 @@ async def to_code_base(config: ConfigType) -> MockObj:
var.set_temperature_iir_filter_config(temperature_config[CONF_IIR_FILTER])
)
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
pressure_config = config[CONF_PRESSURE]
cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING]))
cg.add(var.set_pressure_iir_filter_config(pressure_config[CONF_IIR_FILTER]))
@@ -422,6 +422,21 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat
}
break;
}
case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T
if (this->temperature_ != nullptr) {
const int16_t raw = encode_uint16(value[1], value[0]);
this->temperature_->publish_state(raw * 0.1f);
reported = true;
}
break;
}
case 0x2E: { // humidity (uint8, 1%), e.g. Shelly BLU H&T
if (this->humidity_ != nullptr) {
this->humidity_->publish_state(value[0]);
reported = true;
}
break;
}
default:
break;
}
+16 -4
View File
@@ -19,6 +19,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
CONF_CANBUS_ID = "canbus_id"
CONF_BIT_RATE = "bit_rate"
CONF_ON_FRAME = "on_frame"
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
def validate_id(config: ConfigType) -> ConfigType:
@@ -86,8 +89,13 @@ CANBUS_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(CanbusComponent),
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
CAN_SPEEDS, upper=True
),
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
cv.Optional(
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
): cv.positive_not_null_int,
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
@@ -108,9 +116,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
cg.add(var.set_can_id(config[CONF_CAN_ID]))
if config[CONF_USE_EXTENDED_ID]:
cg.add(var.set_use_extended_id(True))
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
cg.add(var.set_max_frames_per_loop(max_frames))
for conf in config.get(CONF_ON_FRAME, []):
can_id = conf[CONF_CAN_ID]
+15 -7
View File
@@ -67,16 +67,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
void Canbus::loop() {
struct CanFrame can_message;
// read all messages until queue is empty
int message_counter = 0;
while (this->read_message(&can_message) == canbus::ERROR_OK) {
message_counter++;
uint32_t message_counter = 0;
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
if (this->read_message(&can_message) != canbus::ERROR_OK) {
break;
}
if (can_message.use_extended_id) {
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
} else {
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
can_message.can_data_length_code);
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
}
std::vector<uint8_t> data;
@@ -100,6 +102,12 @@ void Canbus::loop() {
}
}
}
if (message_counter == this->max_frames_per_loop_) {
ESP_LOGW(TAG,
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
this->max_frames_per_loop_);
}
}
} // namespace esphome::canbus
+4 -2
View File
@@ -78,6 +78,7 @@ class Canbus : public Component {
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
void add_trigger(CanbusTrigger *trigger);
/**
@@ -96,8 +97,9 @@ class Canbus : public Component {
template<typename... Ts> friend class CanbusSendAction;
std::vector<CanbusTrigger *> triggers_{};
uint32_t can_id_;
bool use_extended_id_;
CanSpeed bit_rate_;
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
bool use_extended_id_{false};
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
callback_manager_{};
+7 -1
View File
@@ -253,11 +253,17 @@ void CC1101Component::dump_config() {
}
void CC1101Component::begin_tx() {
// Read the mode before the PKTCTRL0 write below overwrites it in state_
const bool packet_mode = this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
// Ensure Packet Format is 3 (Async Serial)
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->detach_interrupt();
// setup() only attaches the interrupt in packet mode. In async serial mode the pin may be
// shared with remote_receiver, and detaching here would remove the receiver's interrupt.
if (packet_mode) {
this->gdo0_pin_->detach_interrupt();
}
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
+5 -10
View File
@@ -65,17 +65,12 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if eco2_config := config.get(CONF_ECO2):
sens = await sensor.new_sensor(eco2_config)
cg.add(var.set_co2(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ECO2, var.set_co2)
await sensors(CONF_TVOC, var.set_tvoc)
if tvoc_config := config.get(CONF_TVOC):
sens = await sensor.new_sensor(tvoc_config)
cg.add(var.set_tvoc(sens))
if version_config := config.get(CONF_VERSION):
sens = await text_sensor.new_text_sensor(version_config)
cg.add(var.set_version(sens))
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_VERSION, var.set_version)
if (baseline := config.get(CONF_BASELINE)) is not None:
cg.add(var.set_baseline(baseline))
+3 -11
View File
@@ -368,19 +368,11 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
}
void Climate::save_state_(const ClimateTraits &traits) {
#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wclass-memaccess"
#define TEMP_IGNORE_MEMACCESS
#endif
ClimateDeviceRestoreState state{};
// initialize as zero (including padding) to prevent random data on stack triggering erase
// initialize as zero (including padding) to prevent random data on stack triggering erase;
// the void * cast tells GCC the bytewise clear of a non-trivial type is intentional
// NOLINTNEXTLINE(bugprone-raw-memory-call-on-non-trivial-type) -- intentional bytewise zero for RTC save
memset(&state, 0, sizeof(ClimateDeviceRestoreState));
#ifdef TEMP_IGNORE_MEMACCESS
#pragma GCC diagnostic pop
#undef TEMP_IGNORE_MEMACCESS
#endif
memset(static_cast<void *>(&state), 0, sizeof(ClimateDeviceRestoreState));
state.mode = this->mode;
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
+2 -3
View File
@@ -54,9 +54,8 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if co2_config := config.get(CONF_CO2):
sens = await sensor.new_sensor(co2_config)
cg.add(var.set_co2_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
CALIBRATION_ACTION_SCHEMA = maybe_simple_id(
+9 -28
View File
@@ -1,5 +1,3 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
@@ -18,8 +16,6 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
combination_ns = cg.esphome_ns.namespace("combination")
@@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema(
)
def _migrate_coeffecient(config: ConfigType) -> ConfigType:
"""Migrate deprecated 'coeffecient' spelling to 'coefficient'."""
if CONF_COEFFECIENT in config:
if CONF_COEFFICIENT in config:
raise cv.Invalid(
f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'"
)
_LOGGER.warning(
"'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0",
CONF_COEFFECIENT,
CONF_COEFFICIENT,
)
config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT)
elif CONF_COEFFICIENT not in config:
raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option")
return config
LINEAR_SOURCE_SCHEMA = cv.All(
cv.rename_key(
CONF_COEFFECIENT,
CONF_COEFFICIENT,
removed_in="2026.12.0",
component="combination",
),
cv.Schema(
{
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_),
cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_),
cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_),
}
),
_migrate_coeffecient,
)
SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema(
@@ -203,6 +185,5 @@ async def to_code(config: ConfigType) -> None:
else:
cg.add(var.add_source(source))
if CONF_STD_DEV in config:
sens = await sensor.new_sensor(config[CONF_STD_DEV])
cg.add(var.set_std_dev_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_STD_DEV, var.set_std_dev_sensor)
+4
View File
@@ -7,6 +7,7 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ALLOWED_IPS = "allowed_ips"
CONF_B_CONSTANT = "b_constant"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
@@ -15,6 +16,7 @@ CONF_CLIMATE_ID = "climate_id"
CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
CONF_COLUMNS = "columns"
CONF_CONNECTED = "connected"
CONF_CONVERSION_RATE = "conversion_rate"
CONF_CRC_ENABLE = "crc_enable"
CONF_DATA_BITS = "data_bits"
@@ -41,7 +43,9 @@ CONF_ON_SCAN_END = "on_scan_end"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROLE = "role"
CONF_ROWS = "rows"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SHA256 = "sha256"
+4 -9
View File
@@ -117,15 +117,10 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_hpf_enable(config[CONF_CURRENT_HPF], config[CONF_VOLTAGE_HPF]))
cg.add(var.set_pulse_energy_wh(config[CONF_PULSE_ENERGY]))
if voltage_config := config.get(CONF_VOLTAGE):
sens = await sensor.new_sensor(voltage_config)
cg.add(var.set_voltage_sensor(sens))
if current_config := config.get(CONF_CURRENT):
sens = await sensor.new_sensor(current_config)
cg.add(var.set_current_sensor(sens))
if power_config := config.get(CONF_POWER):
sens = await sensor.new_sensor(power_config)
cg.add(var.set_power_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_POWER, var.set_power_sensor)
automation.register_apply_action(

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