Compare commits

...
Author SHA1 Message Date
J. Nick Koston 081d8d492a Note that the custom init sequence must end with a 0 command 2026-10-02 21:37:05 -05:00
J. Nick Koston e18c1bb39b [core] Let shared_progmem_array emit an unshared array
Identical lambdas can carry separate static state, so callers need a way
to keep such tables apart while still getting a collision free name.
2026-10-02 21:25:12 -05:00
J. Nick Koston 70ee367c28 [ili9xxx] Keep the custom init sequence in a shared PROGMEM table
The table carries the 0 terminator init_lcd_() stops at; the vector it
replaces had none, so the loop read past its end.
2026-10-02 21:00:59 -05:00
J. Nick Koston 5c9c233568 [core] Emit shared PROGMEM arrays at namespace scope
A setup() local table could only be reused from the scope of its first
use. The arrays are now globals. A lambda in a table only references
variables that codegen has already declared, so writing the global
straight away keeps the order valid.
2026-10-02 20:45:23 -05:00
J. Nick Koston c58a26dce2 [core] Add cg.shared_progmem_array for deduplicated PROGMEM tables
Emits one PROGMEM array per distinct type and contents and returns the
same variable to later callers. The array name is made unique against
every config id and registered variable, so a generated table can never
collide with a user id.
2026-10-02 20:45:23 -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
637 changed files with 26445 additions and 5305 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"
+21 -2
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
@@ -1225,9 +1225,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}}"
+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.9
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:
+4
View File
@@ -541,6 +541,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,10 +555,12 @@ 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/tem3200/* @bakerkj
@@ -599,6 +602,7 @@ 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
+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.19.0
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:
+56 -161
View File
@@ -18,7 +18,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 +35,6 @@ from esphome.const import (
CONF_LOGGER,
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
@@ -62,6 +60,7 @@ 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 +84,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 +148,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 +828,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):
# Info, not a warning: an orchestrator cannot see YAML toolchain
# overrides, this is its expected no-op, and a full build is safe.
_LOGGER.info(
"--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 +845,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 +866,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 +987,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 +1300,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
@@ -1381,6 +1391,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 +1772,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
@@ -2104,155 +2132,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 +2169,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 +2258,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 +2319,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 +2383,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 +2416,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 +2425,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",
@@ -2782,6 +2676,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.1",
"e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582",
37189311,
),
}
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
)
+60 -14
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,
@@ -1049,24 +1053,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,
),
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(),
*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",
@@ -1111,7 +1113,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
# 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] = []
src_other: list[str] = []
src_includes: list[str] = []
src_it = iter(
lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags")
)
@@ -1122,15 +1125,33 @@ 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))
include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes]
# 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 +1189,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])
+243 -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,78 @@ 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 two 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)
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)",
)
_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 +275,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 +368,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 +383,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 +461,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:
+32 -1
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.
@@ -228,6 +241,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*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]]:
+81 -6
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:
@@ -94,6 +105,64 @@ def compile_rule_lines() -> list[str]:
]
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 +180,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)
+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
+1
View File
@@ -40,6 +40,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)
+2
View File
@@ -13,6 +13,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,
@@ -613,6 +614,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
enable_spare_ephemeral()
else:
cg.add_define("USE_API_PLAINTEXT")
+6
View File
@@ -317,9 +317,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();
+21 -2
View File
@@ -162,6 +162,13 @@ 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
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 +185,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 +213,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];
+4 -1
View File
@@ -5,7 +5,7 @@
#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
@@ -367,6 +367,9 @@ class APIServer final : public Component
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
+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)
+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};
};
+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)
+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;
}
+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(
+2 -3
View File
@@ -203,6 +203,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(
+1
View File
@@ -60,6 +60,7 @@ async def to_code(config: ConfigType) -> None:
zephyr_add_prj_conf("LOG_BLOCK_IN_THREAD", True)
zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096)
zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True)
zephyr_add_prj_conf("PRINTK", True)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add_define("USE_DEBUG")
+12 -13
View File
@@ -187,19 +187,18 @@ async def dfrobot_sen0395_settings_to_code(
template_ = await cg.templatable(segments[7], args, cg.float_)
cg.add(var.set_det_max4(template_))
if CONF_OUTPUT_LATENCY in config:
template_ = await cg.templatable(
config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DETECT], args, float
)
if isinstance(template_, cv.TimePeriod):
template_ = template_.total_milliseconds / 1000
cg.add(var.set_delay_after_detect(template_))
template_ = await cg.templatable(
config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DISAPPEAR], args, float
)
if isinstance(template_, cv.TimePeriod):
template_ = template_.total_milliseconds / 1000
cg.add(var.set_delay_after_disappear(template_))
latency = config[CONF_OUTPUT_LATENCY]
for key, setter in (
(CONF_DELAY_AFTER_DETECT, var.set_delay_after_detect),
(CONF_DELAY_AFTER_DISAPPEAR, var.set_delay_after_disappear),
):
template_ = await cg.templatable(
latency[key],
args,
float,
to_exp=lambda period: period.total_milliseconds / 1000,
)
cg.add(setter(template_))
if CONF_SENSITIVITY in config:
template_ = await cg.templatable(config[CONF_SENSITIVITY], args, cg.int8)
cg.add(var.set_sensitivity(template_))
+3 -6
View File
@@ -61,11 +61,8 @@ async def to_code(config: ConfigType) -> None:
pin = await gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_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)
cg.add(var.set_dht_model(config[CONF_MODEL]))
+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 CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_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)
+4 -2
View File
@@ -287,13 +287,15 @@ template<typename T> class DisplayWriter {
// Type alias for Display writer - uses optimized DisplayWriter instead of std::function
using display_writer_t = DisplayWriter<Display>;
// Callers usually pass `this`; checking a copy keeps GCC's -Wnonnull-compare quiet.
#define LOG_DISPLAY(prefix, type, obj) \
if ((obj) != nullptr) { \
if (auto *log_display_obj = (obj); log_display_obj != nullptr) { \
ESP_LOGCONFIG(TAG, \
prefix type "\n" \
"%s Rotations: %d °\n" \
"%s Dimensions: %dpx x %dpx", \
prefix, (obj)->rotation_, prefix, (obj)->get_width(), (obj)->get_height()); \
prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \
log_display_obj->get_height()); \
}
/// Turn the pixel OFF.
+1 -1
View File
@@ -86,7 +86,7 @@ void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); }
void DlmsMeterComponent::dump_config() {
ESP_LOGCONFIG(TAG, "DLMS Meter:");
ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_);
ESP_LOGCONFIG(TAG, " Receive Timeout: %" PRIu32 " ms", this->receive_timeout_ms_);
ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_));
for (const auto &pattern : this->custom_patterns_) {
+3 -7
View File
@@ -54,10 +54,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature)
cg.add(var.set_temperature_sensor(sens))
if pressure := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure)
cg.add(var.set_pressure_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
+2 -3
View File
@@ -63,9 +63,8 @@ async def to_code(config: ConfigType) -> None:
if CONF_LAMBDA in config:
lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_)
cg.add(var.set_lambda(lambda_))
if CONF_LAST_TIME in config:
sens = await sensor.new_sensor(config[CONF_LAST_TIME])
cg.add(var.set_last_duty_time_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_LAST_TIME, var.set_last_duty_time_sensor)
# AUTOMATIONS
+4 -11
View File
@@ -57,14 +57,7 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature)
cg.add(var.set_temperature_sensor(sens))
if co2 := config.get(CONF_CO2):
sens = await sensor.new_sensor(co2)
cg.add(var.set_co2_sensor(sens))
if pressure := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure)
cg.add(var.set_pressure_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_CO2, var.set_co2_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
+7 -17
View File
@@ -55,22 +55,12 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend(
async def to_code(config: ConfigType) -> None:
paren = await cg.get_variable(config[CONF_EMC2101_ID])
var = cg.new_Pvariable(config[CONF_ID], paren)
hub = await cg.get_variable(config[CONF_EMC2101_ID])
var = cg.new_Pvariable(config[CONF_ID], hub)
await cg.register_component(var, config)
if CONF_INTERNAL_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_INTERNAL_TEMPERATURE])
cg.add(var.set_internal_temperature_sensor(sens))
if CONF_EXTERNAL_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE])
cg.add(var.set_external_temperature_sensor(sens))
if CONF_SPEED in config:
sens = await sensor.new_sensor(config[CONF_SPEED])
cg.add(var.set_speed_sensor(sens))
if CONF_DUTY_CYCLE in config:
sens = await sensor.new_sensor(config[CONF_DUTY_CYCLE])
cg.add(var.set_duty_cycle_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor)
await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor)
await sensors(CONF_SPEED, var.set_speed_sensor)
await sensors(CONF_DUTY_CYCLE, var.set_duty_cycle_sensor)
+4 -9
View File
@@ -63,15 +63,10 @@ async def to_code_base(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if eco2_config := config.get(CONF_ECO2):
sens = await sensor.new_sensor(eco2_config)
cg.add(var.set_co2(sens))
if tvoc_config := config.get(CONF_TVOC):
sens = await sensor.new_sensor(tvoc_config)
cg.add(var.set_tvoc(sens))
if aqi_config := config.get(CONF_AQI):
sens = await sensor.new_sensor(aqi_config)
cg.add(var.set_aqi(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ECO2, var.set_co2)
await sensors(CONF_TVOC, var.set_tvoc)
await sensors(CONF_AQI, var.set_aqi)
if compensation_config := config.get(CONF_COMPENSATION):
sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE])
+3 -7
View File
@@ -50,10 +50,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature_sensor(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_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)
+25 -16
View File
@@ -16,6 +16,28 @@ namespace esphome::epaper_spi {
/** Delta for when to regard as gray */
static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */
constexpr uint8_t rec601_luma(Color color) {
return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8);
}
/** Map RGB color to a single monochrome bit
*
* @param color RGB color to convert from
* @return 1 = white, 0 = black
*/
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel)
*
* @param color RGB color to convert from
* @return Gray level: 0 = black, 3 = white
*/
constexpr uint8_t color_to_gray4(Color color) {
const uint8_t level = (uint8_t) ((rec601_luma(color) + 32u) >> 6); // quantize 0..255 to 0..3, rounded
return level > 3 ? 3 : level;
}
/** Map RGB color to discrete BWYR hex 4 color key
*
* @tparam NATIVE_COLOR Type of native hardware color values
@@ -25,7 +47,6 @@ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow,
@@ -38,11 +59,7 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
// It's a shade of gray. Map to BLACK or WHITE.
// We split the luminance at the halfway point (382 = (255*3)/2)
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
return color_to_mono(color) ? hw_white : hw_black;
}
// --- Step 2: Check for Primary/Secondary Colors ---
@@ -96,7 +113,6 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C
* @param hw_green Native value for green
* @param hw_blue Native value for blue
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
@@ -105,10 +121,7 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
return color_to_mono(color) ? hw_white : hw_black;
}
const bool r_on = (color.r > 128);
@@ -158,7 +171,6 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV
* @param hw_blue Native value for blue
* @param hw_orange Native value for orange
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
@@ -167,10 +179,7 @@ constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATI
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
return color_to_mono(color) ? hw_white : hw_black;
}
const bool r_on = (color.r > 128);
+36 -2
View File
@@ -1,7 +1,9 @@
from collections.abc import Callable
import importlib
import pkgutil
from typing import Any
from esphome import core, pins
from esphome import automation, core, pins
import esphome.codegen as cg
from esphome.components import display, spi
from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation
@@ -54,6 +56,12 @@ EPaperBase = epaper_spi_ns.class_(
)
Transform = epaper_spi_ns.enum("Transform")
automation.register_apply_action(
"epaper_spi.full_update_next",
automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}),
automation.ApplyCall("request_full_update()"),
)
# Import all models dynamically from the models package
for module_info in pkgutil.iter_modules(models.__path__):
importlib.import_module(f".models.{module_info.name}", package=__package__)
@@ -70,6 +78,23 @@ DIMENSION_SCHEMA = cv.Schema(
TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
def _full_update_every_validator(
model: models.EpaperModel,
) -> Callable[[Any], int]:
if model.get_default("partial_update"):
return cv.int_range(1, 255)
def validate(value: Any) -> int:
value = cv.int_range(1, 255)(value)
if value != 1:
raise cv.Invalid(
f"{model.name} does not support partial update; full_update_every must be 1"
)
return value
return validate
def model_schema(config):
model = MODELS[config[CONF_MODEL]]
class_name = epaper_spi_ns.class_(model.class_name, EPaperBase)
@@ -96,7 +121,9 @@ def model_schema(config):
cv.Required(CONF_MIRROR_Y): cv.boolean,
}
),
cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255),
cv.Optional(
CONF_FULL_UPDATE_EVERY, default=1
): _full_update_every_validator(model),
model.option(CONF_BUSY_PIN): pins.gpio_input_pin_schema,
model.option(CONF_CS_PIN): pins.gpio_output_pin_schema,
model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema,
@@ -132,8 +159,15 @@ def customise_schema(config):
extra=cv.ALLOW_EXTRA,
)(config)
model = MODELS[config[CONF_MODEL]]
model.check_requirements()
config = model_schema(config)(config)
config = model.validate_config(config)
width, height = model.get_dimensions(config)
if width % (width_multiple := model.get_default("width_multiple", 1)):
raise cv.Invalid(
f"{model.name} requires a width that is a multiple of {width_multiple}",
path=[CONF_DIMENSIONS],
)
display.add_metadata(
config[CONF_ID],
width,
+11 -2
View File
@@ -196,6 +196,15 @@ void EPaperBase::process_state_() {
break;
case EPaperState::UPDATE:
this->do_update_(); // Calls ESPHome (current page) lambda
if (this->full_update_requested_) {
// Refresh the whole panel even if nothing was drawn
this->full_update_requested_ = false;
this->update_count_ = 0;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
this->set_state_(EPaperState::IDLE);
return;
@@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) {
return;
const size_t byte_position = y * this->row_width_ + x / 8;
const uint8_t bit_position = x % 8;
const uint8_t pixel_bit = 0x80 >> bit_position;
const uint8_t pixel_bit = 0x80u >> bit_position;
const auto original = this->buffer_[byte_position];
if ((color_to_bit(color) == 0)) {
if (color_to_mono(color) == 0) {
this->buffer_[byte_position] = original & ~pixel_bit;
} else {
this->buffer_[byte_position] = original | pixel_bit;
+6 -10
View File
@@ -1,5 +1,6 @@
#pragma once
#include "colorconv.h"
#include "esphome/components/display/display.h"
#include "esphome/components/spi/spi.h"
#include "esphome/components/split_buffer/split_buffer.h"
@@ -80,15 +81,7 @@ class EPaperBase : public Display,
DisplayType get_display_type() override { return this->display_type_; };
// Default implementations for monochrome displays
static uint8_t color_to_bit(Color color) {
// It's always a shade of gray. Map to BLACK or WHITE.
// We split the luminance at a suitable point
if ((color.r + color.g + color.b) >= 382) {
return 1;
}
return 0;
}
// Default implementation for monochrome displays
void fill(Color color) override {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
@@ -96,7 +89,7 @@ class EPaperBase : public Display,
return;
}
auto pixel_color = color_to_bit(color) ? 0xFF : 0x00;
auto pixel_color = color_to_mono(color) ? 0xFF : 0x00;
// We store 8 pixels per byte
this->buffer_.fill(pixel_color);
@@ -114,6 +107,8 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
void draw_pixel_at(int x, int y, Color color) override;
// Make the next update a full one. Applied when that update starts, so one in progress is not affected.
void request_full_update() { this->full_update_requested_ = true; }
protected:
int get_height_internal() override { return this->height_; };
@@ -185,6 +180,7 @@ class EPaperBase : public Display,
uint8_t transform_{};
uint8_t effective_transform_{};
uint8_t update_count_{};
bool full_update_requested_{};
// these values represent the bounds of the updated buffer. Note that x_high and y_high
// point to the pixel past the last one updated, i.e. may range up to width/height.
uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{};
@@ -9,8 +9,8 @@ namespace esphome::epaper_spi {
class EPaperMono : public EPaperBase {
public:
EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) {
this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up
}
@@ -0,0 +1,101 @@
#include "epaper_spi_ssd1677.h"
#include <algorithm>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.ssd1677";
void EPaperSSD1677::setup() {
EPaperMono::setup();
if (!this->is_failed())
this->init_comparison_frame_();
}
void EPaperSSD1677::init_comparison_frame_() {
if (!this->is_using_partial_update_())
return;
if (!this->sent_.init(this->plane_row_length_() * this->height_)) {
ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas");
}
}
void EPaperSSD1677::plane_row(size_t y, uint8_t *out) {
const size_t row_length = this->plane_row_length_();
const size_t data_idx = y * row_length;
for (size_t i = 0; i != row_length; i++)
out[i] = this->buffer_[data_idx + i];
}
// Nothing a partial update needs is kept in controller RAM any more, so skip the reset for those.
bool EPaperSSD1677::reset() {
if (this->update_count_ != 0 && this->sent_.is_valid())
return true;
return EPaperMono::reset();
}
// The window always covers the whole panel, so each plane is the frame's bytes in order. Where
// those bytes are already stored as the plane needs them (the comparison frame, and a 1-bit
// buffer) they are written straight from the buffer, as many at a time as the time slice allows;
// otherwise they are built a row at a time by plane_row().
bool HOT EPaperSSD1677::transfer_data() {
if (!this->sent_.is_valid())
return EPaperMono::transfer_data();
const auto start_time = millis();
if (this->current_data_index_ == 0) {
if (this->plane_ == 0) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
}
this->set_window();
this->command(this->plane_ == 0 ? 0x26 : 0x24);
}
// A full update ignores 0x26, and the copy may not hold a real frame yet (first update after
// boot): send the new frame to both planes.
const bool send_copy = this->plane_ == 0 && this->update_count_ != 0;
const bool direct = send_copy || this->buffer_is_plane();
const auto &source = send_copy ? this->sent_ : this->buffer_;
const size_t row_length = this->plane_row_length_();
const size_t plane_length = row_length * this->height_;
// Roughly what the bus moves in one time slice, so a slice is not overrun by much
const size_t max_chunk = std::max<size_t>(this->data_rate_ / 8000 * MAX_TRANSFER_TIME, MAX_TRANSFER_SIZE);
SmallBufferWithHeapFallback<128> row_alloc(direct ? 0 : row_length);
this->start_data_();
while (this->current_data_index_ != plane_length) {
size_t length;
const uint8_t *data;
if (direct) {
data = source.get_span(this->current_data_index_, length);
length = std::min(length, max_chunk);
} else {
// Always at the start of a row here, since this path sends whole rows only
this->plane_row(this->current_data_index_ / row_length, row_alloc.get());
data = row_alloc.get();
length = row_length;
}
this->write_array(data, length);
if (this->plane_ == 1)
this->sent_.write(this->current_data_index_, data, length);
this->current_data_index_ += length;
if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
return false;
}
}
this->disable();
this->current_data_index_ = 0;
if (this->plane_ == 0) {
this->plane_ = 1;
return false;
}
this->plane_ = 0;
return true;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,49 @@
#pragma once
#include "epaper_spi_mono.h"
namespace esphome::epaper_spi {
/**
* Monochrome SSD1677 with partial refreshes that leave unchanged pixels alone.
*
* A partial refresh drives each pixel from the pair (RAM 0x26 = the image on the panel,
* RAM 0x24 = the new image) across the whole panel; the RAM window only scopes a write.
* EPaperMono writes 0x26 once and afterwards only the changed window of 0x24, which relies on
* the controller's RAM being unchanged from one update to the next. On this controller it is not:
* the hardware reset at the start of each update loses it, and even without resets, keeping 0x26
* in step one window at a time left unchanged areas alternating between older frames. Either way
* unchanged pixels get driven on every partial and wash out.
*
* So before every refresh this class writes both planes over the whole panel: 0x26 from a copy of
* the frame last sent, 0x24 from the buffer. GxEPD2 likewise rewrites both planes after each
* partial on this controller. The copy is taken as the data goes out, not from the buffer, which
* may already hold the next frame by the time the refresh completes.
*/
class EPaperSSD1677 : public EPaperMono {
public:
EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY)
: EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {}
void setup() override;
protected:
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
// can run without a bus.
void init_comparison_frame_();
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
// Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is.
// A subclass with a deeper buffer returns false and overrides plane_row().
virtual bool buffer_is_plane() const { return true; }
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
virtual void plane_row(size_t y, uint8_t *out);
bool reset() override;
bool transfer_data() override;
split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,129 @@
#include "epaper_spi_ssd1677_gray4.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.ssd1677_gray4";
// Combine two source bytes (4 pixels each, 2 bits per pixel, most significant pixel first) into
// one plane byte covering the same 8 pixels (1 bit per pixel), choosing high or low bit
static uint8_t plane_byte(uint8_t first, uint8_t second, bool high_bit) {
uint8_t out = 0;
for (const uint8_t src : {first, second}) {
for (uint8_t shift = 6;; shift -= 2) {
const uint8_t level = (src >> shift) & 0x03;
out = (uint8_t) ((out << 1) | (high_bit ? (level >> 1) : (level & 1)));
if (shift == 0)
break;
}
}
return out;
}
void EPaperSSD1677Gray4::fill(Color color) {
if (this->get_clipping().is_set()) {
// Falls back to the generic per-pixel implementation for the clipped rectangle.
EPaperBase::fill(color);
return;
}
const uint8_t level = color_to_gray4(color);
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
const uint8_t level = color_to_gray4(color);
const size_t byte_position = (size_t) y * this->row_width_ + x / 4;
const uint8_t shift = (uint8_t) (6 - 2 * (x % 4)); // most significant pixel first
const uint8_t original = this->buffer_[byte_position];
this->buffer_[byte_position] = (uint8_t) ((original & ~(0x03 << shift)) | (level << shift));
}
// A partial update reduces each pixel to its high bit: levels 2 and 3 are light, 0 and 1 dark.
void EPaperSSD1677Gray4::plane_row(size_t y, uint8_t *out) {
const size_t src_row = y * this->row_width_;
for (size_t i = 0; i != this->plane_row_length_(); i++)
out[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], true);
}
// the high bit of every pixel's level goes to the new (bw) plane
// (0x24), the low bit to the old (red) plane (0x26)
bool HOT EPaperSSD1677Gray4::transfer_data() {
if (this->is_partial_push_())
return EPaperSSD1677::transfer_data();
auto start_time = millis();
const bool first_pass = this->send_red_;
if (this->current_data_index_ == 0) {
if (first_pass) {
// With partial updates enabled the window follows the changed area, but the four-level
// refresh drives every pixel from both planes, and the reset before it does not keep RAM.
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
this->set_window();
}
this->command(first_pass ? 0x24 : 0x26);
this->current_data_index_ = this->y_low_;
}
const size_t plane_row_length = (this->x_high_ - this->x_low_) / 8;
// Stack-backed for every panel width in practice; only a custom `dimensions:` far wider than any
// supported panel would fall back to the heap.
SmallBufferWithHeapFallback<128> bytes_to_send_alloc(plane_row_length);
uint8_t *bytes_to_send = bytes_to_send_alloc.get();
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", plane_row_length, this->current_data_index_,
(unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4;
for (size_t i = 0; i != plane_row_length; i++) {
const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
// The OTP grayscale waveform treats data as inverted relative to monochrome
bytes_to_send[i] = (uint8_t) ~plane;
// What the next partial update compares against: the high bits, as a black-and-white frame.
if (first_pass && this->sent_.is_valid())
this->sent_[this->current_data_index_ * plane_row_length + i] = plane;
}
++this->current_data_index_;
this->write_array(bytes_to_send, plane_row_length);
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
return false;
}
}
this->disable();
this->current_data_index_ = 0;
if (first_pass) {
this->send_red_ = false;
return false;
}
this->send_red_ = true;
return true;
}
void EPaperSSD1677Gray4::refresh_screen(bool partial) {
if (this->is_partial_push_()) {
ESP_LOGV(TAG, "Black-and-white partial refresh");
// The border follows the LUT selected in 0x3C. The model's setting (sent with the init
// sequence) picks the LUT that is white under the four-level waveform's inverted data; under
// the black-and-white waveform that LUT drives black and the border darkens, so use LUT1.
this->cmd_data(0x3C, {0x01});
EPaperSSD1677::refresh_screen(true);
return;
}
ESP_LOGV(TAG, "Four-level refresh");
this->cmd_data(0x1A, {0x67, 0x00}); // force temperature by OTP
this->cmd_data(0x22, {0xD7}); // four-level update sequence, panel's OTP waveform
this->command(0x20); // master activation
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,49 @@
#pragma once
#include "colorconv.h"
#include "epaper_spi_ssd1677.h"
namespace esphome::epaper_spi {
/**
* Four-level grayscale for SSD1677 panels.
*
* The SSD1677 has two independent 1-bit RAM planes, normally used for a
* black/white and a red plane. This class writes image data to both,
* splitting each pixel's 2-bit gray level across them, and triggers the
* panel's own OTP grayscale waveform instead of the normal monochrome update
* sequence. No custom LUT upload needed for any currently supported panel,
* the OTP waveform is used instead.
*
* The framebuffer therefore packs 2 bits per pixel (4 per byte, most
* significant pixel first) instead of EPaperMono's 1 bit.
*
* The grayscale waveform has no partial form: master activation redraws the whole panel
* regardless of the RAM window. So a full update is a four-level refresh, and when partial updates
* are enabled (full_update_every > 1, which the model only allows on explicit request) a partial
* update is EPaperSSD1677's black-and-white one, each pixel reduced to light or dark. The partial
* waveform also drives unchanged pixels towards black or white, so from the first partial update
* the whole panel loses its gray levels until the next full update.
*/
class EPaperSSD1677Gray4 : public EPaperSSD1677 {
public:
EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) {
this->row_width_ = (width + 3) / 4; // 4 pixels per byte
this->buffer_length_ = (size_t) this->row_width_ * height;
}
void fill(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
protected:
// A partial update if partial updates are enabled and this is not a full one.
bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); }
bool buffer_is_plane() const override { return false; }
void plane_row(size_t y, uint8_t *out) override;
void refresh_screen(bool partial) override;
bool transfer_data() override;
};
} // namespace esphome::epaper_spi
@@ -2,7 +2,9 @@ from typing import Any, Self
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
class EpaperModel:
@@ -48,6 +50,16 @@ class EpaperModel:
"""
return {}
def validate_config(self, config: ConfigType) -> ConfigType:
"""
Validate the configuration as a whole, once the schema has been applied.
The base implementation accepts it unchanged; specific models override this for
rules that span several options.
:param config: The validated configuration
:return: The configuration, possibly updated
"""
return config
async def to_code(self, var: MockObj, config: dict) -> dict:
"""
Generate model-specific code for the options added by add_options().
@@ -89,3 +101,26 @@ class EpaperModel:
defaults = self.defaults.copy()
defaults.update(kwargs)
return self.__class__(name, initsequence=tuple(initsequence), **defaults)
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
@@ -10,7 +10,9 @@ from . import EpaperModel
class JD79660(EpaperModel):
def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs):
super().__init__(name, class_name, **kwargs)
# Only a fast_update sequence lets the driver do anything but a full refresh
kwargs.setdefault("partial_update", fast_update is not None)
super().__init__(name, class_name=class_name, **kwargs)
self.fast_update = fast_update
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
@@ -1,12 +1,56 @@
from esphome.const import CONF_DATA_RATE
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DATA_RATE, CONF_FULL_UPDATE_EVERY
from esphome.types import ConfigType
from . import EpaperModel
CONF_BORDER_WAVEFORM = "border_waveform"
CONF_MONOCHROME_PARTIAL_UPDATES = "monochrome_partial_updates"
# partial_update value for models whose partial updates are black and white only
MONOCHROME = "monochrome"
class SSD1677(EpaperModel):
def __init__(self, name, class_name="EPaperMono", data_rate="20MHz", **defaults):
def __init__(
self,
name: str,
class_name: str = "EPaperSSD1677",
data_rate: str = "20MHz",
border_waveform: int = 0x01,
**defaults: Any,
) -> None:
defaults[CONF_DATA_RATE] = data_rate
super().__init__(name, class_name, **defaults)
defaults[CONF_BORDER_WAVEFORM] = border_waveform
defaults.setdefault("partial_update", True)
super().__init__(name, class_name=class_name, **defaults)
def get_config_options(self) -> dict:
options = {
self.option(CONF_BORDER_WAVEFORM): cv.hex_uint8_t,
}
if self.get_default("partial_update") == MONOCHROME:
options[cv.Optional(CONF_MONOCHROME_PARTIAL_UPDATES, default=False)] = (
cv.boolean
)
return options
def validate_config(self, config: ConfigType) -> ConfigType:
if (
self.get_default("partial_update") == MONOCHROME
and config[CONF_FULL_UPDATE_EVERY] > 1
and not config[CONF_MONOCHROME_PARTIAL_UPDATES]
):
raise cv.Invalid(
f"{self.name} can only update partially in black and white, and a partial "
"update reduces the whole panel to black and white until the next full "
f"update. Set '{CONF_MONOCHROME_PARTIAL_UPDATES}: true' to accept this, "
"or leave full_update_every at 1",
path=[CONF_FULL_UPDATE_EVERY],
)
return config
# fmt: off
def get_init_sequence(self, config: dict):
@@ -15,13 +59,14 @@ class SSD1677(EpaperModel):
(0x18, 0x80), # Select internal Temp sensor
(0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2
(0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1)
(0x3C, 0x01), # Set border waveform
(0x3C, config[CONF_BORDER_WAVEFORM]), # Set border waveform
(0x11, 3), # Set transform
)
ssd1677 = SSD1677("ssd1677")
wave_4_26 = ssd1677.extend(
"waveshare-4.26in",
width=800,
@@ -52,7 +97,8 @@ ssd1677.extend(
mirror_x=True,
)
ssd1677.extend(
# Sticky - monochrome version
seeed_sticky = ssd1677.extend(
"seeed-reterminal-sticky",
width=800,
height=480,
@@ -63,4 +109,15 @@ ssd1677.extend(
reset_pin=17,
busy_pin=18,
data_rate="10MHz",
requires={"psram"},
)
# Sticky - 4 level grayscale; partial updates only in black and white, on request
seeed_sticky.extend(
"seeed-reterminal-sticky-gray4",
class_name="EPaperSSD1677Gray4",
border_waveform=0x00,
partial_update=MONOCHROME,
# each plane byte is built from two whole buffer bytes
width_multiple=8,
)
@@ -6,7 +6,8 @@ from . import EpaperModel
class SSD1683(EpaperModel):
def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults):
defaults[CONF_DATA_RATE] = data_rate
super().__init__(name, class_name, **defaults)
defaults.setdefault("partial_update", True)
super().__init__(name, class_name=class_name, **defaults)
# fmt: off
def get_init_sequence(self, config: dict):
@@ -32,7 +32,8 @@ class UC8179(EpaperModel):
**defaults: Any,
) -> None:
defaults.setdefault(CONF_DATA_RATE, data_rate)
super().__init__(name, class_name, **defaults)
defaults.setdefault("partial_update", True)
super().__init__(name, class_name=class_name, **defaults)
def get_init_sequence(self, config: dict) -> tuple:
"""Generate the initialization sequence for UC8179 mono displays.
@@ -6,8 +6,12 @@ from . import EpaperModel
class WaveshareModel(EpaperModel):
def __init__(self, name, lut, lut_partial=None, **defaults):
super().__init__(name, "EpaperWaveshare", **defaults)
def __init__(
self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults
):
# A partial LUT is what lets EpaperWaveshare do partial refresh
defaults.setdefault("partial_update", lut_partial is not None)
super().__init__(name, class_name=class_name, **defaults)
self.lut = lut
self.lut_partial = lut_partial
+10 -16
View File
@@ -30,19 +30,13 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_ES8388_ID])
if dac_output_config := config.get(CONF_DAC_OUTPUT):
s = await select.new_select(
dac_output_config,
options=["LINE1", "LINE2", "BOTH"],
)
await cg.register_parented(s, parent)
cg.add(parent.set_dac_output_select(s))
if adc_input_mic_config := config.get(CONF_ADC_INPUT_MIC):
s = await select.new_select(
adc_input_mic_config,
options=["LINE1", "LINE2", "DIFFERENCE"],
)
await cg.register_parented(s, parent)
cg.add(parent.set_adc_input_mic_select(s))
hub = await cg.get_variable(config[CONF_ES8388_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"]
)
await selects(
CONF_ADC_INPUT_MIC,
hub.set_adc_input_mic_select,
options=["LINE1", "LINE2", "DIFFERENCE"],
)
+52 -13
View File
@@ -322,6 +322,34 @@ ARDUINO_EXCLUDED_IDF_COMPONENTS = (
"joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem
)
# Entries arduino-esp32 only declares below the given IDF version; stubbing one past
# it clashes with ESPHome's own managed component of the same short name.
ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF: dict[str, cv.Version] = {
"espressif__libsodium": cv.Version(6, 0, 0),
}
def arduino_bundles_libsodium() -> bool:
"""arduino-esp32 ships its own libsodium below IDF 6.0."""
return (
CORE.using_arduino
and idf_version()
< ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF["espressif__libsodium"]
)
def arduino_excluded_idf_components() -> set[str]:
"""The arduino-bundled components to stub for this build's IDF version."""
version = idf_version()
return {
component
for component in ARDUINO_EXCLUDED_IDF_COMPONENTS
if (max_version := ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF.get(component))
is None
or version < max_version
}
# Mapping of Arduino libraries to IDF managed components they require
# When an Arduino library is enabled via cg.add_library(), these components
# are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS.
@@ -642,20 +670,29 @@ def get_download_types(storage_json):
# No recorded firmware path means nothing was built; no downloads.
if storage_json.firmware_bin_path is None:
return []
return [
{
"title": "Factory format (Previously Modern)",
"description": "For use with ESPHome Web and other tools.",
"file": "firmware.factory.bin",
"download": f"{storage_json.name}.factory.bin",
},
from esphome.espidf.toolchain import tree_skips_bootloader
types = []
# A --skip-bootloader tree deliberately has no factory image; an
# unreadable tree (PlatformIO, capability probes) reads as full.
if not tree_skips_bootloader(Path(storage_json.firmware_bin_path).parent):
types.append(
{
"title": "Factory format (Previously Modern)",
"description": "For use with ESPHome Web and other tools.",
"file": "firmware.factory.bin",
"download": f"{storage_json.name}.factory.bin",
}
)
types.append(
{
"title": "OTA format (Previously Legacy)",
"description": "For OTA updating a device.",
"file": "firmware.ota.bin",
"download": f"{storage_json.name}.ota.bin",
},
]
}
)
return types
def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This feature"):
@@ -3513,9 +3550,7 @@ def _write_idf_component_yml():
}
# Only stub components that are not required by any enabled Arduino library
components_to_stub = (
set(ARDUINO_EXCLUDED_IDF_COMPONENTS) - required_idf_components
)
components_to_stub = arduino_excluded_idf_components() - required_idf_components
stubs_dir = CORE.relative_build_path("component_stubs")
stubs_dir.mkdir(exist_ok=True)
@@ -3580,7 +3615,11 @@ def _write_idf_component_yml():
# Don't process arduino libraries
if name not in ARDUINO_DISABLED_LIBRARIES
]
for component in generate_idf_components(libraries):
# A library also declared as a managed component is not converted too, or
# IDF sees the same requirement twice; converted components reach it through
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
for component in generate_idf_components(libraries, managed=managed):
dependencies[component.get_sanitized_name()] = {
"override_path": str(component.path)
}
+1 -1
View File
@@ -30,7 +30,7 @@ static const gpio_hal_context_t GPIO_HAL = {.dev = GPIO_HAL_GET_HW(GPIO_PORT_0)}
bool ESP32InternalGPIOPin::isr_service_installed = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static gpio_mode_t flags_to_mode(gpio::Flags flags) {
flags = (gpio::Flags)(flags & ~(gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN));
flags = flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN);
if (flags == gpio::FLAG_INPUT)
return GPIO_MODE_INPUT;
if (flags == gpio::FLAG_OUTPUT)
+7 -2
View File
@@ -474,12 +474,17 @@ def final_validation(config: ConfigType) -> None:
# Check if hosted bluetooth is being used
if "esp32_hosted" in full_config:
from esphome.components.esp32_hosted import uses_esp_hosted_3x
add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False)
add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True)
if uses_esp_hosted_3x():
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True)
else:
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True)
# Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client)
has_ble_client = (
+34 -10
View File
@@ -6,16 +6,21 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#else
#include "esphome/components/watchdog/watchdog.h"
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <esp_hosted_misc.h>
#include <esp_hosted_bluedroid.h>
#endif
}
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <esp_hosted_bt_host_stack.h>
#endif
#endif
#include <esp_bt_device.h>
#include <esp_bt_main.h>
@@ -35,7 +40,7 @@ namespace esphome::esp32_ble {
static const char *const TAG = "esp32_ble";
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifdef CONFIG_BT_CONTROLLER_DISABLED
// Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog
// is also 5 seconds. If the co-processor firmware does not answer (for example
@@ -43,8 +48,9 @@ static const char *const TAG = "esp32_ble";
// device before the RPC could return an error, causing a boot loop. Raise the
// watchdog for the duration of the bring-up so failures surface as error
// returns instead. 60 seconds covers the worst case: transport reconnect
// (up to ~20s), version preflight (1s), controller init/enable (5s each) and
// the bluedroid host bring-up over the hosted HCI transport.
// (up to ~20s), version preflight (1s), the controller init retry window
// (5s of 5s RPCs), controller enable (5s) and the bluedroid host bring-up
// over the hosted HCI transport.
static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
#endif
@@ -202,10 +208,10 @@ void ESP32BLE::advertising_init_() {
bool ESP32BLE::ble_setup_() {
esp_err_t err;
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifdef CONFIG_BT_CONTROLLER_DISABLED
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) {
// start bt controller
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_IDLE) {
@@ -239,8 +245,7 @@ bool ESP32BLE::ble_setup_() {
}
// Fast preflight (1 second RPC timeout): verifies the co-processor answers
// RPCs at all before the 5 second timeout BT controller RPCs below, and
// before hosted_hci_bluedroid_open(), which aborts if the transport is down.
// RPCs at all before the 5 second timeout BT controller RPCs below.
esp_hosted_coprocessor_fwver_t fw_ver{};
if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) {
ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted "
@@ -249,6 +254,19 @@ bool ESP32BLE::ble_setup_() {
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
esp_hosted_bt_host_stack_cfg_t bt_cfg{};
bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID;
bt_cfg.bring_up_controller = true;
bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS;
if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) {
ESP_LOGE(TAG,
"BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
#else
if (esp_hosted_bt_controller_init() != ESP_OK) {
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
@@ -273,6 +291,7 @@ bool ESP32BLE::ble_setup_() {
.register_host_callback = hosted_hci_bluedroid_register_host_callback,
};
esp_bluedroid_attach_hci_driver(&operations);
#endif
#endif
err = esp_bluedroid_init();
@@ -393,7 +412,7 @@ bool ESP32BLE::ble_setup_() {
}
bool ESP32BLE::ble_dismantle_() {
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifdef CONFIG_BT_CONTROLLER_DISABLED
// Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
@@ -416,7 +435,7 @@ bool ESP32BLE::ble_dismantle_() {
ESP_LOGD(TAG, "Already deinitialized");
}
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_IDLE) {
// stop bt controller
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED) {
@@ -440,6 +459,11 @@ bool ESP32BLE::ble_dismantle_() {
return false;
}
}
#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT)
if (esp_hosted_bt_host_stack_teardown() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed");
return false;
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
@@ -3,7 +3,7 @@
#ifdef USE_ESP32
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <esp_bt_main.h>
@@ -35,7 +35,7 @@ void ESP32BLEBeacon::dump_config() {
}
}
*bpos = '\0';
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
ESP_LOGCONFIG(TAG,
" UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d"
", TX Power: %ddBm",
@@ -81,7 +81,7 @@ void ESP32BLEBeacon::on_advertise_() {
ibeacon_adv_data.ibeacon_vendor.measured_power = static_cast<uint8_t>(this->measured_power_);
esp_err_t err;
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
ESP_LOGD(TAG, "Setting BLE TX power");
err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
if (err != ESP_OK) {
@@ -5,7 +5,7 @@
#ifdef USE_ESP32
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <esp_gap_ble_api.h>
@@ -47,7 +47,7 @@ class ESP32BLEBeacon final : public Component {
void set_min_interval(uint16_t val) { this->min_interval_ = val; }
void set_max_interval(uint16_t val) { this->max_interval_ = val; }
void set_measured_power(int8_t val) { this->measured_power_ = val; }
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; }
#endif
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
@@ -61,7 +61,7 @@ class ESP32BLEBeacon final : public Component {
uint16_t min_interval_{};
uint16_t max_interval_{};
int8_t measured_power_{};
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
esp_power_level_t tx_power_{};
#endif
esp_ble_adv_params_t ble_adv_params_;
@@ -10,7 +10,7 @@
#include <nvs_flash.h>
#include <freertos/FreeRTOSConfig.h>
#include <esp_bt_main.h>
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <freertos/task.h>
@@ -436,6 +436,25 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESP32_BLE_SOFTWARE_COEXISTENCE")
# First tagged release per series with espressif/esp-idf@82e71c1767 (see bluedroid_stubs.cpp).
# A series without an entry keeps the guard until a fixed release is tagged; the guard is
# harmless on fixed sources. The 5.4, 5.5 and 6.1 branches carry the fix but have no tag yet.
DIRECT_CONN_FIX_VERSIONS = {
(5, 2): cv.Version(5, 2, 8),
(5, 3): cv.Version(5, 3, 6),
(6, 0): cv.Version(6, 0, 3),
}
DIRECT_CONN_FIX_ALL_FROM = cv.Version(6, 2, 0)
def _needs_direct_conn_guard() -> bool:
ver = idf_version()
if ver >= DIRECT_CONN_FIX_ALL_FROM:
return False
fixed = DIRECT_CONN_FIX_VERSIONS.get((ver.major, ver.minor))
return fixed is None or ver < fixed
# This needs to be run as a job with very low priority so that all components have
# chance to call register_ble_tracker and register_client before the list is checked
# and added to the global defines list.
@@ -452,6 +471,11 @@ async def _add_ble_features() -> None:
if BLEFeatures.ESP_BT_DEVICE in required_features:
cg.add_define("USE_ESP32_BLE_DEVICE")
cg.add_define("USE_ESP32_BLE_UUID")
if cg.get_slot_count(CLIENT_COUNT_DEFINE) and _needs_direct_conn_guard():
# --undefined keeps the wrapper, libsrc.a is scanned before the IDF libraries
cg.add_define("USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD")
cg.add_build_flag("-Wl,--wrap=l2cble_init_direct_conn")
cg.add_build_flag("-Wl,--undefined=__wrap_l2cble_init_direct_conn")
ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema(
@@ -0,0 +1,48 @@
/*
* Bluedroid queues outgoing BLE connections as raw link block pointers and does
* not drop them when the block is released, so btm_send_pending_direct_conn()
* can start a connect on a released block and l2c_link_timeout() later crashes
* on its null timer parameter. Mirrors espressif/esp-idf@82e71c1767; codegen
* only enables it for releases without that commit.
*/
#include "esphome/core/defines.h"
#ifdef USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
#include <esp_idf_version.h>
#include <cstdint>
#include "esphome/core/log.h"
namespace esphome::esp32_ble_tracker {
static const char *const TAG = "esp32_ble_tracker";
} // namespace esphome::esp32_ble_tracker
static_assert(ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 2, 0),
"ESP-IDF 6.2 and later have the fix, this guard should not be enabled (esphome/esphome#19373)");
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
bool __real_l2cble_init_direct_conn(void *p_lcb);
void l2cu_release_lcb(void *p_lcb);
bool __wrap_l2cble_init_direct_conn(void *p_lcb) {
// in_use is the first member of the private tL2C_LCB (checked ESP-IDF 5.0 to 6.1)
const auto *in_use = static_cast<const uint8_t *>(p_lcb);
if (p_lcb == nullptr || *in_use == 0) {
ESP_LOGW(esphome::esp32_ble_tracker::TAG, "Dropped queued connect on a released link block");
return false;
}
const bool started = __real_l2cble_init_direct_conn(p_lcb);
// Every failure path releases the block except unknown device, also fixed upstream
if (!started && *in_use != 0) {
l2cu_release_lcb(p_lcb);
}
return started;
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
@@ -7,7 +7,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <esp_bt_defs.h>
@@ -635,7 +635,7 @@ void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
void ESP32BLETracker::update_coex_preference_(bool force_ble) {
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
if (force_ble && !this->coex_prefer_ble_) {
ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection.");
this->coex_prefer_ble_ = true;
@@ -645,7 +645,7 @@ void ESP32BLETracker::update_coex_preference_(bool force_ble) {
this->coex_prefer_ble_ = false;
esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default
}
#endif // CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#endif // CONFIG_BT_CONTROLLER_DISABLED
}
#endif
+195 -24
View File
@@ -1,5 +1,6 @@
import os
from pathlib import Path
import re
from esphome import pins
from esphome.components import esp32
@@ -7,15 +8,22 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
CONF_COMPONENTS,
CONF_CS_PIN,
CONF_FRAMEWORK,
CONF_FREQUENCY,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_NAME,
CONF_REF,
CONF_RESET_PIN,
CONF_TYPE,
CONF_VARIANT,
KEY_ESP32,
)
from esphome.core import CORE
from esphome.cpp_generator import add_define
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@swoboda1337"]
@@ -23,6 +31,9 @@ DEPENDENCIES = ["esp32"]
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5)
ESP_HOSTED_COMPONENT = "espressif/esp_hosted"
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
CONF_CMD_PIN = "cmd_pin"
@@ -145,6 +156,34 @@ CONFIG_SCHEMA = cv.typed_schema(
)
# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x
_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$")
def user_esp_hosted_major() -> int | None:
"""Major version of a user-pinned esp_hosted, or None without a pin."""
try:
full_config = fv.full_config.get()
except LookupError:
full_config = CORE.config
esp32_config = full_config.get(KEY_ESP32) or {}
for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []):
if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT:
continue
ref = component.get(CONF_REF, "").strip()
if not ref:
return 2
if match := _PINNED_MAJOR.match(ref):
return int(match.group(1))
return 3
return None
def uses_esp_hosted_3x() -> bool:
"""Whether the user pinned esp_hosted 3.x."""
return (major := user_esp_hosted_major()) is not None and major >= 3
def _final_validate(config: ConfigType) -> None:
# The esp_hosted releases compatible with older ESP-IDF versions crash at
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
@@ -155,12 +194,34 @@ def _final_validate(config: ConfigType) -> None:
"Remove the framework version from your configuration to use the "
"recommended version, or pin a version at or above 5.3."
)
if (major := user_esp_hosted_major()) is None or major < 3:
return
if idf_ver < cv.Version(5, 5, 0):
raise cv.Invalid(
f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. "
f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework."
"components to stay on the 2.x line, or use ESP-IDF 5.5 or newer."
)
if not config[CONF_ACTIVE_HIGH]:
raise cv.Invalid(
"esp_hosted 3.x always parks the reset line high with a low pulse, so "
"'active_high: false' cannot be expressed on it. Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line."
)
if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1:
raise cv.Invalid(
"esp_hosted 3.x cannot be built with a 1-bit SDIO bus "
"(espressif/esp-hosted-mcu#245). Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line, or use a 4-bit bus."
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _configure_sdio(config: ConfigType) -> None:
def _configure_sdio_2x(config: ConfigType) -> None:
slot = config[CONF_SLOT]
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}",
@@ -202,7 +263,42 @@ def _configure_sdio(config: ConfigType) -> None:
)
def _configure_spi(config: ConfigType) -> None:
def _configure_sdio_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}",
True,
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_HOST_SDIO_BUS_WIDTH_{config[CONF_BUS_WIDTH]}",
True,
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CLK", config[CONF_CLK_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CMD", config[CONF_CMD_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN]
)
if config[CONF_BUS_WIDTH] == 4:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D3", config[CONF_D3_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_CLK_KHZ",
int(config[CONF_SDIO_FREQUENCY] // 1000),
)
def _configure_spi_2x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True)
# SPI mode is set via per-variant choice options
variant = config[CONF_VARIANT]
@@ -250,11 +346,53 @@ def _configure_spi(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True)
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
transport = config[CONF_TYPE]
transport_prefix = "SDIO" if transport == "sdio" else "SPI"
def _configure_spi_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_CLK_MHZ", int(config[CONF_FREQUENCY] // 1e6)
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MOSI_GPIO", config[CONF_MOSI_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MISO_GPIO", config[CONF_MISO_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_CLK_GPIO", config[CONF_CLK_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_CS_GPIO", config[CONF_CS_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_HANDSHAKE_GPIO", config[CONF_HANDSHAKE_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_DATA_READY_GPIO", config[CONF_DATA_READY_PIN]
)
# Handshake and data_ready polarity
if config[CONF_HANDSHAKE_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_HIGH", True
)
else:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_LOW", True
)
if config[CONF_DATA_READY_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_HIGH", True
)
else:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_LOW", True
)
def _configure_2x(config: ConfigType) -> None:
transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI"
# Reset polarity
if config[CONF_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
@@ -274,24 +412,11 @@ async def to_code(config: ConfigType) -> None:
f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT
True,
)
# Transport-specific configuration
if transport == "sdio":
_configure_sdio(config)
if config[CONF_TYPE] == "sdio":
_configure_sdio_2x(config)
else:
_configure_spi(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
# esp_now_* symbols/CustomRpc calls it defines require these Kconfig options
# to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so
# coupling the two keeps the build consistent. When `espnow` is absent the
# symbols are simply unused and never register a callback at runtime.
_configure_spi_2x(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
add_define("USE_ESP_NOW_HOSTED")
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
@@ -299,18 +424,64 @@ async def to_code(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
def _configure_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
)
# Co-processor variant
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}",
True,
)
if config[CONF_TYPE] == "sdio":
_configure_sdio_3x(config)
else:
_configure_spi_3x(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA_MAX_CUSTOM_MSG_HANDLERS",
_MAX_CUSTOM_MSG_HANDLERS,
)
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True)
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
use_3x = uses_esp_hosted_3x()
if use_3x:
_configure_3x(config)
else:
_configure_2x(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
# esp_now_* symbols/CustomRpc calls it defines require the peer-data Kconfig
# options (set above) to link whenever esp_now_hosted.cpp compiles (which is
# on any P4 host), so coupling the two keeps the build consistent. When
# `espnow` is absent the symbols are simply unused and never register a
# callback at runtime.
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
add_define("USE_ESP_NOW_HOSTED")
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# Library versions; this component set requires ESP-IDF 5.3 or newer,
# which is enforced at validation time.
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13")
@@ -26,6 +26,8 @@ static const char *const TAG = "esp32_hosted.update";
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
constexpr size_t CHUNK_SIZE = 1500;
// OTA begin blocks while the coprocessor erases its partition
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// Interval/timeout IDs (uint32_t to avoid string comparison)
@@ -336,7 +338,11 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size);
// Begin OTA on coprocessor
esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT
esp_err_t err;
{
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
err = esp_hosted_slave_ota_begin(); // NOLINT
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err));
container->end();
@@ -425,7 +431,11 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_);
esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT
esp_err_t err;
{
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
err = esp_hosted_slave_ota_begin(); // NOLINT
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err));
this->status_set_error(LOG_STR("Failed to begin OTA"));
@@ -472,8 +482,6 @@ void Esp32HostedUpdate::perform(bool force) {
this->update_info_.has_progress = false;
this->publish_state();
watchdog::WatchdogManager watchdog(60000);
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
if (!this->stream_firmware_to_coprocessor_())
#else

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