From 9adb7e92e1fa84c1f6215bdb842ec860ebe15d93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:26 -1000 Subject: [PATCH 01/79] [ethernet] Collapse duplicate CI test variants (#20406) --- .../ethernet/common-w5500-spi-id.yaml | 17 -------- .../ethernet/test-ip101.esp32-idf.yaml | 7 +++- .../ethernet/test-ipv6.esp32-idf.yaml | 12 ------ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ------------------- .../ethernet/test-w5500-spi-id.esp32-idf.yaml | 3 -- .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ------- .../ethernet/test-w5500.esp32-idf.yaml | 37 +++++++++++++++--- .../ethernet/test.esp32-s3-idf.yaml | 16 +++++++- ...aml => validate-ksz8081rna.esp32-idf.yaml} | 0 ...date-lan8720-with-expander.esp32-idf.yaml} | 0 .../validate-w5500-interface.esp32-idf.yaml | 12 ++++++ 11 files changed, 65 insertions(+), 93 deletions(-) delete mode 100644 tests/components/ethernet/common-w5500-spi-id.yaml delete mode 100644 tests/components/ethernet/test-ipv6.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml rename tests/components/ethernet/{test-ksz8081rna.esp32-idf.yaml => validate-ksz8081rna.esp32-idf.yaml} (100%) rename tests/components/ethernet/{test-lan8720-with-expander.esp32-idf.yaml => validate-lan8720-with-expander.esp32-idf.yaml} (100%) create mode 100644 tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml diff --git a/tests/components/ethernet/common-w5500-spi-id.yaml b/tests/components/ethernet/common-w5500-spi-id.yaml deleted file mode 100644 index 14c01842f0c..00000000000 --- a/tests/components/ethernet/common-w5500-spi-id.yaml +++ /dev/null @@ -1,17 +0,0 @@ -ethernet: - type: W5500 - spi_id: spi_bus - cs_pin: 5 - interrupt_pin: 36 - reset_pin: 22 - clock_speed: 10Mhz - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - domain: .local - mac_address: "02:AA:BB:CC:DD:01" - on_connect: - - logger.log: "Ethernet connected!" - on_disconnect: - - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ip101.esp32-idf.yaml b/tests/components/ethernet/test-ip101.esp32-idf.yaml index e52329d7ea2..628b63d4e97 100644 --- a/tests/components/ethernet/test-ip101.esp32-idf.yaml +++ b/tests/components/ethernet/test-ip101.esp32-idf.yaml @@ -1 +1,6 @@ -<<: !include common-ip101.yaml +packages: + ethernet: !include common-ip101.yaml + +# IPv6 on the internal EMAC: compiles the IPv6 event handler and multicast filter +network: + enable_ipv6: true diff --git a/tests/components/ethernet/test-ipv6.esp32-idf.yaml b/tests/components/ethernet/test-ipv6.esp32-idf.yaml deleted file mode 100644 index 607e82dfa94..00000000000 --- a/tests/components/ethernet/test-ipv6.esp32-idf.yaml +++ /dev/null @@ -1,12 +0,0 @@ -network: - enable_ipv6: true - -ethernet: - type: IP101 - mdc_pin: 23 - mdio_pin: 18 - clk: - pin: 0 - mode: CLK_EXT_IN - phy_addr: 1 - power_pin: 5 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml deleted file mode 100644 index 904a9167896..00000000000 --- a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml +++ /dev/null @@ -1,39 +0,0 @@ -ethernet: - id: eth - type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 - interrupt_pin: 36 - reset_pin: 22 - enable_on_boot: false - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 - -esphome: - on_boot: - priority: 200 - then: - - if: - condition: - not: - ethernet.enabled: - then: - - ethernet.enable: - -button: - - platform: template - name: "Disable Ethernet" - on_press: - - ethernet.disable: - -binary_sensor: - - platform: template - name: "Ethernet Connected" - lambda: |- - return id(eth).is_connected(); diff --git a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml deleted file mode 100644 index 16a25ced2a9..00000000000 --- a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - ethernet: !include common-w5500-spi-id.yaml diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml deleted file mode 100644 index d454dbadc39..00000000000 --- a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM -packages: - ethernet: !include common-w5500.yaml - -psram: - mode: quad - -wifi: - ssid: MySSID - password: password1 - -network: - priority: - - ethernet - - wifi diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index f1551fef603..c99dfaf5281 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -1,20 +1,47 @@ +# W5500 on a shared SPI bus, started from an automation instead of on boot. +# Covers the spi_id attach path and the enable/disable lifecycle in one build. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + ethernet: + id: eth type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 + spi_id: spi_bus + cs_pin: 5 interrupt_pin: 36 reset_pin: 22 clock_speed: 10Mhz + enable_on_boot: false manual_ip: static_ip: 192.168.178.56 gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 on_connect: - logger.log: "Ethernet connected!" on_disconnect: - logger.log: "Ethernet disconnected!" + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml index 36f1b5365f1..d454dbadc39 100644 --- a/tests/components/ethernet/test.esp32-s3-idf.yaml +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -1 +1,15 @@ -<<: !include common-w5500.yaml +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml b/tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml rename to tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml rename to tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml diff --git a/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml new file mode 100644 index 00000000000..a2c62e4a526 --- /dev/null +++ b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml @@ -0,0 +1,12 @@ +# Config only: a user supplied SPI interface on dedicated pins exercises the +# interface option in validation. +ethernet: + type: W5500 + interface: spi3 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 17 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 12 + clock_speed: 10Mhz From de5c2cda09fad57513974c783d7d59c84a66681d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:42 -1000 Subject: [PATCH 02/79] [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) --- esphome/components/mcp23xxx_base/mcp23xxx_base.cpp | 2 ++ esphome/components/mcp23xxx_base/mcp23xxx_base.h | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index b8032fcec8c..8ad2f7521e5 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -4,6 +4,8 @@ namespace esphome::mcp23xxx_base { +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); } + template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 1c45b0f4afa..7414677175e 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; +// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the +// ISR in flash. +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg); + template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: virtual void pin_mode(uint8_t pin, gpio::Flags flags); @@ -35,7 +39,8 @@ template class MCP23XXXBase : public Component, public gpio_expander: void setup_interrupt_pin_() { if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast(this), + gpio::INTERRUPT_FALLING_EDGE); this->set_invalidate_on_read_(false); } // Disable loop until an input pin is configured via pin_mode() @@ -43,7 +48,6 @@ template class MCP23XXXBase : public Component, public gpio_expander: // For polling mode, loop is re-enabled when pin_mode() registers an input pin this->disable_loop(); } - static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; From 65e8345ab8d50cb5ab37ec46d31adb6b3815b693 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:32:07 -1000 Subject: [PATCH 03/79] [ota] Collapse duplicate CI test variants (#20407) --- tests/components/ota/common.yaml | 5 ----- tests/components/ota/test-api_key_offer.esp8266-ard.yaml | 2 -- .../components/ota/test-api_runtime_key.esp8266-ard.yaml | 2 -- .../components/ota/test-partition_access.esp32-idf.yaml | 5 ----- tests/components/ota/test.bk72xx-ard.yaml | 4 +++- tests/components/ota/test.esp32-ard.yaml | 4 +++- tests/components/ota/test.esp32-idf.yaml | 9 ++++++++- tests/components/ota/test.esp8266-ard.yaml | 4 +++- tests/components/ota/test.host.yaml | 6 ++---- tests/components/ota/test.rp2040-ard.yaml | 4 +++- ...ard.yaml => validate-empty_password.esp8266-ard.yaml} | 0 ...yaml => validate-encryption_inherit.esp8266-ard.yaml} | 0 tests/components/ota/wifi.yaml | 3 +++ 13 files changed, 25 insertions(+), 23 deletions(-) delete mode 100644 tests/components/ota/test-api_key_offer.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-api_runtime_key.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml rename tests/components/ota/{test-empty_password.esp8266-ard.yaml => validate-empty_password.esp8266-ard.yaml} (100%) rename tests/components/ota/{test-encryption_inherit.esp8266-ard.yaml => validate-encryption_inherit.esp8266-ard.yaml} (100%) create mode 100644 tests/components/ota/wifi.yaml diff --git a/tests/components/ota/common.yaml b/tests/components/ota/common.yaml index 1433dada1ff..af8cf66708d 100644 --- a/tests/components/ota/common.yaml +++ b/tests/components/ota/common.yaml @@ -1,11 +1,6 @@ -wifi: - ssid: MySSID - password: password1 - ota: - platform: esphome password: "superlongpasswordthatnoonewillknow" - port: 3286 on_begin: then: - logger.log: "OTA start" diff --git a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml b/tests/components/ota/test-api_key_offer.esp8266-ard.yaml deleted file mode 100644 index ecda6255219..00000000000 --- a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_key_offer.yaml diff --git a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml b/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml deleted file mode 100644 index 4709a9e45ce..00000000000 --- a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_runtime_key.yaml diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml deleted file mode 100644 index 0cbf8549520..00000000000 --- a/tests/components/ota/test-partition_access.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ota: - - platform: esphome - allow_partition_access: true - -<<: !include common.yaml diff --git a/tests/components/ota/test.bk72xx-ard.yaml b/tests/components/ota/test.bk72xx-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.bk72xx-ard.yaml +++ b/tests/components/ota/test.bk72xx-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-ard.yaml b/tests/components/ota/test.esp32-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.esp32-ard.yaml +++ b/tests/components/ota/test.esp32-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index dade44d145b..7f25fafadbb 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1 +1,8 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml + +# Final validation merges this into the common block +ota: + - platform: esphome + allow_partition_access: true diff --git a/tests/components/ota/test.esp8266-ard.yaml b/tests/components/ota/test.esp8266-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.esp8266-ard.yaml +++ b/tests/components/ota/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.host.yaml b/tests/components/ota/test.host.yaml index ae7c4d0add7..fdfbf47139a 100644 --- a/tests/components/ota/test.host.yaml +++ b/tests/components/ota/test.host.yaml @@ -1,4 +1,2 @@ -<<: !include common.yaml - -#host platform does not support wifi / network is automatically included -wifi: !remove +packages: + ota: !include common.yaml diff --git a/tests/components/ota/test.rp2040-ard.yaml b/tests/components/ota/test.rp2040-ard.yaml index dade44d145b..3b32457cf40 100644 --- a/tests/components/ota/test.rp2040-ard.yaml +++ b/tests/components/ota/test.rp2040-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test-empty_password.esp8266-ard.yaml b/tests/components/ota/validate-empty_password.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-empty_password.esp8266-ard.yaml rename to tests/components/ota/validate-empty_password.esp8266-ard.yaml diff --git a/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml b/tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-encryption_inherit.esp8266-ard.yaml rename to tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml diff --git a/tests/components/ota/wifi.yaml b/tests/components/ota/wifi.yaml new file mode 100644 index 00000000000..ac662302fc7 --- /dev/null +++ b/tests/components/ota/wifi.yaml @@ -0,0 +1,3 @@ +wifi: + ssid: MySSID + password: password1 From 6f4d0e948870e4505807392393b7136793c57d88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:32:33 -1000 Subject: [PATCH 04/79] [debug] Collapse duplicate CI test variants (#20404) --- tests/components/debug/test.bk72xx-ard.yaml | 3 ++- tests/components/debug/test.esp32-ard.yaml | 3 ++- tests/components/debug/test.esp32-idf.yaml | 5 ++--- tests/components/debug/test.esp32-s2-idf.yaml | 8 -------- tests/components/debug/test.esp8266-ard.yaml | 3 ++- tests/components/debug/test.host.yaml | 3 ++- tests/components/debug/test.ln882x-ard.yaml | 6 ------ tests/components/debug/test.nrf52-adafruit.yaml | 3 ++- tests/components/debug/test.nrf52-mcumgr.yaml | 3 ++- tests/components/debug/test.nrf52-xiao-ble.yaml | 1 - tests/components/debug/test.rp2040-ard.yaml | 3 ++- tests/components/debug/test.rtl87xx-ard.yaml | 6 ------ 12 files changed, 16 insertions(+), 31 deletions(-) delete mode 100644 tests/components/debug/test.esp32-s2-idf.yaml delete mode 100644 tests/components/debug/test.ln882x-ard.yaml delete mode 100644 tests/components/debug/test.nrf52-xiao-ble.yaml delete mode 100644 tests/components/debug/test.rtl87xx-ard.yaml diff --git a/tests/components/debug/test.bk72xx-ard.yaml b/tests/components/debug/test.bk72xx-ard.yaml index fdae374788f..65320a47d63 100644 --- a/tests/components/debug/test.bk72xx-ard.yaml +++ b/tests/components/debug/test.bk72xx-ard.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml sensor: - platform: debug diff --git a/tests/components/debug/test.esp32-ard.yaml b/tests/components/debug/test.esp32-ard.yaml index 8f93b0925eb..cdb1615be14 100644 --- a/tests/components/debug/test.esp32-ard.yaml +++ b/tests/components/debug/test.esp32-ard.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml esp32: cpu_frequency: 240MHz diff --git a/tests/components/debug/test.esp32-idf.yaml b/tests/components/debug/test.esp32-idf.yaml index 6a9996ad065..73bc97b2a96 100644 --- a/tests/components/debug/test.esp32-idf.yaml +++ b/tests/components/debug/test.esp32-idf.yaml @@ -1,12 +1,11 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml esp32: cpu_frequency: 240MHz sensor: - platform: debug - free: - name: "Heap Free" psram: name: "Free PSRAM" fragmentation: diff --git a/tests/components/debug/test.esp32-s2-idf.yaml b/tests/components/debug/test.esp32-s2-idf.yaml deleted file mode 100644 index 80919b0bab8..00000000000 --- a/tests/components/debug/test.esp32-s2-idf.yaml +++ /dev/null @@ -1,8 +0,0 @@ -<<: !include common.yaml - -sensor: - - platform: debug - fragmentation: - name: "Heap Fragmentation" - min_free: - name: "Heap Min Free" diff --git a/tests/components/debug/test.esp8266-ard.yaml b/tests/components/debug/test.esp8266-ard.yaml index 1398087bf06..da54acfc0dd 100644 --- a/tests/components/debug/test.esp8266-ard.yaml +++ b/tests/components/debug/test.esp8266-ard.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml sensor: - platform: debug diff --git a/tests/components/debug/test.host.yaml b/tests/components/debug/test.host.yaml index dade44d145b..25c05c6f819 100644 --- a/tests/components/debug/test.host.yaml +++ b/tests/components/debug/test.host.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml diff --git a/tests/components/debug/test.ln882x-ard.yaml b/tests/components/debug/test.ln882x-ard.yaml deleted file mode 100644 index fdae374788f..00000000000 --- a/tests/components/debug/test.ln882x-ard.yaml +++ /dev/null @@ -1,6 +0,0 @@ -<<: !include common.yaml - -sensor: - - platform: debug - min_free: - name: "Heap Min Free" diff --git a/tests/components/debug/test.nrf52-adafruit.yaml b/tests/components/debug/test.nrf52-adafruit.yaml index 6a446634afa..53eea184f58 100644 --- a/tests/components/debug/test.nrf52-adafruit.yaml +++ b/tests/components/debug/test.nrf52-adafruit.yaml @@ -1,4 +1,5 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml nrf52: reg0: diff --git a/tests/components/debug/test.nrf52-mcumgr.yaml b/tests/components/debug/test.nrf52-mcumgr.yaml index dade44d145b..25c05c6f819 100644 --- a/tests/components/debug/test.nrf52-mcumgr.yaml +++ b/tests/components/debug/test.nrf52-mcumgr.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml diff --git a/tests/components/debug/test.nrf52-xiao-ble.yaml b/tests/components/debug/test.nrf52-xiao-ble.yaml deleted file mode 100644 index dade44d145b..00000000000 --- a/tests/components/debug/test.nrf52-xiao-ble.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common.yaml diff --git a/tests/components/debug/test.rp2040-ard.yaml b/tests/components/debug/test.rp2040-ard.yaml index dade44d145b..25c05c6f819 100644 --- a/tests/components/debug/test.rp2040-ard.yaml +++ b/tests/components/debug/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + debug: !include common.yaml diff --git a/tests/components/debug/test.rtl87xx-ard.yaml b/tests/components/debug/test.rtl87xx-ard.yaml deleted file mode 100644 index fdae374788f..00000000000 --- a/tests/components/debug/test.rtl87xx-ard.yaml +++ /dev/null @@ -1,6 +0,0 @@ -<<: !include common.yaml - -sensor: - - platform: debug - min_free: - name: "Heap Min Free" From a0f0949078aac9906ee18de9171ccd561f9ce2fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:42:00 -1000 Subject: [PATCH 05/79] [mdns] Collapse duplicate CI test variants (#20403) --- .../{common-enabled-ethernet.yaml => common-ethernet.yaml} | 1 - tests/components/mdns/{common-enabled.yaml => common.yaml} | 1 - tests/components/mdns/test-disabled.esp32-idf.yaml | 6 ------ .../components/mdns/test-enabled-ethernet.rp2040-ard.yaml | 1 - tests/components/mdns/test-enabled.esp32-idf.yaml | 1 - tests/components/mdns/test-enabled.esp8266-ard.yaml | 1 - tests/components/mdns/test-enabled.rp2040-ard.yaml | 1 - tests/components/mdns/test-ethernet.rp2040-ard.yaml | 2 ++ tests/components/mdns/test.esp32-idf.yaml | 2 ++ tests/components/mdns/test.esp8266-ard.yaml | 2 ++ tests/components/mdns/test.rp2040-ard.yaml | 2 ++ tests/components/mdns/validate-disabled.esp32-idf.yaml | 7 +++++++ 12 files changed, 15 insertions(+), 12 deletions(-) rename tests/components/mdns/{common-enabled-ethernet.yaml => common-ethernet.yaml} (95%) rename tests/components/mdns/{common-enabled.yaml => common.yaml} (92%) delete mode 100644 tests/components/mdns/test-disabled.esp32-idf.yaml delete mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml delete mode 100644 tests/components/mdns/test-enabled.esp32-idf.yaml delete mode 100644 tests/components/mdns/test-enabled.esp8266-ard.yaml delete mode 100644 tests/components/mdns/test-enabled.rp2040-ard.yaml create mode 100644 tests/components/mdns/test-ethernet.rp2040-ard.yaml create mode 100644 tests/components/mdns/test.esp32-idf.yaml create mode 100644 tests/components/mdns/test.esp8266-ard.yaml create mode 100644 tests/components/mdns/test.rp2040-ard.yaml create mode 100644 tests/components/mdns/validate-disabled.esp32-idf.yaml diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-ethernet.yaml similarity index 95% rename from tests/components/mdns/common-enabled-ethernet.yaml rename to tests/components/mdns/common-ethernet.yaml index bfa9321d436..95a5d0edc8c 100644 --- a/tests/components/mdns/common-enabled-ethernet.yaml +++ b/tests/components/mdns/common-ethernet.yaml @@ -14,7 +14,6 @@ ethernet: mac_address: "02:AA:BB:CC:DD:01" mdns: - disabled: false services: - service: _test_service protocol: _tcp diff --git a/tests/components/mdns/common-enabled.yaml b/tests/components/mdns/common.yaml similarity index 92% rename from tests/components/mdns/common-enabled.yaml rename to tests/components/mdns/common.yaml index 8b3d81cf69d..8d0154fe9d2 100644 --- a/tests/components/mdns/common-enabled.yaml +++ b/tests/components/mdns/common.yaml @@ -3,7 +3,6 @@ wifi: password: password1 mdns: - disabled: false services: - service: _test_service protocol: _tcp diff --git a/tests/components/mdns/test-disabled.esp32-idf.yaml b/tests/components/mdns/test-disabled.esp32-idf.yaml deleted file mode 100644 index 07c70bf2486..00000000000 --- a/tests/components/mdns/test-disabled.esp32-idf.yaml +++ /dev/null @@ -1,6 +0,0 @@ -wifi: - ssid: MySSID - password: password1 - -mdns: - disabled: true diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml deleted file mode 100644 index f84a0bc276b..00000000000 --- a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled-ethernet.yaml diff --git a/tests/components/mdns/test-enabled.esp32-idf.yaml b/tests/components/mdns/test-enabled.esp32-idf.yaml deleted file mode 100644 index 97fd63d70e3..00000000000 --- a/tests/components/mdns/test-enabled.esp32-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled.yaml diff --git a/tests/components/mdns/test-enabled.esp8266-ard.yaml b/tests/components/mdns/test-enabled.esp8266-ard.yaml deleted file mode 100644 index 97fd63d70e3..00000000000 --- a/tests/components/mdns/test-enabled.esp8266-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled.yaml diff --git a/tests/components/mdns/test-enabled.rp2040-ard.yaml b/tests/components/mdns/test-enabled.rp2040-ard.yaml deleted file mode 100644 index 97fd63d70e3..00000000000 --- a/tests/components/mdns/test-enabled.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled.yaml diff --git a/tests/components/mdns/test-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-ethernet.rp2040-ard.yaml new file mode 100644 index 00000000000..4c2dfce1e3b --- /dev/null +++ b/tests/components/mdns/test-ethernet.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common-ethernet.yaml diff --git a/tests/components/mdns/test.esp32-idf.yaml b/tests/components/mdns/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5e8cf7aeaf --- /dev/null +++ b/tests/components/mdns/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common.yaml diff --git a/tests/components/mdns/test.esp8266-ard.yaml b/tests/components/mdns/test.esp8266-ard.yaml new file mode 100644 index 00000000000..c5e8cf7aeaf --- /dev/null +++ b/tests/components/mdns/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common.yaml diff --git a/tests/components/mdns/test.rp2040-ard.yaml b/tests/components/mdns/test.rp2040-ard.yaml new file mode 100644 index 00000000000..c5e8cf7aeaf --- /dev/null +++ b/tests/components/mdns/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + mdns: !include common.yaml diff --git a/tests/components/mdns/validate-disabled.esp32-idf.yaml b/tests/components/mdns/validate-disabled.esp32-idf.yaml new file mode 100644 index 00000000000..7d76bf4de97 --- /dev/null +++ b/tests/components/mdns/validate-disabled.esp32-idf.yaml @@ -0,0 +1,7 @@ +# Disabling mdns compiles no mdns code, so config validation is enough. +wifi: + ssid: MySSID + password: password1 + +mdns: + disabled: true From d9b9ce2909354efe1c60093d536e0378f7d5180c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:07:56 -1000 Subject: [PATCH 06/79] [core] Use numeric scheduler ids instead of string names (ESP32 1/2) (#20380) --- .../airthings_wave_base/airthings_wave_base.cpp | 7 +++++-- esphome/components/demo/demo_cover.h | 6 ++++-- esphome/components/improv_ble/improv_ble_component.cpp | 10 +++++++--- .../pvvx_mithermometer/display/pvvx_display.cpp | 8 +++++--- esphome/components/sendspin/image/sendspin_image.cpp | 4 +++- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp index d67a2e4a0fa..f4dfaaee326 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp +++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp @@ -10,6 +10,9 @@ namespace esphome::airthings_wave_base { ESPHOME_LOG_TAG(TAG, "airthings_wave_base"); +static constexpr uint32_t BATTERY_READ_TIMEOUT_ID = 0; +static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1; + void AirthingsWaveBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { switch (event) { @@ -181,7 +184,7 @@ void AirthingsWaveBase::read_battery_(uint8_t *raw_value, uint16_t value_len) { // read the battery again at the configured update interval if (this->battery_update_interval_ != this->update_interval_) { this->read_battery_next_update_ = false; - this->set_timeout("battery", this->battery_update_interval_, + this->set_timeout(BATTERY_READ_TIMEOUT_ID, this->battery_update_interval_, [this]() { this->read_battery_next_update_ = true; }); } } @@ -204,7 +207,7 @@ void AirthingsWaveBase::response_received_() { } void AirthingsWaveBase::set_response_timeout_() { - this->set_timeout("response_timeout", 30 * 1000, [this]() { + this->set_timeout(RESPONSE_TIMEOUT_ID, 30 * 1000, [this]() { this->responses_pending_ = 1; this->response_received_(); }); diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index aa12c885f43..eeb188a4631 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -36,6 +36,8 @@ class DemoCover final : public cover::Cover, public Component { } protected: + static constexpr uint32_t MOVE_TIMEOUT_ID = 0; + void control(const cover::CoverCall &call) override { auto pos = call.get_position(); if (pos.has_value()) { @@ -43,7 +45,7 @@ class DemoCover final : public cover::Cover, public Component { this->current_operation = target > this->position ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING; - this->set_timeout("move", 2000, [this, target]() { + this->set_timeout(MOVE_TIMEOUT_ID, 2000, [this, target]() { this->current_operation = cover::COVER_OPERATION_IDLE; this->position = target; this->publish_state(); @@ -54,7 +56,7 @@ class DemoCover final : public cover::Cover, public Component { this->tilt = *tilt; } if (call.get_stop()) { - this->cancel_timeout("move"); + this->cancel_timeout(MOVE_TIMEOUT_ID); } this->publish_state(); diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 15adc497764..2029058899f 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -21,6 +21,10 @@ namespace esphome::improv_ble { using namespace bytebuffer; ESPHOME_LOG_TAG(TAG, "improv_ble.component"); + +static constexpr uint32_t STOP_SERVICE_TIMEOUT_ID = 0; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 1; + static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -322,7 +326,7 @@ void ImprovBLEComponent::stop() { // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing // them to observe that the device is provisioned before the service disappears. - this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { + this->set_timeout(STOP_SERVICE_TIMEOUT_ID, STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; // Release first so removing the service UUID does not restart advertising on the way out @@ -398,7 +402,7 @@ void ImprovBLEComponent::process_incoming_data_() { ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); }); + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, 30000, [this]() { this->on_wifi_connect_timeout_(); }); this->incoming_data_.clear(); break; } @@ -438,7 +442,7 @@ void ImprovBLEComponent::check_wifi_connection_() { if (this->state_ == improv::STATE_PROVISIONING) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); // Build the URL list directly into a stack buffer with no heap allocation std::array buf; diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 57876f8c074..995c4aff50d 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -9,6 +9,8 @@ namespace esphome::pvvx_mithermometer { ESPHOME_LOG_TAG(TAG, "display.pvvx_mithermometer"); +static constexpr uint32_t DISCONNECT_TIMEOUT_ID = 0; + void PVVXDisplay::dump_config() { char service_buf[ble_device_base::UUID_STR_LEN]; char char_buf[ble_device_base::UUID_STR_LEN]; @@ -39,7 +41,7 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t case ESP_GATTC_DISCONNECT_EVT: ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str()); this->connection_established_ = false; - this->cancel_timeout("disconnect"); + this->cancel_timeout(DISCONNECT_TIMEOUT_ID); this->char_handle_ = 0; break; case ESP_GATTC_SEARCH_CMPL_EVT: { @@ -147,8 +149,8 @@ void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { void PVVXDisplay::delayed_disconnect_() { if (this->disconnect_delay_ms_ == 0) return; - this->cancel_timeout("disconnect"); - this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); + this->cancel_timeout(DISCONNECT_TIMEOUT_ID); + this->set_timeout(DISCONNECT_TIMEOUT_ID, this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } void PVVXDisplay::sync_time_and_display_() { diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 4787abc7601..a93c2eb4430 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -10,6 +10,8 @@ namespace esphome::sendspin_ { ESPHOME_LOG_TAG(TAG, "sendspin.image"); +static constexpr uint32_t TRANSITION_ACK_TIMEOUT_ID = 0; + // How long a displayed frame may wait for sendspin.image.transition_finished before a warning // names the missing ack. Generous next to a typical fade of a second or two. static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; @@ -163,7 +165,7 @@ void SendspinImageSlot::on_display_(uint32_t lateness_ms) { // that never reaches the action stalls the slot with nothing in the log. Name the cause after // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame // that was already acked and superseded. - this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + this->set_timeout(TRANSITION_ACK_TIMEOUT_ID, TRANSITION_ACK_WARNING_MS, [this]() { if (this->transition_pending_) { ESP_LOGW(TAG, "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " From 466081acc16c6885327b1547a7ea1adcf082e016 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:09:04 -1000 Subject: [PATCH 07/79] [core] Use numeric scheduler ids instead of string names (ESP32 2/2) (#20381) --- .../media_player/speaker_media_player.cpp | 30 +++++++++++-------- .../voice_assistant/voice_assistant.cpp | 27 ++++++++++------- .../xiaomi_mue4094rt/xiaomi_mue4094rt.cpp | 4 ++- .../xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp | 7 +++-- .../zigbee/time/zigbee_time_esp32.cpp | 7 +++-- esphome/components/zigbee/zigbee_esp32.cpp | 11 ++++--- 6 files changed, 54 insertions(+), 32 deletions(-) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index e4aa78d10db..8ab4128e927 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -52,6 +52,11 @@ static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1; ESPHOME_LOG_TAG(TAG, "speaker_media_player"); +static constexpr uint32_t UNPAUSE_MEDIA_INTERVAL_ID = 0; +static constexpr uint32_t NEXT_ANNOUNCEMENT_TIMEOUT_ID = 1; +static constexpr uint32_t NEXT_MEDIA_TIMEOUT_ID = 2; +static constexpr uint32_t UNPAUSE_ANNOUNCEMENT_INTERVAL_ID = 3; + void SpeakerMediaPlayer::setup() { #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF state = media_player::MEDIA_PLAYER_STATE_OFF; @@ -112,13 +117,13 @@ void SpeakerMediaPlayer::set_playlist_delay_ms(AudioPipelineType pipeline_type, void SpeakerMediaPlayer::stop_and_unpause_media_() { this->media_pipeline_->stop(); this->unpause_media_remaining_ = 3; - this->set_interval("unpause_med", 50, [this]() { + this->set_interval(UNPAUSE_MEDIA_INTERVAL_ID, 50, [this]() { if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->cancel_interval("unpause_med"); + this->cancel_interval(UNPAUSE_MEDIA_INTERVAL_ID); this->media_pipeline_->set_pause_state(false); this->is_paused_ = false; } else if (--this->unpause_media_remaining_ == 0) { - this->cancel_interval("unpause_med"); + this->cancel_interval(UNPAUSE_MEDIA_INTERVAL_ID); } }); } @@ -152,7 +157,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { if (!enqueue) { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause - this->cancel_timeout("next_ann"); + this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); this->announcement_playlist_.clear(); this->announcement_item_failed_ = false; if (media_command.file.has_value()) { @@ -166,7 +171,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { } else { if (!enqueue) { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause - this->cancel_timeout("next_media"); + this->cancel_timeout(NEXT_MEDIA_TIMEOUT_ID); this->media_playlist_.clear(); this->media_item_failed_ = false; if (this->is_paused_) { @@ -236,23 +241,23 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { #endif if (this->announcement_pipeline_ != nullptr) { - this->cancel_timeout("next_ann"); + this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); this->announcement_playlist_.clear(); this->announcement_item_failed_ = false; this->announcement_pipeline_->stop(); this->unpause_announcement_remaining_ = 3; - this->set_interval("unpause_ann", 50, [this]() { + this->set_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID, 50, [this]() { if (this->announcement_pipeline_state_ == AudioPipelineState::STOPPED) { - this->cancel_interval("unpause_ann"); + this->cancel_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID); this->announcement_pipeline_->set_pause_state(false); } else if (--this->unpause_announcement_remaining_ == 0) { - this->cancel_interval("unpause_ann"); + this->cancel_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID); } }); } } else { if (this->media_pipeline_ != nullptr) { - this->cancel_timeout("next_media"); + this->cancel_timeout(NEXT_MEDIA_TIMEOUT_ID); this->media_playlist_.clear(); this->media_item_failed_ = false; this->stop_and_unpause_media_(); @@ -408,7 +413,8 @@ void SpeakerMediaPlayer::loop() { this->announcement_pipeline_->set_pause_state(true); // Internally unpause the pipeline after the delay between playlist items. Announcements do not follow the // media player's pause state. - this->set_timeout("next_ann", timeout_ms, [this]() { this->announcement_pipeline_->set_pause_state(false); }); + this->set_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID, timeout_ms, + [this]() { this->announcement_pipeline_->set_pause_state(false); }); } } } else { @@ -454,7 +460,7 @@ void SpeakerMediaPlayer::loop() { this->media_pipeline_->set_pause_state(true); // Internally unpause the pipeline after the delay between playlist items, if the media player state is // not paused. - this->set_timeout("next_media", timeout_ms, + this->set_timeout(NEXT_MEDIA_TIMEOUT_ID, timeout_ms, [this]() { this->media_pipeline_->set_pause_state(this->is_paused_); }); } } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index e9523084370..039410abdbf 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -14,6 +14,11 @@ namespace esphome::voice_assistant { ESPHOME_LOG_TAG(TAG, "voice_assistant"); +static constexpr uint32_t RESET_CONVERSATION_TIMEOUT_ID = 0; +static constexpr uint32_t PLAYBACK_TIMEOUT_ID = 1; +static constexpr uint32_t SPEAKER_IDLE_TIMEOUT_ID = 2; +static constexpr uint32_t TIMER_TICK_INTERVAL_ID = 3; + #ifdef SAMPLE_RATE_HZ #undef SAMPLE_RATE_HZ #endif @@ -370,7 +375,7 @@ void VoiceAssistant::loop() { break; } this->set_state_(State::STARTING_PIPELINE); - this->set_timeout("reset-conversation_id", this->conversation_timeout_, + this->set_timeout(RESET_CONVERSATION_TIMEOUT_ID, this->conversation_timeout_, [this]() { this->reset_conversation_id(); }); break; } @@ -460,10 +465,10 @@ void VoiceAssistant::loop() { if (this->speaker_bytes_received_ > RECEIVE_SIZE * 4 || end_of_stream) this->write_speaker_(); if (this->wait_for_stream_end_) { - this->cancel_timeout("playing"); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); if (end_of_stream) { ESP_LOGD(TAG, "End of audio stream received"); - this->cancel_timeout("speaker-timeout"); + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); } break; // We dont want to timeout here as the STREAM_END event will take care of that. @@ -477,7 +482,7 @@ void VoiceAssistant::loop() { if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) { this->media_player_response_state_ = MediaPlayerResponseState::IDLE; - this->cancel_timeout("playing"); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); ESP_LOGD(TAG, "Announcement finished playing"); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); @@ -507,8 +512,8 @@ void VoiceAssistant::loop() { } ESP_LOGD(TAG, "Speaker has finished outputting all audio"); this->speaker_->stop(); - this->cancel_timeout("speaker-timeout"); - this->cancel_timeout("playing"); + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); this->clear_buffers_(); @@ -540,7 +545,7 @@ void VoiceAssistant::write_speaker_() { memmove(this->speaker_buffer_, this->speaker_buffer_ + written, this->speaker_buffer_size_ - written); this->speaker_buffer_size_ -= written; this->speaker_buffer_index_ -= written; - this->set_timeout("speaker-timeout", 5000, [this]() { this->speaker_->stop(); }); + this->set_timeout(SPEAKER_IDLE_TIMEOUT_ID, 5000, [this]() { this->speaker_->stop(); }); } else { ESP_LOGV(TAG, "Speaker buffer full, trying again next loop"); } @@ -755,8 +760,8 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout("playing", 2000, [this]() { - this->cancel_timeout("speaker-timeout"); + this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); if (this->api_client_ == nullptr) @@ -1046,10 +1051,10 @@ void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse } if (this->timers_.empty()) { - this->cancel_interval("timer-event"); + this->cancel_interval(TIMER_TICK_INTERVAL_ID); this->timer_tick_running_ = false; } else if (!this->timer_tick_running_) { - this->set_interval("timer-event", 1000, [this]() { this->timer_tick_(); }); + this->set_interval(TIMER_TICK_INTERVAL_ID, 1000, [this]() { this->timer_tick_(); }); this->timer_tick_running_ = true; } } diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index afd8715ec5a..683e8a2a927 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -5,6 +5,8 @@ namespace esphome::xiaomi_mue4094rt { ESPHOME_LOG_TAG(TAG, "xiaomi_mue4094rt"); +static constexpr uint32_t MOTION_TIMEOUT_ID = 0; + void XiaomiMUE4094RT::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MUE4094RT"); LOG_BINARY_SENSOR(" ", "Motion", this); @@ -40,7 +42,7 @@ bool XiaomiMUE4094RT::parse_device(const ble_device_base::ESPBTDevice &device) { } if (res->has_motion.has_value()) { this->publish_state(*res->has_motion); - this->set_timeout("motion_timeout", timeout_, [this]() { this->publish_state(false); }); + this->set_timeout(MOTION_TIMEOUT_ID, timeout_, [this]() { this->publish_state(false); }); } success = true; } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index 8bf098a676f..f7ecf15c3bb 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -6,6 +6,9 @@ namespace esphome::xiaomi_rtcgq02lm { ESPHOME_LOG_TAG(TAG, "xiaomi_rtcgq02lm"); +static constexpr uint32_t MOTION_TIMEOUT_ID = 0; +static constexpr uint32_t BUTTON_TIMEOUT_ID = 1; + static constexpr size_t RTCGQ02LM_BINDKEY_SIZE = 16; void XiaomiRTCGQ02LM::dump_config() { @@ -55,13 +58,13 @@ bool XiaomiRTCGQ02LM::parse_device(const ble_device_base::ESPBTDevice &device) { #ifdef USE_BINARY_SENSOR if (res->has_motion.has_value() && this->motion_ != nullptr) { this->motion_->publish_state(*res->has_motion); - this->set_timeout("motion_timeout", this->motion_timeout_, [this]() { this->motion_->publish_state(false); }); + this->set_timeout(MOTION_TIMEOUT_ID, this->motion_timeout_, [this]() { this->motion_->publish_state(false); }); } if (res->is_light.has_value() && this->light_ != nullptr) this->light_->publish_state(*res->is_light); if (res->button_press.has_value() && this->button_ != nullptr) { this->button_->publish_state(*res->button_press); - this->set_timeout("button_timeout", this->button_timeout_, [this]() { this->button_->publish_state(false); }); + this->set_timeout(BUTTON_TIMEOUT_ID, this->button_timeout_, [this]() { this->button_->publish_state(false); }); } #endif #ifdef USE_SENSOR diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp index a6a25ef2f3e..1bc0923a1f7 100644 --- a/esphome/components/zigbee/time/zigbee_time_esp32.cpp +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -7,6 +7,9 @@ namespace esphome::zigbee { ESPHOME_LOG_TAG(TAG, "zigbee.time"); +static constexpr uint32_t REGISTER_RETRY_TIMEOUT_ID = 0; +static constexpr uint32_t SYNC_RETRY_TIMEOUT_ID = 1; + // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; @@ -29,7 +32,7 @@ void ZigbeeTime::register_zb_time_() { }; ezb_err_t ret; if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); }); + this->set_timeout(REGISTER_RETRY_TIMEOUT_ID, 100, [this]() { this->register_zb_time_(); }); return; } ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface); @@ -69,7 +72,7 @@ void ZigbeeTime::update() { ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times"); } if (this->retry_count_ < 3) { - this->set_timeout("zb_time_sync", 100, [this]() { this->update(); }); + this->set_timeout(SYNC_RETRY_TIMEOUT_ID, 100, [this]() { this->update(); }); this->retry_count_++; } else { ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time"); diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 676407d751c..8b7b59acb0b 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -19,6 +19,8 @@ namespace esphome::zigbee { ESPHOME_LOG_TAG(TAG, "zigbee"); +static constexpr uint32_t COMMISSIONING_RETRY_TIMEOUT_ID = 0; + static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { @@ -49,7 +51,8 @@ void ZigbeeComponent::factory_reset() { void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, + [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); App.wake_loop_threadsafe(); return; } @@ -86,7 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { } } else { ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); - global_zigbee->set_timeout("zb_init", 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); App.wake_loop_threadsafe(); @@ -107,11 +110,11 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - global_zigbee->set_timeout("zb_init", 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } else { - global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 600 * 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } From a2434f9aa3321feaf0e217375495efae31076a84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:09:33 -1000 Subject: [PATCH 08/79] [core] Document numeric scheduler ids in AGENTS.md (#20382) --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index dc364e4e5c5..d5d33b32af6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,13 @@ file does, and it is the authority when they disagree. The most useful starting Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a + `static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other + components, so number them from 0 and keep all of a component's ids together in one place. + ```cpp + static constexpr uint32_t READ_TIMEOUT_ID = 0; + this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); }); + ``` * **Don't override a default with the same value:** if a base class method already returns what you want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a component that wants `DATA` should simply leave it alone. From bc6fecfadb6531183b32f446098940db2482ccd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:10:04 -1000 Subject: [PATCH 09/79] [core] Use numeric scheduler ids instead of string names (1/5) (#20374) --- esphome/components/aqi/aqi_sensor.cpp | 6 ++++-- esphome/components/bme280_base/bme280_base.cpp | 4 +++- esphome/components/bme680/bme680.cpp | 4 +++- esphome/components/bme680_bsec/bme680_bsec.cpp | 4 +++- esphome/components/bme68x_bsec2/bme68x_bsec2.cpp | 6 ++++-- esphome/components/bmp085/bmp085.cpp | 7 +++++-- esphome/components/bmp280_base/bmp280_base.cpp | 4 +++- esphome/components/bmp3xx_base/bmp3xx_base.cpp | 4 +++- esphome/components/bmp581_base/bmp581_base.cpp | 4 +++- esphome/components/combination/combination.cpp | 12 ++++++++---- 10 files changed, 39 insertions(+), 16 deletions(-) diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index d7fb120741b..c242ee39136 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -5,18 +5,20 @@ namespace esphome::aqi { ESPHOME_LOG_TAG(TAG, "aqi"); +static constexpr uint32_t CALCULATE_DEFER_ID = 0; + void AQISensor::setup() { if (this->pm_2_5_sensor_ != nullptr) { this->pm_2_5_sensor_->add_on_state_callback([this](float value) { this->pm_2_5_value_ = value; // Defer calculation to avoid double-publishing if both sensors update in the same loop - this->defer("update", [this]() { this->calculate_aqi_(); }); + this->defer(CALCULATE_DEFER_ID, [this]() { this->calculate_aqi_(); }); }); } if (this->pm_10_0_sensor_ != nullptr) { this->pm_10_0_sensor_->add_on_state_callback([this](float value) { this->pm_10_0_value_ = value; - this->defer("update", [this]() { this->calculate_aqi_(); }); + this->defer(CALCULATE_DEFER_ID, [this]() { this->calculate_aqi_(); }); }); } } diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 6cc011d76b5..96bb56f345e 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -13,6 +13,8 @@ namespace esphome::bme280_base { ESPHOME_LOG_TAG(TAG, "bme280.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const uint8_t BME280_REGISTER_DIG_T1 = 0x88; static const uint8_t BME280_REGISTER_DIG_T2 = 0x8A; static const uint8_t BME280_REGISTER_DIG_T3 = 0x8C; @@ -221,7 +223,7 @@ void BME280Component::update() { meas_time += 2.3f * oversampling_to_time(this->pressure_oversampling_) + 0.575f; meas_time += 2.3f * oversampling_to_time(this->humidity_oversampling_) + 0.575f; - this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ceilf(meas_time)), [this]() { uint8_t data[8]; if (!this->read_bytes(BME280_REGISTER_MEASUREMENTS, data, 8)) { ESP_LOGW(TAG, "Error reading registers"); diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 2ff7ca2b8cc..6bacb71ff20 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -6,6 +6,8 @@ namespace esphome::bme680 { ESPHOME_LOG_TAG(TAG, "bme680.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const uint8_t BME680_REGISTER_COEFF1 = 0x89; static const uint8_t BME680_REGISTER_COEFF2 = 0xE1; @@ -242,7 +244,7 @@ void BME680Component::update() { return; } - this->set_timeout("data", this->calc_meas_duration_(), [this]() { this->read_data_(); }); + this->set_timeout(DATA_TIMEOUT_ID, this->calc_meas_duration_(), [this]() { this->read_data_(); }); } uint8_t BME680Component::calc_heater_resistance_(uint16_t temperature) { diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 219f72d3dfc..3f0082e1af7 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -7,6 +7,8 @@ namespace esphome::bme680_bsec { #ifdef USE_BSEC ESPHOME_LOG_TAG(TAG, "bme680_bsec.sensor"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; std::vector @@ -259,7 +261,7 @@ void BME680BSECComponent::run_() { this->snapshot_state_(); ESP_LOGV(TAG, "Queueing read in %ums", meas_dur); - this->set_timeout("read", meas_dur, [this]() { this->read_(); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur, [this]() { this->read_(); }); } else { ESP_LOGV(TAG, "Measurement not required"); this->read_(); diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index 2b113c179fd..f5a674d6b92 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -15,6 +15,8 @@ namespace esphome::bme68x_bsec2 { ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; static bool is_no_new_data_warning(int8_t status) { @@ -285,11 +287,11 @@ void BME68xBSEC2Component::run_() { uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout("read", 0, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { this->read_(this->trigger_time_ns_); }); } } diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index 4badfd95f5b..b989ed877b6 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -5,6 +5,9 @@ namespace esphome::bmp085 { ESPHOME_LOG_TAG(TAG, "bmp085.sensor"); +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 0; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 1; + static const uint8_t BMP085_ADDRESS = 0x77; static const uint8_t BMP085_REGISTER_AC1_H = 0xAA; static const uint8_t BMP085_REGISTER_CONTROL = 0xF4; @@ -16,7 +19,7 @@ void BMP085Component::update() { if (!this->set_mode_(BMP085_CONTROL_MODE_TEMPERATURE)) return; - this->set_timeout("temperature", 5, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 5, [this]() { this->read_temperature_(); }); } void BMP085Component::setup() { uint8_t data[22]; @@ -84,7 +87,7 @@ void BMP085Component::read_temperature_() { return; } - this->set_timeout("pressure", 26, [this]() { this->read_pressure_(); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 26, [this]() { this->read_pressure_(); }); } void BMP085Component::read_pressure_() { uint8_t buffer[3]; diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 88ff0985812..9cb023c6884 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -8,6 +8,8 @@ namespace esphome::bmp280_base { ESPHOME_LOG_TAG(TAG, "bmp280.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const uint8_t BMP280_REGISTER_STATUS = 0xF3; static const uint8_t BMP280_REGISTER_CONTROL = 0xF4; static const uint8_t BMP280_REGISTER_CONFIG = 0xF5; @@ -166,7 +168,7 @@ void BMP280Component::update() { meas_time += 2.3f * oversampling_to_time(this->temperature_oversampling_); meas_time += 2.3f * oversampling_to_time(this->pressure_oversampling_) + 0.575f; - this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ceilf(meas_time)), [this]() { int32_t t_fine = 0; float temperature = this->read_temperature_(&t_fine); if (std::isnan(temperature)) { diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index ba85a20e3d8..b10a7a7a951 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -15,6 +15,8 @@ namespace esphome::bmp3xx_base { ESPHOME_LOG_TAG(TAG, "bmp3xx.sensor"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + static const LogString *chip_type_to_str(uint8_t chip_type) { switch (chip_type) { case BMP388_ID: @@ -170,7 +172,7 @@ void BMP3XXComponent::update() { const uint32_t meas_timeout = uint32_t(ceilf(meas_time)); ESP_LOGVV(TAG, "measurement time %" PRIu32, meas_timeout); - this->set_timeout("data", meas_timeout, [this]() { + this->set_timeout(DATA_TIMEOUT_ID, meas_timeout, [this]() { float temperature = 0.0f; float pressure = 0.0f; if (this->pressure_sensor_ != nullptr) { diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7d4761f5166..29f67fadc51 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -19,6 +19,8 @@ namespace esphome::bmp581_base { ESPHOME_LOG_TAG(TAG, "bmp581"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + // Oversampling strings indexed by Oversampling enum (0-7): NONE, X2, X4, X8, X16, X32, X64, X128 PROGMEM_STRING_TABLE(OversamplingStrings, "None", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); @@ -272,7 +274,7 @@ void BMP581Component::update() { ESP_LOGVV(TAG, "Measurement should take %d ms", this->conversion_time_); - this->set_timeout("measurement", this->conversion_time_, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, this->conversion_time_, [this]() { float temperature = 0.0; float pressure = 0.0; diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index d10490a20ce..0488fbb6ab2 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -9,6 +9,8 @@ namespace esphome::combination { ESPHOME_LOG_TAG(TAG, "combination"); +static constexpr uint32_t NEW_VALUE_DEFER_ID = 0; + void CombinationComponent::log_config_(const LogString *combo_type) { LOG_SENSOR("", "Combination Sensor:", this); ESP_LOGCONFIG(TAG, " Combination Type: %s", LOG_STR_ARG(combo_type)); @@ -43,8 +45,9 @@ void CombinationNoParameterComponent::setup() { for (const auto &sensor : this->sensors_) { // All sensor updates are deferred until the next loop. This avoids publishing the combined sensor's result // repeatedly in the same loop if multiple source senors update. - sensor->add_on_state_callback( - [this](float value) -> void { this->defer("update", [this, value]() { this->handle_new_value(value); }); }); + sensor->add_on_state_callback([this](float value) -> void { + this->defer(NEW_VALUE_DEFER_ID, [this, value]() { this->handle_new_value(value); }); + }); } } @@ -119,8 +122,9 @@ void LinearCombinationComponent::setup() { for (auto &source : this->sensor_sources_) { // All sensor updates are deferred until the next loop. This avoids publishing the combined sensor's result // repeatedly in the same loop if multiple source senors update. - source.sensor->add_on_state_callback( - [this](float value) -> void { this->defer("update", [this, value]() { this->handle_new_value(value); }); }); + source.sensor->add_on_state_callback([this](float value) -> void { + this->defer(NEW_VALUE_DEFER_ID, [this, value]() { this->handle_new_value(value); }); + }); } } From 824834f847eb300b3e183b1723ba9947be6f940a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:10:19 -1000 Subject: [PATCH 10/79] [core] Use numeric scheduler ids instead of string names (3/5) (#20376) --- esphome/components/hyt271/hyt271.cpp | 4 +++- esphome/components/ld6002b/ld6002b.cpp | 7 ++++--- esphome/components/ld6002b/ld6002b.h | 8 ++++---- esphome/components/max31855/max31855.cpp | 4 +++- esphome/components/max31856/max31856.cpp | 4 +++- esphome/components/max31865/max31865.cpp | 4 +++- esphome/components/max6675/max6675.cpp | 4 +++- esphome/components/mics_4514/mics_4514.cpp | 4 +++- esphome/components/mlx90393/sensor_mlx90393.cpp | 7 +++++-- esphome/components/ms5611/ms5611.cpp | 7 +++++-- esphome/components/ms8607/ms8607.cpp | 17 ++++++++++++----- 11 files changed, 48 insertions(+), 22 deletions(-) diff --git a/esphome/components/hyt271/hyt271.cpp b/esphome/components/hyt271/hyt271.cpp index 72d3940f30a..a8374c4115e 100644 --- a/esphome/components/hyt271/hyt271.cpp +++ b/esphome/components/hyt271/hyt271.cpp @@ -6,6 +6,8 @@ namespace esphome::hyt271 { ESPHOME_LOG_TAG(TAG, "hyt271"); +static constexpr uint32_t WAIT_CONVERT_TIMEOUT_ID = 0; + static const uint8_t HYT271_ADDRESS = 0x28; void HYT271Component::dump_config() { @@ -23,7 +25,7 @@ void HYT271Component::update() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); return; } - this->set_timeout("wait_convert", 50, [this]() { + this->set_timeout(WAIT_CONVERT_TIMEOUT_ID, 50, [this]() { uint8_t raw_data[4]; if (this->read(raw_data, 4) != i2c::ERROR_OK) { this->status_set_warning(); diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index fdf1b15d06c..93a2ea341f4 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -620,7 +620,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->process_command_queue_(); if (refresh_areas) { this->area_write_in_flight_ = false; - this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + this->set_timeout(AREA_REFRESH_TIMEOUT_ID, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); } return; } @@ -1215,7 +1215,7 @@ void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data std::memcpy(this->wake_scratch_.data(), data, len); } // A button pulse must not raise the pin in the middle of this one. - this->cancel_timeout(WAKE_BUTTON_TIMEOUT); + this->cancel_timeout(WAKE_BUTTON_TIMEOUT_ID); this->wake_pulse_pending_ = true; this->wakeup_pin_->digital_write(false); const uint8_t generation = this->send_generation_; @@ -1364,7 +1364,8 @@ void LD6002BComponent::wake_() { if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) return; this->wakeup_pin_->digital_write(false); - this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); + this->set_timeout(WAKE_BUTTON_TIMEOUT_ID, this->wakeup_pulse_ms_, + [this]() { this->wakeup_pin_->digital_write(true); }); } void LD6002BComponent::set_number_value(NumberType type, float value) { diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index bea38043128..c52f770d6e9 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -413,12 +413,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // How long the module stays awake after any frame, and so still answers the next one. static constexpr uint32_t MODULE_AWAKE_MS = 10000; static constexpr uint8_t CMD_MAX_RETRIES = 3; - // Named so a repeated press replaces its own pending timeout instead of stacking + // Keyed so a repeated press replaces its own pending timeout instead of stacking // another, and so the command path can cancel it when it takes the pin over. - static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; - // Named so a burst of writes collapses to one read once they settle, rather than + static constexpr uint32_t WAKE_BUTTON_TIMEOUT_ID = 0; + // Keyed so a burst of writes collapses to one read once they settle, rather than // one read per write. - static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; + static constexpr uint32_t AREA_REFRESH_TIMEOUT_ID = 1; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; diff --git a/esphome/components/max31855/max31855.cpp b/esphome/components/max31855/max31855.cpp index 3d681e49233..4bef41761c5 100644 --- a/esphome/components/max31855/max31855.cpp +++ b/esphome/components/max31855/max31855.cpp @@ -7,6 +7,8 @@ namespace esphome::max31855 { ESPHOME_LOG_TAG(TAG, "max31855"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void MAX31855Sensor::update() { this->enable(); delay(1); @@ -14,7 +16,7 @@ void MAX31855Sensor::update() { this->disable(); // Conversion time typ: 170ms, max: 220ms - this->set_timeout("value", 220, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, 220, [this]() { this->read_data_(); }); } void MAX31855Sensor::setup() { this->spi_setup(); } diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index 5612a4cbf70..bdb47216daa 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -7,6 +7,8 @@ namespace esphome::max31856 { ESPHOME_LOG_TAG(TAG, "max31856"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + // Based on Adafruit's library: https://github.com/adafruit/Adafruit_MAX31856 void MAX31856Sensor::setup() { @@ -42,7 +44,7 @@ void MAX31856Sensor::update() { this->one_shot_temperature_(); // Datasheet max conversion time for 1 shot is 155ms for 60Hz / 185ms for 50Hz - this->set_timeout("MAX31856Sensor::read_thermocouple_temperature_", filter_ == FILTER_60HZ ? 155 : 185, + this->set_timeout(READ_TIMEOUT_ID, filter_ == FILTER_60HZ ? 155 : 185, [this]() { this->read_thermocouple_temperature_(); }); } diff --git a/esphome/components/max31865/max31865.cpp b/esphome/components/max31865/max31865.cpp index b637d6dbfc8..94899b05ab5 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -8,6 +8,8 @@ namespace esphome::max31865 { ESPHOME_LOG_TAG(TAG, "max31865"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void MAX31865Sensor::update() { // Check new faults since last measurement if (!has_fault_) { @@ -59,7 +61,7 @@ void MAX31865Sensor::update() { this->write_config_(0b11100000, 0b10100000); // Datasheet max conversion time is 55ms for 60Hz / 66ms for 50Hz - this->set_timeout("value", filter_ == FILTER_60HZ ? 55 : 66, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, filter_ == FILTER_60HZ ? 55 : 66, [this]() { this->read_data_(); }); } void MAX31865Sensor::setup() { diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index ad0c470e336..2fc8dbb73d7 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -5,6 +5,8 @@ namespace esphome::max6675 { ESPHOME_LOG_TAG(TAG, "max6675"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void MAX6675Sensor::update() { this->enable(); delay(1); @@ -12,7 +14,7 @@ void MAX6675Sensor::update() { this->disable(); // Conversion time typ: 170ms, max: 220ms - this->set_timeout("value", 250, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, 250, [this]() { this->read_data_(); }); } void MAX6675Sensor::setup() { this->spi_setup(); } diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index d4794b66826..957cdf90ef2 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -7,6 +7,8 @@ namespace esphome::mics_4514 { ESPHOME_LOG_TAG(TAG, "mics_4514"); +static constexpr uint32_t WARMUP_TIMEOUT_ID = 0; + static const uint8_t SENSOR_REGISTER = 0x04; static const uint8_t POWER_MODE_REGISTER = 0x0a; @@ -18,7 +20,7 @@ void MICS4514Component::setup() { power_mode = 0x01; this->write_register(POWER_MODE_REGISTER, &power_mode, 1); delay(100); // NOLINT - this->set_timeout("warmup", 3 * 60 * 1000, [this]() { this->warmed_up_ = true; }); + this->set_timeout(WARMUP_TIMEOUT_ID, 3 * 60 * 1000, [this]() { this->warmed_up_ = true; }); this->status_set_warning(); return; } diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 82d12a57898..e47b7d8c30b 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -7,6 +7,8 @@ namespace esphome::mlx90393 { ESPHOME_LOG_TAG(TAG, "mlx90393"); +static constexpr uint32_t VERIFY_SETTINGS_TIMEOUT_ID = 0; + const LogString *settings_to_string(MLX90393Setting setting) { switch (setting) { case MLX90393_GAIN_SEL: @@ -114,7 +116,7 @@ void MLX90393Cls::setup() { } // start verify settings process - this->set_timeout("verify settings", 3000, [this]() { this->verify_settings_timeout_(MLX90393_GAIN_SEL); }); + this->set_timeout(VERIFY_SETTINGS_TIMEOUT_ID, 3000, [this]() { this->verify_settings_timeout_(MLX90393_GAIN_SEL); }); } void MLX90393Cls::dump_config() { @@ -268,7 +270,8 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { next_stage = static_cast(0); } - this->set_timeout("verify settings", 3000, [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); + this->set_timeout(VERIFY_SETTINGS_TIMEOUT_ID, 3000, + [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); } } // namespace esphome::mlx90393 diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 8b275e53f2e..8f66d704fe6 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -6,6 +6,9 @@ namespace esphome::ms5611 { ESPHOME_LOG_TAG(TAG, "ms5611"); +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 0; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 1; + static const uint8_t MS5611_ADDRESS = 0x77; static const uint8_t MS5611_CMD_ADC_READ = 0x00; static const uint8_t MS5611_CMD_RESET = 0x1E; @@ -44,7 +47,7 @@ void MS5611Component::update() { return; } - this->set_timeout("temperature", 10, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 10, [this]() { this->read_temperature_(); }); } void MS5611Component::read_temperature_() { uint8_t bytes[3]; @@ -60,7 +63,7 @@ void MS5611Component::read_temperature_() { return; } - this->set_timeout("pressure", 10, [this, raw_temperature]() { this->read_pressure_(raw_temperature); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 10, [this, raw_temperature]() { this->read_pressure_(raw_temperature); }); } void MS5611Component::read_pressure_(uint32_t raw_temperature) { uint8_t bytes[3]; diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index 832e7ab2d94..2e20c4e0ea5 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -9,6 +9,12 @@ namespace esphome::ms8607 { /// TAG used for logging calls ESPHOME_LOG_TAG(TAG, "ms8607"); +static constexpr uint32_t RESET_TIMEOUT_ID = 0; +static constexpr uint32_t PROM_READ_TIMEOUT_ID = 1; +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 2; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 3; +static constexpr uint32_t HUMIDITY_TIMEOUT_ID = 4; + /// Reset the Pressure/Temperature sensor static const uint8_t MS8607_PT_CMD_RESET = 0x1E; @@ -97,7 +103,7 @@ void MS8607Component::try_reset_() { if (--this->reset_attempts_remaining_ > 0) { uint32_t delay = this->reset_interval_; this->reset_interval_ *= 5; - this->set_timeout("reset", delay, [this]() { this->try_reset_(); }); + this->set_timeout(RESET_TIMEOUT_ID, delay, [this]() { this->try_reset_(); }); this->status_set_error(); } else { this->mark_failed(); @@ -110,7 +116,7 @@ void MS8607Component::try_reset_() { this->status_clear_error(); // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library - this->set_timeout("prom-read", 15, [this]() { + this->set_timeout(PROM_READ_TIMEOUT_ID, 15, [this]() { if (this->read_calibration_values_from_prom_()) { this->setup_status_ = SetupStatus::SUCCESSFUL; this->status_clear_error(); @@ -251,7 +257,7 @@ void MS8607Component::request_read_temperature_() { } // datasheet says 17.2ms max conversion time at OSR 8192 - this->set_timeout("temperature", 20, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 20, [this]() { this->read_temperature_(); }); } void MS8607Component::read_temperature_() { @@ -272,7 +278,8 @@ void MS8607Component::request_read_pressure_(uint32_t d2_raw_temperature) { } // datasheet says 17.2ms max conversion time at OSR 8192 - this->set_timeout("pressure", 20, [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 20, + [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); } void MS8607Component::read_pressure_(uint32_t d2_raw_temperature) { @@ -293,7 +300,7 @@ void MS8607Component::request_read_humidity_(float temperature_float) { } // datasheet says 15.89ms max conversion time at OSR 8192 - this->set_timeout("humidity", 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); + this->set_timeout(HUMIDITY_TIMEOUT_ID, 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); } void MS8607Component::read_humidity_(float temperature_float) { From 541562451e5494e81fba963fb96919003af4c9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:10:48 -1000 Subject: [PATCH 11/79] [sprinkler] Key timers by index and keep them in a fixed array (#20379) --- esphome/components/sprinkler/sprinkler.cpp | 16 +++++++--------- esphome/components/sprinkler/sprinkler.h | 6 +++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index a3c264da582..eb9e61958f5 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -322,13 +322,11 @@ optional SprinklerValveRunRequest::valve_as_opt() { SprinklerValveOperator *SprinklerValveRunRequest::valve_operator() { return this->valve_op_; } Sprinkler::Sprinkler() : Sprinkler("") {} -Sprinkler::Sprinkler(const char *name) : name_(name) { - // The `name` is stored for dump_config logging - this->timer_.init(2); - // Timer names only need to be unique within this component instance - this->timer_.push_back({"sm", false, 0, 0, [this]() { this->sm_timer_callback_(); }}); - this->timer_.push_back({"vs", false, 0, 0, [this]() { this->valve_selection_callback_(); }}); -} +// The `name` is stored for dump_config logging; the scheduler keys each timer by its SprinklerTimerIndex +Sprinkler::Sprinkler(const char *name) + : name_(name), + timer_{{{false, 0, 0, [this]() { this->sm_timer_callback_(); }}, + {false, 0, 0, [this]() { this->valve_selection_callback_(); }}}} {} void Sprinkler::setup() { this->all_valves_off_(true); @@ -1510,7 +1508,7 @@ const LogString *Sprinkler::state_as_str_(SprinklerState state) { void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { if (this->timer_duration_(timer_index) > 0) { - this->set_timeout(this->timer_[timer_index].name, this->timer_duration_(timer_index), + this->set_timeout(static_cast(timer_index), this->timer_duration_(timer_index), this->timer_cbf_(timer_index)); this->timer_[timer_index].start_time = millis(); this->timer_[timer_index].active = true; @@ -1521,7 +1519,7 @@ void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { bool Sprinkler::cancel_timer_(const SprinklerTimerIndex timer_index) { this->timer_[timer_index].active = false; - return this->cancel_timeout(this->timer_[timer_index].name); + return this->cancel_timeout(static_cast(timer_index)); } bool Sprinkler::timer_active_(const SprinklerTimerIndex timer_index) { return this->timer_[timer_index].active; } diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2499a0a5915..c51824d8833 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -7,6 +7,7 @@ #include "esphome/components/number/number.h" #include "esphome/components/switch/switch.h" +#include #include namespace esphome::sprinkler { @@ -49,7 +50,6 @@ struct SprinklerQueueItem { }; struct SprinklerTimer { - const char *name; bool active; uint32_t time; uint32_t start_time; @@ -564,8 +564,8 @@ class Sprinkler final : public Component { /// Sprinkler valve operator objects std::vector valve_op_{2}; - /// Valve control timers - FixedVector enforces that this can never grow beyond init() size - FixedVector timer_; + /// Valve control timers, indexed by SprinklerTimerIndex (also used as the scheduler id) + std::array timer_; /// Other Sprinkler instances we should be aware of (used to check if pumps are in use) std::vector other_controllers_; From 3485297e6e586c70406cc53565b1786bd85d20ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:01 -1000 Subject: [PATCH 12/79] [core] Use numeric scheduler ids instead of string names (4/5) (#20377) --- esphome/components/opt3001/opt3001.cpp | 7 +++++-- esphome/components/output/button/output_button.cpp | 6 ++++-- esphome/components/pid/pid_climate.cpp | 4 +++- .../remote_transmitter/remote_transmitter_rmt.cpp | 6 ++++-- esphome/components/scd30/scd30.cpp | 4 +++- esphome/components/spa06_base/spa06_base.cpp | 4 +++- esphome/components/tfluna/tfluna.cpp | 10 ++++++---- esphome/components/tmp102/tmp102.cpp | 4 +++- esphome/components/tsl2561/tsl2561.cpp | 4 +++- esphome/components/tsl2591/tsl2591.cpp | 10 +++++----- 10 files changed, 39 insertions(+), 20 deletions(-) diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index c6692d8dadb..0101368cebd 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -5,6 +5,9 @@ namespace esphome::opt3001 { ESPHOME_LOG_TAG(TAG, "opt3001.sensor"); +static constexpr uint32_t RESULT_RETRY_TIMEOUT_ID = 0; +static constexpr uint32_t READ_TIMEOUT_ID = 1; + static const uint8_t OPT3001_REG_RESULT = 0x00; static const uint8_t OPT3001_REG_CONFIGURATION = 0x01; // See datasheet for full description of each bit. @@ -42,7 +45,7 @@ void OPT3001Sensor::read_result_(const std::function &f) { if ((raw_value & OPT3001_CONFIGURATION_CONVERSION_MODE_MASK) != OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN) { // not ready; wait 10ms and try again ESP_LOGW(TAG, "Data not ready; waiting 10ms"); - this->set_timeout("opt3001_wait", 10, [this, f]() { read_result_(f); }); + this->set_timeout(RESULT_RETRY_TIMEOUT_ID, 10, [this, f]() { read_result_(f); }); return; } @@ -70,7 +73,7 @@ void OPT3001Sensor::read_lx_(const std::function &f) { return; } - this->set_timeout("read", OPT3001_CONVERSION_TIME_800, [this, f]() { + this->set_timeout(READ_TIMEOUT_ID, OPT3001_CONVERSION_TIME_800, [this, f]() { if (this->write(&OPT3001_REG_CONFIGURATION, 1) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Starting configuration register read failed"); f(NAN); diff --git a/esphome/components/output/button/output_button.cpp b/esphome/components/output/button/output_button.cpp index 6ebbae13d68..17ffc39d7f0 100644 --- a/esphome/components/output/button/output_button.cpp +++ b/esphome/components/output/button/output_button.cpp @@ -5,6 +5,8 @@ namespace esphome::output { ESPHOME_LOG_TAG(TAG, "output.button"); +static constexpr uint32_t RESET_TIMEOUT_ID = 0; + void OutputButton::dump_config() { LOG_BUTTON("", "Output Button", this); ESP_LOGCONFIG(TAG, " Duration: %.1fs", this->duration_ / 1e3f); @@ -12,8 +14,8 @@ void OutputButton::dump_config() { void OutputButton::press_action() { this->output_->turn_on(); - // Use a named timeout so that it's automatically cancelled if button is pressed again before it's reset - this->set_timeout("reset", this->duration_, [this]() { this->output_->turn_off(); }); + // One timeout id, so a second press restarts the reset instead of stacking another + this->set_timeout(RESET_TIMEOUT_ID, this->duration_, [this]() { this->output_->turn_off(); }); } } // namespace esphome::output diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 67585f65e8c..d33a7f75441 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -5,6 +5,8 @@ namespace esphome::pid { ESPHOME_LOG_TAG(TAG, "pid.climate"); +static constexpr uint32_t AUTOTUNE_PROGRESS_INTERVAL_ID = 0; + bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { if (threshold_low > threshold_high) { ESP_LOGW(TAG, "Deadband threshold low %.2f must not be greater than high %.2f", threshold_low, threshold_high); @@ -191,7 +193,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { "Until your sensor provides a reading, the autotuner may display \'nan\'", this->get_name().c_str()); - this->set_interval("autotune-progress", 10000, [this]() { + this->set_interval(AUTOTUNE_PROGRESS_INTERVAL_ID, 10000, [this]() { if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) this->autotuner_->dump_config(); }); diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index f6550d107c6..22a63ca4fd3 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -11,6 +11,8 @@ namespace esphome::remote_transmitter { ESPHOME_LOG_TAG(TAG, "remote_transmitter"); +static constexpr uint32_t COMPLETE_TIMEOUT_ID = 0; + // Maximum RMT symbol duration (15-bit field) static constexpr uint32_t RMT_SYMBOL_DURATION_MAX = 0x7FFF; @@ -206,7 +208,7 @@ void RemoteTransmitterComponent::wait_for_rmt_() { #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) void RemoteTransmitterComponent::flush_pending_completion() { // a frame still on the wire is waited out, and its completion reported, before the next one - if (this->non_blocking_ && this->cancel_timeout("complete")) { + if (this->non_blocking_ && this->cancel_timeout(COMPLETE_TIMEOUT_ID)) { this->wait_for_rmt_(); } } @@ -292,7 +294,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->status_clear_warning(); if (this->non_blocking_) { - this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); }); + this->set_timeout(COMPLETE_TIMEOUT_ID, total_duration / 1000, [this]() { this->wait_for_rmt_(); }); } else { this->wait_for_rmt_(); } diff --git a/esphome/components/scd30/scd30.cpp b/esphome/components/scd30/scd30.cpp index 3e8794e65c7..5802503627a 100644 --- a/esphome/components/scd30/scd30.cpp +++ b/esphome/components/scd30/scd30.cpp @@ -10,6 +10,8 @@ namespace esphome::scd30 { ESPHOME_LOG_TAG(TAG, "scd30"); +static constexpr uint32_t STATUS_CHECK_INTERVAL_ID = 0; + static const uint16_t SCD30_CMD_GET_FIRMWARE_VERSION = 0xd100; static const uint16_t SCD30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0010; static const uint16_t SCD30_CMD_ALTITUDE_COMPENSATION = 0x5102; @@ -107,7 +109,7 @@ void SCD30Component::setup() { } // check each 500ms if data is ready, and read it in that case - this->set_interval("status-check", 500, [this]() { + this->set_interval(STATUS_CHECK_INTERVAL_ID, 500, [this]() { if (this->is_data_ready_()) this->update(); }); diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index 987a14c66a8..02defd07735 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -8,6 +8,8 @@ namespace esphome::spa06_base { ESPHOME_LOG_TAG(TAG, "spa06"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + // Sign extension function for <=16 bit types inline int16_t decode16(uint8_t msb, uint8_t lsb, size_t bits, size_t head = 0) { return static_cast(encode_uint16(msb, lsb) << head) >> (16 - bits); @@ -243,7 +245,7 @@ void SPA06Component::update() { } // Queue a background task for retrieving the measurement - this->set_timeout("measurement", this->conversion_time_, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, this->conversion_time_, [this]() { float raw_temperature; float temperature = 0.0; float pressure = 0.0; diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index 0936a07f49c..e3d7045d8e6 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -17,7 +17,9 @@ static constexpr uint8_t TRIGGER_ONESHOT_REGISTER = 0x24; static constexpr uint8_t MAX_READ_ATTEMPTS = 5; static constexpr uint8_t READ_RETRY_MS = 5; ESPHOME_LOG_TAG(TAG, "tfluna"); -static const char *const READ_RETRY_TIMEOUT = "read_retry"; + +static constexpr uint32_t SETUP_TIMEOUT_ID = 0; +static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 1; void TFLuna::dump_config() { ESP_LOGCONFIG(TAG, "TF-Luna (i2c):"); @@ -121,7 +123,7 @@ void TFLuna::read_data_timeout_() { } else { if (this->attempt_ < MAX_READ_ATTEMPTS) { this->attempt_++; - this->set_timeout(READ_RETRY_TIMEOUT, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); + this->set_timeout(READ_RETRY_TIMEOUT_ID, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); } else { this->status_set_warning("Hung device, restarting..."); this->restart(); @@ -130,7 +132,7 @@ void TFLuna::read_data_timeout_() { } void TFLuna::update() { - this->cancel_timeout(READ_RETRY_TIMEOUT); + this->cancel_timeout(READ_RETRY_TIMEOUT_ID); this->attempt_ = 0; if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); @@ -145,7 +147,7 @@ void TFLuna::factory_reset() { return; } this->status_set_warning("Factory reset issued; waiting for device to become ready"); - this->set_timeout("_setup", 100, [this]() { this->setup(); }); + this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->setup(); }); } void TFLuna::restart() { diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 4f10555324e..d851bd925fa 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -6,6 +6,8 @@ namespace esphome::tmp102 { ESPHOME_LOG_TAG(TAG, "tmp102"); +static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; + static const uint8_t TMP102_ADDRESS = 0x48; static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; @@ -29,7 +31,7 @@ void TMP102Component::update() { this->status_set_warning(); return; } - this->set_timeout("read_temp", 50, [this]() { + this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { this->status_set_warning(); diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 989a15f054b..d1d1d66a265 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -5,6 +5,8 @@ namespace esphome::tsl2561 { ESPHOME_LOG_TAG(TAG, "tsl2561"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const uint8_t TSL2561_COMMAND_BIT = 0x80; static const uint8_t TSL2561_WORD_BIT = 0x20; static const uint8_t TSL2561_REGISTER_CONTROL = 0x00; @@ -60,7 +62,7 @@ void TSL2561Sensor::update() { // Make sure the data is there when we will read it. auto timeout = static_cast(this->get_integration_time_ms_() + 20); - this->set_timeout("illuminance", timeout, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, timeout, [this]() { this->read_data_(); }); } float TSL2561Sensor::calculate_lx_(uint16_t ch0, uint16_t ch1) { diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index dfc0dfedf4e..f1f9ae9cfdb 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -6,6 +6,8 @@ namespace esphome::tsl2591 { ESPHOME_LOG_TAG(TAG, "tsl2591.sensor"); +static constexpr uint32_t ADC_WAIT_INTERVAL_ID = 0; + // Various constants used in TSL2591 register manipulation #define TSL2591_COMMAND_BIT (0xA0) // 1010 0000: bits 7 and 5 for 'command, normal' #define TSL2591_ENABLE_POWERON (0x01) // Flag for ENABLE register, to enable @@ -163,19 +165,17 @@ void TSL2591Component::process_update_() { this->status_clear_warning(); } -#define interval_name "tsl2591_interval_for_update" - void TSL2591Component::interval_function_for_update_() { if (!this->is_adc_valid()) { uint64_t now = millis(); ESP_LOGD(TAG, "Elapsed %3llu ms; still waiting for valid ADC", (now - this->interval_start_)); if (now > this->interval_timeout_) { ESP_LOGW(TAG, "Interval timeout for '%s' expired before ADCs became valid", this->name_); - this->cancel_interval(interval_name); + this->cancel_interval(ADC_WAIT_INTERVAL_ID); } return; } - this->cancel_interval(interval_name); + this->cancel_interval(ADC_WAIT_INTERVAL_ID); this->process_update_(); } @@ -191,7 +191,7 @@ void TSL2591Component::update() { } else { this->interval_start_ = millis(); this->interval_timeout_ = this->interval_start_ + 620; - this->set_interval(interval_name, 100, [this] { this->interval_function_for_update_(); }); + this->set_interval(ADC_WAIT_INTERVAL_ID, 100, [this] { this->interval_function_for_update_(); }); } } } From 8a09e49e3158a94bad14ae977e4b2932b4423149 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:18 -1000 Subject: [PATCH 13/79] [ci] Skip the memory impact target build when the base test is missing (#20410) --- .github/workflows/ci.yml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf59f073fa..586263c6514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1422,31 +1422,27 @@ jobs: echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis" fi - # Check if test files exist on the target branch for the requested - # components and platform. When a PR adds new test files for a platform, - # the target branch won't have them yet, so skip instead of failing. - # This check must be done here (not in determine-jobs.py) because - # determine-jobs runs on the PR branch and cannot see what the target - # branch has. + # The build below uses --base-only, so skip unless the target branch has + # test..yaml for every requested component (test-*.yaml + # variants alone compile nothing, and a partial set would compare a + # smaller target build against the PR build). Checked here, not in + # determine-jobs.py, because determine-jobs runs on the PR branch and + # cannot see the target branch. - name: Check for test files on target branch id: check-tests if: steps.check-script.outputs.skip != 'true' run: | components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}' platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}" - found=false + missing="" for component in $(echo "$components" | jq -r '.[]'); do - # Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml) - for f in tests/components/${component}/test*.${platform}.yaml; do - if [ -f "$f" ]; then - found=true - break 2 - fi - done + if [ ! -f "tests/components/${component}/test.${platform}.yaml" ]; then + missing="${missing} ${component}" + fi done - if [ "$found" = false ]; then + if [ -n "$missing" ]; then echo "skip=true" >> $GITHUB_OUTPUT - echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis" + echo "::warning::No test.${platform}.yaml on target branch for${missing}, skipping memory impact analysis" else echo "skip=false" >> $GITHUB_OUTPUT fi From 7ac679aa046073e739f4e1ec0866943f6f09988d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:30 -1000 Subject: [PATCH 14/79] [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index c94ea1df9cd..16fd5d62829 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.2", - "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", - 37192128, + "3.1.2-esphome.3", + "3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db", + 37185650, ), } From 56e6e332cbd4233e5438d5ff50d818c2339a6964 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:45 -1000 Subject: [PATCH 15/79] [ci] Skip the static analyzer on the generated protobuf dump file (#20414) --- script/clang-tidy | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/script/clang-tidy b/script/clang-tidy index f97e9a482eb..a2becb59cc8 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -279,6 +279,14 @@ def clang_options(idedata, environment): pids = set() +# Generated files the static analyzer is skipped on. The analyzer inlines the +# small dump helpers into every generated dump function and takes +# over 200 s on this file in CI, 99% of its clang-tidy time; any finding there +# would be fixed in script/api_protobuf anyway. The other checks still run. +ANALYZER_SKIPPED_FILES: frozenset[str] = frozenset( + {"esphome/components/api/api_pb2_dump.cpp"} +) + def run_tidy( executable, args, options, tmpdir, path_queue, lock, failed_files, timings @@ -302,16 +310,22 @@ def run_tidy( if sys.stdout.isatty(): invocation.append("--use-color") + disabled_checks = [] if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all # fixed-address. # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher # recursion on lvgl_esphome.h under the RP2 defines. - invocation.append( - "--checks=-clang-analyzer-core.FixedAddressDereference," - "-bugprone-pointer-arithmetic-on-polymorphic-object,-cert-ctr56-cpp" - ) + disabled_checks += [ + "-clang-analyzer-core.FixedAddressDereference", + "-bugprone-pointer-arithmetic-on-polymorphic-object", + "-cert-ctr56-cpp", + ] + if os.path.normpath(path) in ANALYZER_SKIPPED_FILES: + disabled_checks.append("-clang-analyzer-*") + if disabled_checks: + invocation.append("--checks=" + ",".join(disabled_checks)) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") invocation.append(str(Path(path).resolve())) invocation.append("--") From d4d5ee87cb343cc0f320890bc926c7671cec377a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:12:14 -1000 Subject: [PATCH 16/79] [ir_rf_proxy] Collapse duplicate CI test variants (#20399) --- .../components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml | 9 --------- tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml | 7 ------- tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml | 7 ------- tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml | 7 ------- tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml | 7 ------- tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml | 7 ------- 6 files changed, 44 deletions(-) delete mode 100644 tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml delete mode 100644 tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml delete mode 100644 tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml diff --git a/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml deleted file mode 100644 index e25c47ab236..00000000000 --- a/tests/components/ir_rf_proxy/test-cc1101.esp8266-ard.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - cs_pin: GPIO5 - gdo0_pin: GPIO4 - gdo2_pin: GPIO16 - -packages: - common: !include common.yaml - spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml - cc1101: !include common-cc1101.yaml diff --git a/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml deleted file mode 100644 index 8172885b31b..00000000000 --- a/tests/components/ir_rf_proxy/test-rx.esp8266-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml deleted file mode 100644 index 8172885b31b..00000000000 --- a/tests/components/ir_rf_proxy/test-rx.rp2040-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - rx: !include common-rx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml b/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml deleted file mode 100644 index 7162f15b2dd..00000000000 --- a/tests/components/ir_rf_proxy/test-tx.esp32-idf.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml b/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml deleted file mode 100644 index 7162f15b2dd..00000000000 --- a/tests/components/ir_rf_proxy/test-tx.esp8266-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - tx: !include common-tx.yaml diff --git a/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml b/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml deleted file mode 100644 index 7162f15b2dd..00000000000 --- a/tests/components/ir_rf_proxy/test-tx.rp2040-ard.yaml +++ /dev/null @@ -1,7 +0,0 @@ -substitutions: - tx_pin: GPIO4 - rx_pin: GPIO5 - -packages: - common: !include common.yaml - tx: !include common-tx.yaml From 24237a07c9f13ca3c561ac2cfb858e57163e2e17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:12:35 -1000 Subject: [PATCH 17/79] [ci] Refit component batch estimates to a full run (#20401) --- script/split_components_for_ci.py | 172 ++++++++++++++----- tests/script/test_determine_jobs.py | 14 +- tests/script/test_split_components_for_ci.py | 121 +++++++++++-- 3 files changed, 248 insertions(+), 59 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index cc458d5d664..26907936c17 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -13,15 +13,20 @@ from __future__ import annotations import argparse from collections import defaultdict +from collections.abc import Set as AbstractSet +import contextlib from dataclasses import dataclass, field +import functools import json import math from pathlib import Path import sys +from typing import Any, NamedTuple # Add esphome to path sys.path.insert(0, str(Path(__file__).parent.parent)) +from esphome import yaml_util from script.analyze_component_buses import ( ISOLATED_COMPONENTS, ISOLATED_SIGNATURE_PREFIX, @@ -31,29 +36,52 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, ) from script.helpers import ( + _extract_components_from_yaml, get_component_test_files, parse_test_filename, split_conflicting_groups, ) -# Estimated CI seconds for one build per test platform, read from batch job -# logs. Absolute accuracy does not matter, only the ratios between platforms. -# A full run takes over an hour, so recording real timings is not practical. +# Estimated CI seconds for one build per test platform, fitted to the batch +# job logs of a full run (every component, PR #20389). Absolute accuracy does +# not matter, only the ratios between platforms. A full run takes over an +# hour, so recording real timings is not practical. PLATFORM_BUILD_SECONDS = { "host": 10, "esp8266-ard": 30, - "esp32-idf": 45, - "rtl87xx-ard": 35, - "ln882x-ard": 50, - "rp2040-ard": 50, - "rp2350-ard": 50, - "bk72xx-ard": 60, + "rtl87xx-ard": 40, + "esp32-c3-idf": 40, + "esp32-s2-idf": 40, + "rp2350-ard": 40, + "rp2040-ard": 45, + "esp32-idf": 55, + "esp32-p4-idf": 55, + "ln882x-ard": 55, + "esp32-s3-idf": 60, + "bk72xx-ard": 70, + "esp32-c6-idf": 70, } -NRF52_BUILD_SECONDS = 45 -# Other ESP32 chips and Arduino on ESP32 share less of the ccache -DEFAULT_BUILD_SECONDS = 90 +NRF52_BUILD_SECONDS = 50 +# Arduino on any ESP32 chip builds the IDF plus the Arduino core on top +ESP32_ARDUINO_BUILD_SECONDS = 90 +# The remaining ESP32 chips on ESP-IDF +DEFAULT_BUILD_SECONDS = 50 # Each extra component merged into a grouped build makes it larger -GROUPED_COMPONENT_SECONDS = 5 +GROUPED_COMPONENT_SECONDS = 3 +# Components that pull in a large library the build compiles from source +# (TensorFlow Lite Micro, FastLED, LVGL, the HTTP server), added to every +# build whose test config enables them. Fitted the same way as the platforms. +HEAVY_COMPONENT_SECONDS: dict[str, int] = { + "micro_wake_word": 200, + "fastled_clockless": 90, + "fastled_spi": 90, + "wled": 60, + "lvgl": 50, + "web_server": 50, + "online_image": 45, + "prometheus": 35, + "bluetooth_proxy": 30, +} # Estimated build seconds per CI runner; sets how many runners are used. # Approximate: the runner count charges each grouped build once, but a large # group spreads over several runners, which each pay for that build. @@ -90,9 +118,62 @@ def build_seconds(platform: str) -> int: return seconds if platform.startswith("nrf52"): return NRF52_BUILD_SECONDS + if platform.startswith("esp32") and platform.endswith("-ard"): + return ESP32_ARDUINO_BUILD_SECONDS return DEFAULT_BUILD_SECONDS +def _config_components(data: Any) -> set[str]: + """Return the components a loaded test config and its ``packages:`` use.""" + if not isinstance(data, dict): + return set() + found = _extract_components_from_yaml(data) + if isinstance(packages := data.get("packages"), dict): + for package in packages.values(): + if isinstance(package, yaml_util.IncludeFile): + found |= _file_components( + (package.parent_file.parent / package.file).resolve() + ) + else: + found |= _config_components(package) + return found + + +@functools.cache +def _file_components(path: Path) -> frozenset[str]: + """Return the components a config file uses; shared packages load once.""" + return frozenset( + _config_components(yaml_util.load_yaml(path, track_document_range=False)) + ) + + +def heavy_components(component: str, test_file: Path) -> frozenset[str]: + """Return the heavy components a test compiles. + + A test counts as heavy when it belongs to a heavy component or when its + config enables one, such as a display test that draws with lvgl. + """ + heavy = {component} & HEAVY_COMPONENT_SECONDS.keys() + # A config that does not load is left to the build to report + with contextlib.suppress(Exception): + heavy |= _file_components(test_file.resolve()) & HEAVY_COMPONENT_SECONDS.keys() + return frozenset(heavy) + + +def heavy_seconds(heavy: AbstractSet[str]) -> int: + """Return the library seconds a set of heavy components adds to a build.""" + return sum(HEAVY_COMPONENT_SECONDS[name] for name in heavy) + + +class _GroupedShare(NamedTuple): + """A component's part in one (signature, platform) shared build.""" + + # Seconds of its files on that platform when no other member shares it + solo: int + # Heavy components its base test brings into the shared build + heavy: frozenset[str] + + @dataclass class _BatchItem: """A component and the builds it adds to the batch it lands in.""" @@ -101,40 +182,52 @@ class _BatchItem: # Builds that always run on their own (isolated tests, variants on a # platform without a base test) own_seconds: int - # Per (signature, platform) shared build: the seconds of this component's - # files on that platform when no other batch member shares the build - grouped_builds: dict[tuple[str, str], int] = field(default_factory=dict) + grouped_builds: dict[tuple[str, str], _GroupedShare] = field(default_factory=dict) def standalone_seconds(self) -> int: - return self.own_seconds + sum(self.grouped_builds.values()) + return self.own_seconds + sum( + share.solo for share in self.grouped_builds.values() + ) -def _grouped_build_seconds(platform: str, solo_seconds: list[int]) -> int: +def _grouped_build_seconds(platform: str, members: list[_GroupedShare]) -> int: """Return the seconds of one (signature, platform) build in a batch. + A library two members both enable compiles once, so the union is charged. test_build_components only groups when two or more members share the build; a grouped member then skips its variants on that platform. """ - if len(solo_seconds) <= 1: - return sum(solo_seconds) - return build_seconds(platform) + GROUPED_COMPONENT_SECONDS * (len(solo_seconds) - 1) + if len(members) <= 1: + return sum(share.solo for share in members) + return ( + build_seconds(platform) + + GROUPED_COMPONENT_SECONDS * (len(members) - 1) + + heavy_seconds(set().union(*(share.heavy for share in members))) + ) def _make_item( tests_dir: Path, component: str, signature: str, is_isolated: bool ) -> _BatchItem: - files_by_platform: dict[str, list[str]] = defaultdict(list) + # Per platform: the seconds of its test files, and the base test's heavy + # components; a platform without a base test cannot join a shared build + seconds_by_platform: dict[str, int] = defaultdict(int) + base_heavy: dict[str, frozenset[str]] = {} for test_file in (tests_dir / component).glob("test[.-]*.yaml"): test_name, platform = parse_test_filename(test_file) - files_by_platform[platform].append(test_name) + heavy = heavy_components(component, test_file) + seconds_by_platform[platform] += build_seconds(platform) + heavy_seconds(heavy) + if test_name == "test": + base_heavy[platform] = heavy own_seconds = 0 - grouped: dict[tuple[str, str], int] = {} - for platform, test_names in files_by_platform.items(): - seconds = build_seconds(platform) * len(test_names) - if is_isolated or "test" not in test_names: + grouped: dict[tuple[str, str], _GroupedShare] = {} + for platform, seconds in seconds_by_platform.items(): + if is_isolated or platform not in base_heavy: own_seconds += seconds else: - grouped[(signature, platform)] = seconds + grouped[(signature, platform)] = _GroupedShare( + seconds, base_heavy[platform] + ) return _BatchItem(component, own_seconds, grouped) @@ -144,22 +237,24 @@ class _Batch: def __init__(self) -> None: self.components: list[str] = [] self.seconds = 0 - self.grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + self.grouped_builds: dict[tuple[str, str], list[_GroupedShare]] = defaultdict( + list + ) def added_seconds(self, item: _BatchItem) -> int: """Return the seconds item would add; joining an existing build is cheap.""" added = item.own_seconds - for (signature, platform), solo in item.grouped_builds.items(): + for (signature, platform), share in item.grouped_builds.items(): members = self.grouped_builds.get((signature, platform), []) added += _grouped_build_seconds( - platform, [*members, solo] + platform, [*members, share] ) - _grouped_build_seconds(platform, members) return added def add(self, item: _BatchItem) -> None: self.seconds += self.added_seconds(item) - for build, solo in item.grouped_builds.items(): - self.grouped_builds[build].append(solo) + for build, share in item.grouped_builds.items(): + self.grouped_builds[build].append(share) self.components.append(item.component) @@ -172,14 +267,11 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """ if not items: return [] - grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + # One batch holding every item counts each grouped build once + whole = _Batch() for item in items: - for build, solo in item.grouped_builds.items(): - grouped_builds[build].append(solo) - total = sum(item.own_seconds for item in items) + sum( - _grouped_build_seconds(platform, solos) - for (_, platform), solos in grouped_builds.items() - ) + whole.add(item) + total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7e9b09b2b93..e63e0849deb 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2613,8 +2613,8 @@ def test_component_batching_beta_branch_groups_evenly( For beta/release branches, all components should be groupable (not isolated), so they share one grouped build per runner and spread evenly. """ - # Create 120 test components with test files - component_names = [f"comp_{i:03d}" for i in range(120)] + # Create 200 test components with test files + component_names = [f"comp_{i:03d}" for i in range(200)] tests_dir = tmp_path / "tests" / "components" for comp in component_names: @@ -2675,15 +2675,15 @@ def test_component_batching_beta_branch_groups_evenly( assert "component_test_batches" in output batches = output["component_test_batches"] - # One grouped esp32-idf build plus 5 s per extra component is 640 s, - # so two runners of 60 components each - assert [len(batch["components"].split()) for batch in batches] == [60, 60] + # One grouped esp32-idf build plus 3 s per extra component is 652 s, + # so two runners of 100 components each + assert [len(batch["components"].split()) for batch in batches] == [100, 100] - # Verify all 120 components are in batches + # Verify all 200 components are in batches all_components = [] for batch in batches: all_components.extend(batch["components"].split()) - assert len(all_components) == 120 + assert len(all_components) == 200 assert set(all_components) == set(component_names) diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 88a1a17b333..4da644d6798 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -35,13 +35,46 @@ def test_build_seconds() -> None: """Known platforms use the table; nrf52 boards share one estimate.""" build_seconds = split_components_for_ci.build_seconds assert build_seconds("host") == 10 - assert build_seconds("esp32-idf") == 45 + assert build_seconds("esp32-idf") == 55 assert ( build_seconds("nrf52-xiao-ble") == split_components_for_ci.NRF52_BUILD_SECONDS ) assert ( - build_seconds("esp32-s3-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS + build_seconds("esp32-c5-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS ) + assert ( + build_seconds("esp32-c3-ard") + == split_components_for_ci.ESP32_ARDUINO_BUILD_SECONDS + ) + + +def test_heavy_components(tests_dir: Path) -> None: + """A heavy component, or a test that enables one, costs its library time.""" + heavy_components = split_components_for_ci.heavy_components + heavy_seconds = split_components_for_ci.heavy_seconds + table = split_components_for_ci.HEAVY_COMPONENT_SECONDS + _add_component(tests_dir, "dht", ["test.esp32-idf.yaml"]) + _add_component(tests_dir, "micro_wake_word", ["test.esp32-idf.yaml"]) + assert heavy_components("dht", tests_dir / "dht/test.esp32-idf.yaml") == set() + assert heavy_components( + "micro_wake_word", tests_dir / "micro_wake_word/test.esp32-idf.yaml" + ) == {"micro_wake_word"} + assert heavy_seconds(frozenset({"micro_wake_word"})) == table["micro_wake_word"] + + display = tests_dir / "mipi_spi" + display.mkdir() + (display / "common.yaml").write_text("web_server:\n port: 80\n") + (display / "test-lvgl.esp32-s3-idf.yaml").write_text( + "packages:\n" + " mipi_spi: !include common.yaml\n" + "lvgl:\n pages: []\n" + "light:\n - platform: fastled_spi\n num_leds: 1\n" + ) + assert heavy_components("mipi_spi", display / "test-lvgl.esp32-s3-idf.yaml") == { + "lvgl", + "web_server", + "fastled_spi", + } def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: @@ -61,29 +94,93 @@ def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: isolated = split_components_for_ci._make_item( tests_dir, "comp", "isolated_comp", True ) - assert isolated.own_seconds == 45 + 10 + 45 + 50 + assert isolated.own_seconds == 55 + 10 + 55 + 45 assert isolated.grouped_builds == {} # The rp2040 variant has no base test to group with, so it always runs grouped = split_components_for_ci._make_item(tests_dir, "comp", "i2c", False) - assert grouped.own_seconds == 50 - assert grouped.grouped_builds == {("i2c", "esp32-idf"): 90, ("i2c", "host"): 10} + assert grouped.own_seconds == 45 + assert grouped.grouped_builds == { + ("i2c", "esp32-idf"): (110, frozenset()), + ("i2c", "host"): (10, frozenset()), + } + + +def test_make_item_heavy_component(tests_dir: Path) -> None: + """A heavy component's library cost follows it into every build.""" + _add_component(tests_dir, "lvgl", ["test.esp32-idf.yaml", "test-a.host.yaml"]) + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + + item = split_components_for_ci._make_item(tests_dir, "lvgl", "spi", False) + assert item.own_seconds == 10 + extra + assert item.grouped_builds == { + ("spi", "esp32-idf"): (55 + extra, frozenset({"lvgl"})) + } + + +def test_heavy_cost_is_per_platform(tests_dir: Path) -> None: + """A base test that enables a heavy component only charges its own platform.""" + comp = tests_dir / "web_server_idf" + comp.mkdir() + (comp / "test.esp32-idf.yaml").write_text("web_server:\n port: 80\n") + (comp / "test.host.yaml").write_text("logger:\n") + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["web_server"] + + item = split_components_for_ci._make_item( + tests_dir, "web_server_idf", "none", False + ) + assert item.grouped_builds == { + ("none", "esp32-idf"): (55 + extra, frozenset({"web_server"})), + ("none", "host"): (10, frozenset()), + } def test_grouped_component_joins_existing_build() -> None: """A second member turns a lone build into a group that skips variants.""" item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare batch = split_components_for_ci._Batch() - batch.add(item("a", 0, {("i2c", "esp32-idf"): 90})) - assert batch.seconds == 90 + batch.add(item("a", 0, {("i2c", "esp32-idf"): share(110, frozenset())})) + assert batch.seconds == 110 - other = item("b", 0, {("i2c", "esp32-idf"): 45, ("i2c", "host"): 10}) - grouped = 45 + split_components_for_ci.GROUPED_COMPONENT_SECONDS - assert batch.added_seconds(other) == grouped - 90 + 10 + other = item( + "b", + 0, + { + ("i2c", "esp32-idf"): share(55, frozenset()), + ("i2c", "host"): share(10, frozenset()), + }, + ) + grouped = 55 + split_components_for_ci.GROUPED_COMPONENT_SECONDS + assert batch.added_seconds(other) == grouped - 110 + 10 batch.add(other) assert batch.seconds == grouped + 10 +def test_heavy_component_charges_the_shared_build() -> None: + """A heavy member makes the whole grouped build slower, once per library.""" + item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare + batch = split_components_for_ci._Batch() + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + per_member = split_components_for_ci.GROUPED_COMPONENT_SECONDS + lvgl = frozenset({"lvgl"}) + batch.add(item("a", 0, {("spi", "esp32-idf"): share(55, frozenset())})) + + heavy = item("lvgl", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + grouped = 55 + per_member + extra + assert batch.added_seconds(heavy) == grouped - 55 + batch.add(heavy) + assert batch.seconds == grouped + + light = item("b", 0, {("spi", "esp32-idf"): share(55, frozenset())}) + assert batch.added_seconds(light) == per_member + + # A second member that also draws with lvgl compiles the library once + also_heavy = item("c", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + assert batch.added_seconds(also_heavy) == per_member + + def test_isolated_components_balance_across_runners(tests_dir: Path) -> None: """Heavy isolated components spread out and light ones fill the gaps.""" platforms = ["esp32-idf", "esp8266-ard", "rp2040-ard", "bk72xx-ard"] @@ -138,7 +235,7 @@ def test_groupable_components_split_evenly(tests_dir: Path) -> None: components=names, tests_dir=tests_dir, target_seconds=100 ) - assert sorted(len(batch) for batch in batches) == [2, 2, 2, 3, 3] + assert sorted(len(batch) for batch in batches) == [3, 3, 3, 3] assert sorted(c for batch in batches for c in batch) == names @@ -149,7 +246,7 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: _add_component(other, "comp", ["test.esp32-idf.yaml"]) item = split_components_for_ci._make_item(other, "comp", "isolated_comp", True) - assert item.own_seconds == 45 + assert item.own_seconds == 55 def test_balance_batches_empty() -> None: From 4c17ee739398a9ad368b0cfddd7c7de587c9cc94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:13:46 -1000 Subject: [PATCH 18/79] [core] Use numeric scheduler ids instead of string names (2/5) (#20375) --- esphome/components/cs5460a/cs5460a.cpp | 6 ++++-- esphome/components/ct_clamp/ct_clamp_sensor.cpp | 4 +++- esphome/components/current_based/current_based_cover.cpp | 6 ++++-- esphome/components/dps310/dps310.cpp | 4 +++- esphome/components/ens210/ens210.cpp | 4 +++- esphome/components/gp2y1010au0f/gp2y1010au0f.cpp | 5 ++++- esphome/components/hdc302x/hdc302x.cpp | 8 +++++--- esphome/components/honeywell_hih_i2c/honeywell_hih.cpp | 6 ++++-- esphome/components/honeywellabp2_i2c/honeywellabp2.cpp | 6 ++++-- esphome/components/hydreon_rgxx/hydreon_rgxx.cpp | 7 +++++-- 10 files changed, 39 insertions(+), 17 deletions(-) diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 6ef4588b171..efc0cc9fa1f 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -5,6 +5,8 @@ namespace esphome::cs5460a { ESPHOME_LOG_TAG(TAG, "cs5460a"); +static constexpr uint32_t STATUS_CHECK_TIMEOUT_ID = 0; + void CS5460AComponent::write_register_(enum CS5460ARegister addr, uint32_t value) { this->write_byte(CMD_WRITE | (addr << 1)); this->write_byte(value >> 16); @@ -149,13 +151,13 @@ void CS5460AComponent::schedule_next_check_() { this->status_momentary_warning("warning", 1000); } else { ESP_LOGCONFIG(TAG, "Device officially stuck, resetting"); - this->cancel_timeout("status-check"); + this->cancel_timeout(STATUS_CHECK_TIMEOUT_ID); this->hw_init_(); return; } } - this->set_timeout("status-check", time_left, [this]() { + this->set_timeout(STATUS_CHECK_TIMEOUT_ID, time_left, [this]() { if (!this->check_status_()) this->schedule_next_check_(); }); diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.cpp b/esphome/components/ct_clamp/ct_clamp_sensor.cpp index d9e4b2b8fa2..6488c468c34 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.cpp +++ b/esphome/components/ct_clamp/ct_clamp_sensor.cpp @@ -8,6 +8,8 @@ namespace esphome::ct_clamp { ESPHOME_LOG_TAG(TAG, "ct_clamp"); +static constexpr uint32_t READ_TIMEOUT_ID = 0; + void CTClampSensor::dump_config() { LOG_SENSOR("", "CT Clamp Sensor", this); ESP_LOGCONFIG(TAG, " Sample Duration: %.2fs", this->sample_duration_ / 1e3f); @@ -21,7 +23,7 @@ void CTClampSensor::update() { this->high_freq_.start(); // Set timeout for ending sampling phase - this->set_timeout("read", this->sample_duration_, [this]() { + this->set_timeout(READ_TIMEOUT_ID, this->sample_duration_, [this]() { this->is_sampling_ = false; this->high_freq_.stop(); diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 6311fd3cf3f..04743975715 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -8,6 +8,8 @@ namespace esphome::current_based { ESPHOME_LOG_TAG(TAG, "current_based.cover"); +static constexpr uint32_t ROLLBACK_TIMEOUT_ID = 0; + using namespace esphome::cover; CoverTraits CurrentBasedCover::get_traits() { @@ -73,7 +75,7 @@ void CurrentBasedCover::loop() { ESP_LOGD(TAG, "'%s' - Obstacle detected during opening.", this->name_.c_str()); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { - this->set_timeout("rollback", 300, [this]() { + this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); this->target_position_ = clamp(this->position - this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_CLOSING); @@ -94,7 +96,7 @@ void CurrentBasedCover::loop() { ESP_LOGD(TAG, "'%s' - Obstacle detected during closing.", this->name_.c_str()); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { - this->set_timeout("rollback", 300, [this]() { + this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); this->target_position_ = clamp(this->position + this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_OPENING); diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index abb34d6598a..080f101747f 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -6,6 +6,8 @@ namespace esphome::dps310 { ESPHOME_LOG_TAG(TAG, "dps310"); +static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 0; + void DPS310Component::setup() { uint8_t coef_data_raw[DPS310_NUM_COEF_REGS]; auto timer = DPS310_INIT_TIMEOUT; @@ -126,7 +128,7 @@ void DPS310Component::read_() { this->update_in_progress_ = false; this->status_clear_warning(); } else { - this->set_timeout("dps310", 10, [this]() { this->read_(); }); + this->set_timeout(READ_RETRY_TIMEOUT_ID, 10, [this]() { this->read_(); }); } } diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index fa1d4594070..5815fc90382 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -14,6 +14,8 @@ namespace esphome::ens210 { ESPHOME_LOG_TAG(TAG, "ens210"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + // ENS210 chip constants static const uint8_t ENS210_BOOTING_MS = 2; // Booting time in ms (also after reset, or going to high power) static const uint8_t ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS = @@ -149,7 +151,7 @@ void ENS210Component::update() { return; } // Wait for measurement to complete - this->set_timeout("data", uint32_t(ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS), [this]() { int temperature_data, temperature_status, humidity_data, humidity_status; uint8_t data[6]; uint32_t h_val_data, t_val_data; diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp index 552261155ff..ac98a01fd5d 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp @@ -7,6 +7,9 @@ namespace esphome::gp2y1010au0f { ESPHOME_LOG_TAG(TAG, "gp2y1010au0f"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const float MIN_VOLTAGE = 0.0f; static const float MAX_VOLTAGE = 4.0f; @@ -22,7 +25,7 @@ void GP2Y1010AU0FSensor::dump_config() { void GP2Y1010AU0FSensor::update() { is_sampling_ = true; - this->set_timeout("read", this->sample_duration_, [this]() { + this->set_timeout(READ_TIMEOUT_ID, this->sample_duration_, [this]() { this->is_sampling_ = false; if (this->num_samples_ == 0) return; diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index b45b3b612f1..d5428486579 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -8,6 +8,8 @@ namespace esphome::hdc302x { ESPHOME_LOG_TAG(TAG, "hdc302x.sensor"); +static constexpr uint32_t HEATER_OFF_TIMEOUT_ID = 0; + // Commands (per datasheet Table 7-4) static const uint8_t HDC302X_CMD_SOFT_RESET[2] = {0x30, 0xa2}; static const uint8_t HDC302X_CMD_CLEAR_STATUS_REGISTER[2] = {0x30, 0x41}; @@ -68,14 +70,14 @@ void HDC302XComponent::start_heater(uint16_t power, uint32_t duration_ms) { return; } this->heater_active_ = true; - this->cancel_timeout("heater_off"); + this->cancel_timeout(HEATER_OFF_TIMEOUT_ID); if (duration_ms > 0) { - this->set_timeout("heater_off", duration_ms, [this]() { this->stop_heater(); }); + this->set_timeout(HEATER_OFF_TIMEOUT_ID, duration_ms, [this]() { this->stop_heater(); }); } } void HDC302XComponent::stop_heater() { - this->cancel_timeout("heater_off"); + this->cancel_timeout(HEATER_OFF_TIMEOUT_ID); if (!this->disable_heater_()) { ESP_LOGW(TAG, "Heater stop failed"); } diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index a237f3bd609..352d3f7f01f 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -9,6 +9,8 @@ namespace esphome::honeywell_hih_i2c { ESPHOME_LOG_TAG(TAG, "honeywell_hih.i2c"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + static const uint8_t REQUEST_CMD[1] = {0x00}; // Measurement Request Format static const uint16_t MAX_COUNT = 0x3FFE; // 2^14 - 2 @@ -68,13 +70,13 @@ void HoneywellHIComponent::update() { this->start_measurement_(); // The measurement cycle duration is typically 36.65 ms for temperature and humidity readings. - this->set_timeout("meas_timeout", 100, [this] { this->measurement_timeout_(); }); + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 100, [this] { this->measurement_timeout_(); }); } void HoneywellHIComponent::loop() { if (this->measurement_running_ && this->is_measurement_ready_()) { this->measurement_running_ = false; - this->cancel_timeout("meas_timeout"); + this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data_(); } } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index 891eb4447df..2ca3fb821cc 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -11,6 +11,8 @@ static const uint8_t STATUS_MATH_SAT = 0; ESPHOME_LOG_TAG(TAG, "honeywellabp2"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + void HONEYWELLABP2Sensor::read_sensor_data() { if (this->read(raw_data_, 7) != i2c::ERROR_OK) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -62,7 +64,7 @@ float HONEYWELLABP2Sensor::get_temperature() { return this->last_temperature_; } void HONEYWELLABP2Sensor::loop() { if (this->measurement_running_) { if (this->is_measurement_ready()) { - this->cancel_timeout("meas_timeout"); + this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data(); if (pressure_sensor_ != nullptr) { @@ -79,7 +81,7 @@ void HONEYWELLABP2Sensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP2 Sensor"); this->start_measurement(); - this->set_timeout("meas_timeout", 100, [this] { this->measurement_timeout(); }); + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 100, [this] { this->measurement_timeout(); }); } void HONEYWELLABP2Sensor::dump_config() { diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 0afeb94bf3b..36a7b1b3511 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -4,6 +4,9 @@ namespace esphome::hydreon_rgxx { ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); + +static constexpr uint32_t REBOOT_INTERVAL_ID = 0; + static const int MAX_DATA_LENGTH_BYTES = 80; static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , @@ -138,7 +141,7 @@ void HydreonRGxxComponent::loop() { */ void HydreonRGxxComponent::schedule_reboot_() { this->boot_count_ = 0; - this->set_interval("reboot", 5000, [this]() { + this->set_interval(REBOOT_INTERVAL_ID, 5000, [this]() { if (this->boot_count_ < 0) { ESP_LOGW(TAG, "hydreon_rgxx failed to boot %d times", -this->boot_count_); } @@ -188,7 +191,7 @@ void HydreonRGxxComponent::process_line_() { } else { this->boot_count_++; } - this->cancel_interval("reboot"); + this->cancel_interval(REBOOT_INTERVAL_ID); this->no_response_count_ = 0; ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); From aba0c5d7ee4b3f87f444088a3f521157f638a0a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:14:19 -1000 Subject: [PATCH 19/79] [dallas_temp] Use a numeric scheduler id and drop the cached address string (#20361) --- esphome/components/dallas_temp/dallas_temp.cpp | 4 +++- esphome/components/one_wire/one_wire.cpp | 13 ------------- esphome/components/one_wire/one_wire.h | 13 ++++++------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 2721383ae84..45101e4981f 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -5,6 +5,8 @@ namespace esphome::dallas_temp { ESPHOME_LOG_TAG(TAG, "dallas.temp.sensor"); +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; @@ -44,7 +46,7 @@ void DallasTemperatureSensor::update() { this->send_command_(DALLAS_COMMAND_START_CONVERSION); - this->set_timeout(this->get_address_name().c_str(), this->millis_to_wait_for_conversion_(), [this] { + this->set_timeout(CONVERSION_TIMEOUT_ID, this->millis_to_wait_for_conversion_(), [this] { if (!this->read_scratch_pad_() || !this->check_scratch_pad_()) { this->publish_state(NAN); return; diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index 65a99623490..e9504720729 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -4,19 +4,6 @@ namespace esphome::one_wire { ESPHOME_LOG_TAG(TAG, "one_wire"); -const std::string &OneWireDevice::get_address_name() { - if (this->address_name_.empty()) { - char hex_buf[19]; // "0x" + 16 hex chars + null - this->address_name_ = format_hex_prefixed_to(hex_buf, this->address_); - } - return this->address_name_; -} - -void OneWireDevice::set_address(uint64_t address) { - this->address_ = address; - this->address_name_.clear(); -} - bool OneWireDevice::send_command_(uint8_t cmd) { if (!this->bus_->select(this->address_)) return false; diff --git a/esphome/components/one_wire/one_wire.h b/esphome/components/one_wire/one_wire.h index 4dbbe117927..e3abb95bb70 100644 --- a/esphome/components/one_wire/one_wire.h +++ b/esphome/components/one_wire/one_wire.h @@ -7,14 +7,17 @@ namespace esphome::one_wire { #define LOG_ONE_WIRE_DEVICE(this) \ - ESP_LOGCONFIG(TAG, " Address: %s (%s)", this->get_address_name().c_str(), \ - LOG_STR_ARG(this->bus_->get_model_str(this->address_ & 0xff))); + do { \ + char address_buf[format_hex_prefixed_size(sizeof(uint64_t))]; \ + ESP_LOGCONFIG(TAG, " Address: %s (%s)", format_hex_prefixed_to(address_buf, (this)->address_), \ + LOG_STR_ARG((this)->bus_->get_model_str((this)->address_ & 0xff))); \ + } while (0) class OneWireDevice { public: /// @brief store the address of the device /// @param address of the device - void set_address(uint64_t address); + void set_address(uint64_t address) { this->address_ = address; } void set_index(uint8_t index) { this->index_ = index; } @@ -22,16 +25,12 @@ class OneWireDevice { /// @param bus pointer to the OneWireBus object void set_one_wire_bus(OneWireBus *bus) { this->bus_ = bus; } - /// Helper to create (and cache) the name for this sensor. For example "0xfe0000031f1eaf29". - const std::string &get_address_name(); - protected: static constexpr uint8_t INDEX_NOT_SET = 255; uint64_t address_{0}; uint8_t index_{INDEX_NOT_SET}; OneWireBus *bus_{nullptr}; ///< pointer to OneWireBus instance - std::string address_name_; /// @brief find an address if necessary /// should be called from setup From 558de5ebee615f70670a5910d9875a708424713d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:14:39 -1000 Subject: [PATCH 20/79] [core] Use numeric scheduler ids instead of string names (5/5) (#20378) --- esphome/components/ufire_ec/ufire_ec.cpp | 4 +++- esphome/components/ufire_ise/ufire_ise.cpp | 4 +++- esphome/components/veml3235/veml3235.cpp | 4 +++- esphome/components/xgzp68xx/xgzp68xx.cpp | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index afa806d2466..1bb12229417 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -6,6 +6,8 @@ namespace esphome::ufire_ec { ESPHOME_LOG_TAG(TAG, "ufire_ec"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + void UFireECComponent::setup() { uint8_t version; if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) { @@ -45,7 +47,7 @@ void UFireECComponent::update() { } if (wait > 0) { - this->set_timeout("data", wait, [this]() { this->update_internal_(); }); + this->set_timeout(DATA_TIMEOUT_ID, wait, [this]() { this->update_internal_(); }); } } diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index c0fa7154859..aa899968e44 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -7,6 +7,8 @@ namespace esphome::ufire_ise { ESPHOME_LOG_TAG(TAG, "ufire_ise"); +static constexpr uint32_t DATA_TIMEOUT_ID = 0; + void UFireISEComponent::setup() { uint8_t version; if (!this->read_byte(REGISTER_VERSION, &version) || version == 0xFF) { @@ -38,7 +40,7 @@ void UFireISEComponent::update() { } // Wait until measurement are taken - this->set_timeout("data", wait, [this]() { this->update_internal_(); }); + this->set_timeout(DATA_TIMEOUT_ID, wait, [this]() { this->update_internal_(); }); } void UFireISEComponent::update_internal_() { diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 3d555bc469a..3a856696782 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -6,6 +6,8 @@ namespace esphome::veml3235 { ESPHOME_LOG_TAG(TAG, "veml3235.sensor"); +static constexpr uint32_t REREAD_TIMEOUT_ID = 0; + // ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot // be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead static const uint16_t CLIPPED_COUNTS = 64224; @@ -71,7 +73,7 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { const uint32_t old_integration_time_ms = this->integration_time_ms_(); if (this->adjust_sensitivity_(als_counts)) { const uint32_t wait_ms = old_integration_time_ms + 2 * this->integration_time_ms_(); - this->set_timeout("reread", wait_ms, + this->set_timeout(REREAD_TIMEOUT_ID, wait_ms, [this, adjustments_left]() { this->read_and_publish_(adjustments_left - 1); }); return; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 35e1ed950b8..795db448f0b 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -10,6 +10,8 @@ namespace esphome::xgzp68xx { ESPHOME_LOG_TAG(TAG, "xgzp68xx.sensor"); +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + static const uint8_t CMD_ADDRESS = 0x30; static const uint8_t SYSCONFIG_ADDRESS = 0xA5; static const uint8_t PCONFIG_ADDRESS = 0xA6; @@ -54,7 +56,7 @@ void XGZP68XXComponent::update() { this->write_register(0x30, &READ_COMMAND, 1); // Wait 20mS per datasheet - this->set_timeout("measurement", 20, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 20, [this]() { uint8_t data[5] = {}; uint32_t pressure_raw = 0; uint16_t temperature_raw = 0; From cea872be0baa370e039a6c557e8654eda7a1dfe1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:14:59 -1000 Subject: [PATCH 21/79] [modbus] Use a numeric scheduler id for the deferred server reply (#20368) --- esphome/components/modbus/modbus.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 7c56d2340ff..80065546cf7 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -11,6 +11,8 @@ namespace esphome::modbus { ESPHOME_LOG_TAG(TAG, "modbus"); +static constexpr uint32_t DEFERRED_SEND_TIMEOUT_ID = 0; + static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; static constexpr uint32_t US_PER_SEC = 1000000; @@ -205,7 +207,7 @@ void ModbusServerHub::parse_modbus_frames() { while (!this->rx_buffer_.empty()) { if (this->deferred_payload_len_ != 0) { // Another frame arrived before the deferred reply went out, so the client has moved on. - this->cancel_timeout("deferred_send"); + this->cancel_timeout(DEFERRED_SEND_TIMEOUT_ID); ESP_LOGD(TAG, "Dropped deferred reply to %" PRIu8 ": a new frame arrived first", this->deferred_payload_[0]); this->deferred_payload_len_ = 0; } @@ -1216,7 +1218,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; // set_timeout() takes milliseconds; round the microsecond delay up so we never fire early. - this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { + this->set_timeout(DEFERRED_SEND_TIMEOUT_ID, (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { ModbusFrame frame(this->deferred_payload_[0], this->deferred_payload_.data() + 1, this->deferred_payload_len_ - 1); this->deferred_payload_len_ = 0; From b6ee8f8035370025c5bafa7737d9d6f3cd446b65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:15:14 -1000 Subject: [PATCH 22/79] [power_supply] Use a numeric scheduler id for the power off timeout (#20367) --- esphome/components/power_supply/power_supply.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 7daee1663a4..3abffc88e15 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -5,6 +5,8 @@ namespace esphome::power_supply { ESPHOME_LOG_TAG(TAG, "power_supply"); +static constexpr uint32_t POWER_OFF_TIMEOUT_ID = 0; + void PowerSupply::setup() { this->pin_->setup(); this->pin_->digital_write(false); @@ -31,7 +33,7 @@ bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } void PowerSupply::request_high_power() { if (this->active_requests_ == 0) { - this->cancel_timeout("power-supply-off"); + this->cancel_timeout(POWER_OFF_TIMEOUT_ID); ESP_LOGV(TAG, "Enabling"); this->pin_->digital_write(true); delay(this->enable_time_); @@ -46,7 +48,7 @@ void PowerSupply::unrequest_high_power() { } this->active_requests_--; if (this->active_requests_ == 0) { - this->set_timeout("power-supply-off", this->keep_on_time_, [this]() { + this->set_timeout(POWER_OFF_TIMEOUT_ID, this->keep_on_time_, [this]() { ESP_LOGV(TAG, "Disabling"); this->pin_->digital_write(false); }); From 279f68c4324233d01e76b6934ac9ae0d08d6de27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:15:33 -1000 Subject: [PATCH 23/79] [tuya] Use numeric scheduler ids instead of string names (#20365) --- esphome/components/tuya/tuya.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 992dde15336..784aea5c50b 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -19,6 +19,10 @@ namespace esphome::tuya { ESPHOME_LOG_TAG(TAG, "tuya"); + +static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; +static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; +static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; @@ -34,7 +38,7 @@ static bool network_is_connected() { } void Tuya::setup() { - this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); + this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -223,7 +227,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff this->status_pin_reported_); } ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + this->set_interval(WIFI_STATUS_INTERVAL_ID, 1000, [this] { this->set_status_pin_(); }); } else { ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); @@ -231,7 +235,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); - this->set_interval("wifi", 1000, [this] { this->send_wifi_status_(); }); + this->set_interval(WIFI_STATUS_INTERVAL_ID, 1000, [this] { this->send_wifi_status_(); }); } } break; @@ -279,7 +283,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff case TuyaCommandType::DATAPOINT_REPORT_SYNC: if (this->init_state_ == TuyaInitState::INIT_DATAPOINT) { this->init_state_ = TuyaInitState::INIT_DONE; - this->set_timeout("datapoint_dump", 1000, [this] { this->dump_config(); }); + this->set_timeout(DATAPOINT_DUMP_TIMEOUT_ID, 1000, [this] { this->dump_config(); }); this->initialized_callback_.call(); } this->handle_datapoints_(buffer, len); From f89a60358295e1683c566a1d318599e441991af5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:15:59 -1000 Subject: [PATCH 24/79] [esp8266] Keep switch lookup tables out of RAM (#20398) --- esphome/components/esp8266/__init__.py | 2 ++ tests/component_tests/esp8266/__init__.py | 0 .../component_tests/esp8266/config/minimal.yaml | 5 +++++ .../component_tests/esp8266/test_build_flags.py | 17 +++++++++++++++++ 4 files changed, 24 insertions(+) create mode 100644 tests/component_tests/esp8266/__init__.py create mode 100644 tests/component_tests/esp8266/config/minimal.yaml create mode 100644 tests/component_tests/esp8266/test_build_flags.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d975d08f25e..d95cbd239b5 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -444,6 +444,8 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO") cg.add_build_flag("-Wno-nonnull-compare") + # .rodata is linked into RAM on ESP8266; keep switch lookup tables (CSWTCH) out of it + cg.add_build_flag("-fno-tree-switch-conversion") if use_platformio: cg.add_platformio_option("framework", "arduino") cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) diff --git a/tests/component_tests/esp8266/__init__.py b/tests/component_tests/esp8266/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp8266/config/minimal.yaml b/tests/component_tests/esp8266/config/minimal.yaml new file mode 100644 index 00000000000..79fde0157b5 --- /dev/null +++ b/tests/component_tests/esp8266/config/minimal.yaml @@ -0,0 +1,5 @@ +esphome: + name: test + +esp8266: + board: d1_mini diff --git a/tests/component_tests/esp8266/test_build_flags.py b/tests/component_tests/esp8266/test_build_flags.py new file mode 100644 index 00000000000..938089333bd --- /dev/null +++ b/tests/component_tests/esp8266/test_build_flags.py @@ -0,0 +1,17 @@ +"""Tests for ESP8266 build flags.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_esp8266_keeps_switches_out_of_ram( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("minimal.yaml")) + + assert "-fno-tree-switch-conversion" in CORE.build_flags From f5ce33a8debc727d2ce9c9480f6020771332fec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:16:17 -1000 Subject: [PATCH 25/79] [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 68fccac2880..dde47b0c5e4 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -150,6 +150,8 @@ void BLEClientBase::connect() { // A registration whose event never arrived must not block this connection's release. this->services_released_ = false; this->pending_notify_regs_ = 0; + // A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own. + this->conn_id_ = UNSET_CONN_ID; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_gattc_lifecycle_event_("OPEN"); - // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an @@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } - if (this->state() != espbt::ClientState::CONNECTING) { - // This should not happen but lets log it in case it does - // because it means we have a bad assumption about how the - // ESP BT stack works. + const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN; + // Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT, + // so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption + // about how the ESP BT stack works, so log it. + const bool expected_failure = + open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID; + if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) { ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + if (open_failed) { this->log_gattc_warning_("Connection open", param->open.status); // Connection was never established so CLOSE_EVT may not follow this->set_idle_(); break; } + // An open on a link that is already up sends no CONNECT_EVT, so take the id here. + this->conn_id_ = param->open.conn_id; if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have From 0701329ea73e2ae03a43806966380f3307436731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:20:59 -1000 Subject: [PATCH 26/79] [wifi] Use numeric scheduler ids instead of string names (#20357) --- esphome/components/wifi/automation.h | 11 +++++++---- esphome/components/wifi/wifi_component.cpp | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index c14341330f5..eb3d7471dc1 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -76,13 +76,13 @@ template class WiFiConfigureAction final : public Action, // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + this->set_timeout(CONNECT_TIMEOUT_ID, this->connection_timeout_.value(x...), [this, x...]() { // If the timeout is reached, stop connecting and revert to the old AP global_wifi_component->disable(); global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); global_wifi_component->enable(); // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->set_timeout(FALLBACK_TIMEOUT_ID, this->connection_timeout_.value(x...), [this]() { this->connecting_ = false; this->error_trigger_.trigger(); }); @@ -97,8 +97,8 @@ template class WiFiConfigureAction final : public Action, return; if (global_wifi_component->is_connected()) { // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); + this->cancel_timeout(CONNECT_TIMEOUT_ID); + this->cancel_timeout(FALLBACK_TIMEOUT_ID); this->connecting_ = false; char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) { @@ -112,6 +112,9 @@ template class WiFiConfigureAction final : public Action, } protected: + static constexpr uint32_t CONNECT_TIMEOUT_ID = 0; + static constexpr uint32_t FALLBACK_TIMEOUT_ID = 1; + bool connecting_{false}; WiFiAP new_sta_; WiFiAP old_sta_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ef8b22d2395..001dae98b83 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -398,6 +398,7 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -1222,7 +1223,7 @@ void WiFiComponent::connect_soon_() { // New credentials from the portal: the AP pauses for them once its answer // is out, and the STA state machine picks them up from there. if (this->ap_setup_) { - this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); From d98ddeeb563aec9f599dda39c20dc8b1759835ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:29 -1000 Subject: [PATCH 27/79] [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) --- esphome/components/mqtt/mqtt_component.h | 2 ++ esphome/components/mqtt/mqtt_fan.cpp | 2 +- esphome/components/mqtt/mqtt_light.cpp | 2 +- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index b4ae6244048..141b5f12b38 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -273,6 +273,8 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: + static constexpr uint32_t SEND_DEFER_ID = 0; + /// Helper method to get the discovery topic for this component into a buffer. StringRef get_discovery_topic_to_(std::span buf, const MQTTDiscoveryInfo &discovery_info) const; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 90f51cf6cec..daf2a511dcc 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -121,7 +121,7 @@ void MQTTFanComponent::setup() { }); } - this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTFanComponent::dump_config() { diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index e9058eb642d..e1affae8a24 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() { } void MQTTJSONLightComponent::on_light_remote_values_update() { - this->defer("send", [this]() { this->publish_state_(); }); + this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 8d8b4c38e88..4f81045fdc3 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -29,7 +29,7 @@ void MQTTLockComponent::setup() { } }); this->lock_->add_on_state_callback( - [this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); }); + [this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTLockComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index b786b4412e6..17ba86a9187 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -34,7 +34,7 @@ void MQTTSwitchComponent::setup() { } }); this->switch_->add_on_state_callback( - [this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); }); + [this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); }); } void MQTTSwitchComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index a4565058ea2..eac45d30b71 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -24,7 +24,7 @@ void MQTTUpdateComponent::setup() { } }); - this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } bool MQTTUpdateComponent::publish_state() { From cf0e64f84cbd800fe90740e942af767b89d697eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:53 -1000 Subject: [PATCH 28/79] [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) --- .../components/improv_serial/improv_serial_component.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ecb7bc98fb4..10454678669 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -18,6 +18,8 @@ namespace esphome::improv_serial { ESPHOME_LOG_TAG(TAG, "improv_serial"); +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; + void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_IMPROV_SERIAL_UART @@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); this->set_state_(improv::STATE_PROVISIONED); this->send_settings_response_(improv::WIFI_SETTINGS); @@ -302,7 +304,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, [this]() { this->on_wifi_connect_timeout_(); }); #else // No Wi-Fi support compiled in; there is nothing to provision. From 11368a0b16cd82f4276da41eb613e3655c4b3f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:22:20 -1000 Subject: [PATCH 29/79] [adc] Collapse duplicate CI test variants (#20400) --- tests/components/adc/test.nrf52-mcumgr.yaml | 23 ------------------- ...72xx-ard.yaml => validate.bk72xx-ard.yaml} | 2 ++ ...p4-idf.yaml => validate.esp32-p4-idf.yaml} | 2 ++ ...s2-idf.yaml => validate.esp32-s2-idf.yaml} | 3 +++ 4 files changed, 7 insertions(+), 23 deletions(-) delete mode 100644 tests/components/adc/test.nrf52-mcumgr.yaml rename tests/components/adc/{test.bk72xx-ard.yaml => validate.bk72xx-ard.yaml} (64%) rename tests/components/adc/{test.esp32-p4-idf.yaml => validate.esp32-p4-idf.yaml} (67%) rename tests/components/adc/{test.esp32-s2-idf.yaml => validate.esp32-s2-idf.yaml} (58%) diff --git a/tests/components/adc/test.nrf52-mcumgr.yaml b/tests/components/adc/test.nrf52-mcumgr.yaml deleted file mode 100644 index 4be5b4b5c20..00000000000 --- a/tests/components/adc/test.nrf52-mcumgr.yaml +++ /dev/null @@ -1,23 +0,0 @@ -sensor: - - platform: adc - pin: VDDHDIV5 - name: "VDDH Voltage" - update_interval: 5sec - filters: - - multiply: 5 - - platform: adc - pin: VDD - name: "VDD Voltage" - update_interval: 5sec - - platform: adc - pin: AIN0 - name: "AIN0 Voltage" - update_interval: 5sec - - platform: adc - pin: P0.03 - name: "AIN1 Voltage" - update_interval: 5sec - - platform: adc - name: "AIN2 Voltage" - update_interval: 5sec - pin: 4 diff --git a/tests/components/adc/test.bk72xx-ard.yaml b/tests/components/adc/validate.bk72xx-ard.yaml similarity index 64% rename from tests/components/adc/test.bk72xx-ard.yaml rename to tests/components/adc/validate.bk72xx-ard.yaml index 09ef0e1fad8..0064276e308 100644 --- a/tests/components/adc/test.bk72xx-ard.yaml +++ b/tests/components/adc/validate.bk72xx-ard.yaml @@ -1,3 +1,5 @@ +# Config only: every LibreTiny chip compiles the same ADC code, covered by the +# ln882x-ard build; this checks the BK72xx analog pin names. sensor: - id: adc_my_sensor platform: adc diff --git a/tests/components/adc/test.esp32-p4-idf.yaml b/tests/components/adc/validate.esp32-p4-idf.yaml similarity index 67% rename from tests/components/adc/test.esp32-p4-idf.yaml rename to tests/components/adc/validate.esp32-p4-idf.yaml index 77cf50d17ce..708ab03f764 100644 --- a/tests/components/adc/test.esp32-p4-idf.yaml +++ b/tests/components/adc/validate.esp32-p4-idf.yaml @@ -1,3 +1,5 @@ +# Config only: the P4 compiles the same ADC code as the esp32-s3-idf build +# (curve fitting calibration), only the pin table differs. sensor: - id: adc_my_sensor platform: adc diff --git a/tests/components/adc/test.esp32-s2-idf.yaml b/tests/components/adc/validate.esp32-s2-idf.yaml similarity index 58% rename from tests/components/adc/test.esp32-s2-idf.yaml rename to tests/components/adc/validate.esp32-s2-idf.yaml index a3019466b55..b8d6923bc21 100644 --- a/tests/components/adc/test.esp32-s2-idf.yaml +++ b/tests/components/adc/validate.esp32-s2-idf.yaml @@ -1,3 +1,6 @@ +# Config only: the S2 shares the C2's line fitting path (no default_vref) and +# the original ESP32's ADC init (no clk_src); both are compiled by those +# builds, only the S2 pin table differs. sensor: - id: adc_my_sensor platform: adc From 3eb8155ed233029ffc7b3df488306378cbee7c1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:22:48 -1000 Subject: [PATCH 30/79] [logger] Collapse duplicate CI test variants (#20402) --- .../components/logger/test-custom_buffer_size.esp32-idf.yaml | 5 ----- .../components/logger/test-disable_log_buffer.esp32-idf.yaml | 5 ----- tests/components/logger/test-uart0.ln882x-ard.yaml | 2 -- tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml | 1 - tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml | 1 - .../components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml | 1 - .../components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml | 1 - tests/components/logger/test.esp32-c3-idf.yaml | 4 ++++ .../logger/{test-on_message.host.yaml => test.host.yaml} | 0 tests/components/logger/test.ln882x-ard.yaml | 1 + ...sp32-idf.yaml => validate-max_buffer_size.esp32-idf.yaml} | 0 ...sp32-idf.yaml => validate-min_buffer_size.esp32-idf.yaml} | 0 12 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 tests/components/logger/test-custom_buffer_size.esp32-idf.yaml delete mode 100644 tests/components/logger/test-disable_log_buffer.esp32-idf.yaml delete mode 100644 tests/components/logger/test-uart0.ln882x-ard.yaml delete mode 100644 tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml delete mode 100644 tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml delete mode 100644 tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml delete mode 100644 tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml rename tests/components/logger/{test-on_message.host.yaml => test.host.yaml} (100%) rename tests/components/logger/{test-max_buffer_size.esp32-idf.yaml => validate-max_buffer_size.esp32-idf.yaml} (100%) rename tests/components/logger/{test-min_buffer_size.esp32-idf.yaml => validate-min_buffer_size.esp32-idf.yaml} (100%) diff --git a/tests/components/logger/test-custom_buffer_size.esp32-idf.yaml b/tests/components/logger/test-custom_buffer_size.esp32-idf.yaml deleted file mode 100644 index 9a396ca0230..00000000000 --- a/tests/components/logger/test-custom_buffer_size.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -<<: !include common-default_uart.yaml - -logger: - id: logger_id - task_log_buffer_size: 1024B # Set a custom buffer size diff --git a/tests/components/logger/test-disable_log_buffer.esp32-idf.yaml b/tests/components/logger/test-disable_log_buffer.esp32-idf.yaml deleted file mode 100644 index 4260f178f95..00000000000 --- a/tests/components/logger/test-disable_log_buffer.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -<<: !include common-default_uart.yaml - -logger: - id: logger_id - task_log_buffer_size: 0 diff --git a/tests/components/logger/test-uart0.ln882x-ard.yaml b/tests/components/logger/test-uart0.ln882x-ard.yaml deleted file mode 100644 index 864d2b96dea..00000000000 --- a/tests/components/logger/test-uart0.ln882x-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -logger: - hardware_uart: UART0 diff --git a/tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml b/tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml deleted file mode 100644 index cfdaec97715..00000000000 --- a/tests/components/logger/test-usb_cdc.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_cdc.yaml diff --git a/tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml b/tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml deleted file mode 100644 index cfdaec97715..00000000000 --- a/tests/components/logger/test-usb_cdc.esp32-s2-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_cdc.yaml diff --git a/tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml b/tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/logger/test-usb_serial_jtag.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/logger/test-usb_serial_jtag.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/logger/test.esp32-c3-idf.yaml b/tests/components/logger/test.esp32-c3-idf.yaml index 3fe04e18a30..4260f178f95 100644 --- a/tests/components/logger/test.esp32-c3-idf.yaml +++ b/tests/components/logger/test.esp32-c3-idf.yaml @@ -1 +1,5 @@ <<: !include common-default_uart.yaml + +logger: + id: logger_id + task_log_buffer_size: 0 diff --git a/tests/components/logger/test-on_message.host.yaml b/tests/components/logger/test.host.yaml similarity index 100% rename from tests/components/logger/test-on_message.host.yaml rename to tests/components/logger/test.host.yaml diff --git a/tests/components/logger/test.ln882x-ard.yaml b/tests/components/logger/test.ln882x-ard.yaml index c5bc9de6e87..d5c62d298a7 100644 --- a/tests/components/logger/test.ln882x-ard.yaml +++ b/tests/components/logger/test.ln882x-ard.yaml @@ -2,4 +2,5 @@ logger: id: logger_id + hardware_uart: UART0 task_log_buffer_size: 1024B diff --git a/tests/components/logger/test-max_buffer_size.esp32-idf.yaml b/tests/components/logger/validate-max_buffer_size.esp32-idf.yaml similarity index 100% rename from tests/components/logger/test-max_buffer_size.esp32-idf.yaml rename to tests/components/logger/validate-max_buffer_size.esp32-idf.yaml diff --git a/tests/components/logger/test-min_buffer_size.esp32-idf.yaml b/tests/components/logger/validate-min_buffer_size.esp32-idf.yaml similarity index 100% rename from tests/components/logger/test-min_buffer_size.esp32-idf.yaml rename to tests/components/logger/validate-min_buffer_size.esp32-idf.yaml From b5716a70c2a40b9c2e3d7e29fe014d97a6d26055 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:26:47 -1000 Subject: [PATCH 31/79] [ethernet] Remove deprecated clk_mode option (#20384) --- esphome/components/ethernet/__init__.py | 39 +++++-------------- .../fixtures/bluetooth_proxy_device.yaml | 4 +- .../component_tests/ethernet/test_ethernet.py | 38 ++++++++++++++++++ 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index bc26db99565..58915ff96db 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -import logging from esphome import automation, pins from esphome.automation import Condition @@ -62,7 +61,6 @@ from esphome.types import ConfigType AUTO_LOAD = ["network"] DOMAIN = "ethernet" -LOGGER = logging.getLogger(__name__) # Key for tracking IP state listener count in CORE.data ETHERNET_IP_STATE_LISTENERS_KEY = "ethernet_ip_state_listeners" @@ -214,13 +212,6 @@ CLK_MODES = { "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, } -CLK_MODES_DEPRECATED = { - "GPIO0_IN": ("CLK_EXT_IN", 0), - "GPIO0_OUT": ("CLK_OUT", 0), - "GPIO16_OUT": ("CLK_OUT", 16), - "GPIO17_OUT": ("CLK_OUT", 17), -} - spi_host_device_t = cg.global_ns.enum("spi_host_device_t") SPI_INTERFACE_MAP = { @@ -387,23 +378,6 @@ def _validate(config: ConfigType) -> ConfigType: get_esp32_variant, ) - if CONF_CLK_MODE in config: - mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] - LOGGER.warning( - "[ethernet] The 'clk_mode' option is deprecated. " - "Please replace 'clk_mode: %s' with:\n" - " clk:\n" - " mode: %s\n" - " pin: %s\n" - "Removal scheduled for 2026.11.0.", - config[CONF_CLK_MODE], - mode, - pin, - ) - config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) - del config[CONF_CLK_MODE] - elif CONF_CLK not in config: - raise cv.Invalid("'clk' is a required option for [ethernet].") variant = get_esp32_variant() if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): raise cv.Invalid( @@ -452,10 +426,17 @@ RMII_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLK_MODE): cv.enum( - CLK_MODES_DEPRECATED, upper=True, space="_" + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_CLK_MODE): cv.invalid( + "The 'clk_mode' option has been removed in ESPHome 2026.11.0.\n" + "Replace it with a 'clk:' block containing 'mode:' and 'pin:':\n" + " GPIO0_IN -> mode: CLK_EXT_IN, pin: GPIO0\n" + " GPIO0_OUT -> mode: CLK_OUT, pin: GPIO0\n" + " GPIO16_OUT -> mode: CLK_OUT, pin: GPIO16\n" + " GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17" ), - cv.Optional(CONF_CLK): CLK_SCHEMA, + cv.Required(CONF_CLK): CLK_SCHEMA, cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), diff --git a/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml index dfa5a487b85..c74846689bb 100644 --- a/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml +++ b/tests/benchmarks/python/fixtures/bluetooth_proxy_device.yaml @@ -25,7 +25,9 @@ ethernet: type: LAN8720 mdc_pin: GPIO23 mdio_pin: GPIO18 - clk_mode: GPIO17_OUT + clk: + mode: CLK_OUT + pin: GPIO17 phy_addr: 0 power_pin: GPIO12 diff --git a/tests/component_tests/ethernet/test_ethernet.py b/tests/component_tests/ethernet/test_ethernet.py index 522357f972f..73985597d9f 100644 --- a/tests/component_tests/ethernet/test_ethernet.py +++ b/tests/component_tests/ethernet/test_ethernet.py @@ -8,6 +8,7 @@ from esphome.components.esp32 import ( KEY_BOARD, KEY_IDF_VERSION, KEY_VARIANT, + VARIANT_ESP32, VARIANT_ESP32S3, ) from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate @@ -82,3 +83,40 @@ def test_ch390_rejects_clock_speed_above_the_datasheet_maximum( CORE.name = "ch390-test" with pytest.raises(Invalid, match="value must be at most 72000000"): CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"}) + + +_LAN8720_CONFIG = {"type": "LAN8720", "mdc_pin": 23, "mdio_pin": 18} + + +def _set_esp32_core(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32dev", + KEY_VARIANT: VARIANT_ESP32, + KEY_IDF_VERSION: cv.Version(5, 3, 2), + }, + ) + CORE.name = "rmii-test" + + +def test_rmii_rejects_removed_clk_mode( + set_core_config: SetCoreConfigCallable, +) -> None: + """The removed clk_mode option points at the clk: replacement.""" + _set_esp32_core(set_core_config) + with pytest.raises(Invalid, match="GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17"): + CONFIG_SCHEMA( + { + **_LAN8720_CONFIG, + "clk_mode": "GPIO17_OUT", + "clk": {"mode": "CLK_OUT", "pin": 17}, + } + ) + + +def test_rmii_requires_clk(set_core_config: SetCoreConfigCallable) -> None: + """An RMII PHY has no default clock, so clk must be given.""" + _set_esp32_core(set_core_config) + with pytest.raises(Invalid, match=r"required key not provided @ data\['clk'\]"): + CONFIG_SCHEMA(_LAN8720_CONFIG) From 52b274c5e8a7af1e4d1fcc6a5a8127340b909d4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:34:34 -1000 Subject: [PATCH 32/79] [improv_serial] Collapse duplicate CI test variants (#20405) --- .../components/improv_serial/common-usb_serial_jtag.yaml | 8 -------- .../improv_serial/{common-uart0.yaml => common.yaml} | 0 .../improv_serial/test-uart-bus.esp8266-ard.yaml | 3 --- .../components/improv_serial/test-uart0.esp32-c3-idf.yaml | 1 - tests/components/improv_serial/test-uart0.esp32-idf.yaml | 1 - .../components/improv_serial/test-uart0.esp32-s2-idf.yaml | 1 - .../components/improv_serial/test-uart0.esp32-s3-idf.yaml | 1 - .../components/improv_serial/test-uart0.esp8266-ard.yaml | 1 - tests/components/improv_serial/test-uart0.rp2040-ard.yaml | 1 - .../improv_serial/test-usb_cdc.esp32-s2-idf.yaml | 3 ++- .../components/improv_serial/test-usb_cdc.rp2040-ard.yaml | 1 - .../improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml | 1 - .../improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml | 1 - tests/components/improv_serial/test.esp32-c3-idf.yaml | 2 ++ tests/components/improv_serial/test.esp32-idf.yaml | 2 ++ tests/components/improv_serial/test.esp8266-ard.yaml | 2 ++ tests/components/improv_serial/test.rp2040-ard.yaml | 2 ++ 17 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 tests/components/improv_serial/common-usb_serial_jtag.yaml rename tests/components/improv_serial/{common-uart0.yaml => common.yaml} (100%) delete mode 100644 tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml delete mode 100644 tests/components/improv_serial/test-uart0.esp8266-ard.yaml delete mode 100644 tests/components/improv_serial/test-uart0.rp2040-ard.yaml delete mode 100644 tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml delete mode 100644 tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml delete mode 100644 tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml create mode 100644 tests/components/improv_serial/test.esp32-c3-idf.yaml create mode 100644 tests/components/improv_serial/test.esp32-idf.yaml create mode 100644 tests/components/improv_serial/test.esp8266-ard.yaml create mode 100644 tests/components/improv_serial/test.rp2040-ard.yaml diff --git a/tests/components/improv_serial/common-usb_serial_jtag.yaml b/tests/components/improv_serial/common-usb_serial_jtag.yaml deleted file mode 100644 index 0abcc071686..00000000000 --- a/tests/components/improv_serial/common-usb_serial_jtag.yaml +++ /dev/null @@ -1,8 +0,0 @@ -wifi: - ssid: MySSID - password: password1 - -logger: - hardware_uart: USB_SERIAL_JTAG - -improv_serial: diff --git a/tests/components/improv_serial/common-uart0.yaml b/tests/components/improv_serial/common.yaml similarity index 100% rename from tests/components/improv_serial/common-uart0.yaml rename to tests/components/improv_serial/common.yaml diff --git a/tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml b/tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml deleted file mode 100644 index 40a6b7f4fe2..00000000000 --- a/tests/components/improv_serial/test-uart-bus.esp8266-ard.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml - improv_serial: !include common-uart-bus.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-s2-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml b/tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.esp8266-ard.yaml b/tests/components/improv_serial/test-uart0.esp8266-ard.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.esp8266-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-uart0.rp2040-ard.yaml b/tests/components/improv_serial/test-uart0.rp2040-ard.yaml deleted file mode 100644 index ef8c7992412..00000000000 --- a/tests/components/improv_serial/test-uart0.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-uart0.yaml diff --git a/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml b/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml index cfdaec97715..f4217b994d1 100644 --- a/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml +++ b/tests/components/improv_serial/test-usb_cdc.esp32-s2-idf.yaml @@ -1 +1,2 @@ -<<: !include common-usb_cdc.yaml +packages: + improv_serial: !include common-usb_cdc.yaml diff --git a/tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml b/tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml deleted file mode 100644 index cfdaec97715..00000000000 --- a/tests/components/improv_serial/test-usb_cdc.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_cdc.yaml diff --git a/tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml b/tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/improv_serial/test-usb_serial_jtag.esp32-c3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml deleted file mode 100644 index 46a927ff107..00000000000 --- a/tests/components/improv_serial/test-usb_serial_jtag.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-usb_serial_jtag.yaml diff --git a/tests/components/improv_serial/test.esp32-c3-idf.yaml b/tests/components/improv_serial/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.esp32-c3-idf.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml diff --git a/tests/components/improv_serial/test.esp32-idf.yaml b/tests/components/improv_serial/test.esp32-idf.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml diff --git a/tests/components/improv_serial/test.esp8266-ard.yaml b/tests/components/improv_serial/test.esp8266-ard.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml diff --git a/tests/components/improv_serial/test.rp2040-ard.yaml b/tests/components/improv_serial/test.rp2040-ard.yaml new file mode 100644 index 00000000000..2a833811fc2 --- /dev/null +++ b/tests/components/improv_serial/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + improv_serial: !include common.yaml From daea315b13d1c0c1be6de0fc06092e13d628e355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:36:42 -1000 Subject: [PATCH 33/79] [nrf52] Fake a larger flash in testing mode (#20439) --- esphome/components/nrf52/__init__.py | 38 ++++++++++++++++++- .../components/zephyr_mcumgr/ota/__init__.py | 8 ++-- .../nrf52/test_testing_mode_flash.py | 30 +++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/components/nrf52/test_testing_mode_flash.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index dc9800a3617..7cd0c6cdc1b 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -12,6 +12,7 @@ from esphome.build_helpers import pch from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( + Section, add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, @@ -126,11 +127,37 @@ def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: - zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]) + sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]] + if CORE.testing_mode: + sections = _testing_mode_sections(sections) + zephyr_add_pm_static(sections) return config +# In testing mode, fake a larger flash to allow linking grouped component +# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot, +# which an openthread config alone fills; CI images are never flashed. +NRF52840_FLASH_SIZE = 0x100000 +TESTING_FLASH_SIZE = 0x400000 + + +def _testing_mode_sections(sections: list[Section]) -> list[Section]: + """Move a bootloader pinned to the end of the real flash to the end of the + faked one, so the partition manager still sees a single gap for the app.""" + return [ + Section( + section.name, + section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE, + section.size, + section.region, + ) + if section.end_address == NRF52840_FLASH_SIZE + else section + for section in sections + ] + + _TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) _resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) @@ -381,6 +408,15 @@ async def to_code(config: ConfigType) -> None: zephyr_setup_preferences() zephyr_to_code(config) + if CORE.testing_mode: + zephyr_add_overlay( + f""" + &flash0 {{ + reg = <0x0 {TESTING_FLASH_SIZE:#x}>; + }}; + """ + ) + if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index f5ab04809e4..d4f84e30f24 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -13,6 +13,7 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, KEY_BOOTLOADER, + KEY_PM_STATIC, KEY_SYSBUILD, ) from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu @@ -207,10 +208,11 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - sections = BOOTLOADER_CONFIG[bootloader] - # Derive partition addresses from the SoftDevice and bootloader sections so - # that the DTS flash map matches what the Partition Manager produces: + # Derive partition addresses from the SoftDevice and bootloader sections + # registered with the Partition Manager (moved in testing mode) so that + # the DTS flash map matches what it produces: # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) bl_start = next(s.address for s in sections if "Adafruit" in s.name) diff --git a/tests/unit_tests/components/nrf52/test_testing_mode_flash.py b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py new file mode 100644 index 00000000000..7c66cd3361b --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py @@ -0,0 +1,30 @@ +"""Testing mode fakes a larger flash so grouped component tests link.""" + +from esphome.components import nrf52 +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr import Section + + +def test_testing_mode_sections_move_only_the_bootloader() -> None: + """The bootloader pinned to the end of flash follows the faked end; the + SoftDevice at the start stays.""" + sections = nrf52._testing_mode_sections( + BOOTLOADER_CONFIG[BOOTLOADER_ADAFRUIT_NRF52_SD140_V7] + ) + + assert [(s.name, s.address, s.size) for s in sections] == [ + ("SoftDevice", 0x0, 0x27000), + ( + "Adafruit_nRF52_Bootloader", + nrf52.TESTING_FLASH_SIZE - 0xC000, + 0xC000, + ), + ] + assert sections[1].end_address == nrf52.TESTING_FLASH_SIZE + + +def test_testing_mode_sections_leave_unpinned_sections() -> None: + section = Section("other", 0x50000, 0x1000, "flash_primary") + + assert nrf52._testing_mode_sections([section]) == [section] From 822bbb85dcfe1f5173f2c575c314396531447bca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:50:32 -1000 Subject: [PATCH 34/79] [core] Build trigger callbacks as stateless lambdas (#19606) --- esphome/automation.py | 184 +++++++++++++++--- esphome/codegen.py | 1 + esphome/cpp_generator.py | 7 + tests/unit_tests/test_automation.py | 287 ++++++++++++++++++++++++++-- 4 files changed, 438 insertions(+), 41 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index b69edd04a13..645fe566d41 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass, field import logging import string @@ -23,7 +23,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda from esphome.cpp_generator import ( - FlashStringLiteral, + Expression, LambdaExpression, MockObj, MockObjClass, @@ -245,9 +245,7 @@ ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) def flash_string(config: ConfigType, value: str) -> str: """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" - if CORE.is_esp8266: - return f"progmem_string({FlashStringLiteral(value)})" - return str(cg.safe_exp(value)) + return str(cg.progmem_string(value)) def literal_with_length(config: ConfigType, value: str) -> str: @@ -259,6 +257,13 @@ def literal_with_length(config: ConfigType, value: str) -> str: return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" +def string_ref_literal(config: ConfigType, value: str) -> str: + """Renderer for a ``StringRef`` comparison: a flash literal on ESP8266, else ``StringRef(literal, length)``.""" + if CORE.is_esp8266: + return str(cg.FlashStringLiteral(value)) + return f"StringRef({literal_with_length(config, value)})" + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. @@ -365,9 +370,16 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] +def parent_ref(var: MockObj) -> MockObj: + """``var`` named from global scope, so a trigger argument cannot shadow it. + + Also how a generated callback names its Automation. + """ + return MockObj(f"::{var}", "->") + + async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: - # Global-scope qualified so a trigger arg named like the id cannot shadow it. - return f"::{await cg.get_variable(config[id_key])}" + return str(parent_ref(await cg.get_variable(config[id_key]))) def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: @@ -399,7 +411,7 @@ async def _render_values( members: list[tuple[Any, Any, Any]], values: list[Any], config: ConfigType, - parent: str, + parent: str | None, lambda_args: TemplateArgsType, compare: bool = False, ) -> list[str]: @@ -477,7 +489,7 @@ def register_apply_action( statements: list[str] = [] for target, members in statements_spec: values = _apply_values(config, members) - if members and all(value is None for value in values): + if not _apply_call_active(members, values): continue exprs = await _render_values( name, target, members, values, config, parent, lambda_args @@ -496,6 +508,27 @@ def register_apply_action( register_action(name, ApplyAction, schema, synchronous=True)(builder) +def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool: + """An ``ApplyCall`` is emitted unless it has keys and none of them is set.""" + return not members or any(value is not None for value in values) + + +async def _render_check( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str | None, + lambda_args: TemplateArgsType, +) -> str: + """Render a boolean check with ``values`` compared against config.""" + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args, compare=True + ) + return target.format(*exprs) + + def register_apply_condition( name: str, schema: cv.Schema, check: str | ApplyCall, id_key: str = CONF_ID ) -> None: @@ -521,22 +554,16 @@ def register_apply_condition( ) -> MockObj: parent = await _apply_parent(config, id_key) lambda_args = _apply_lambda_args(args) - exprs = await _render_values( - name, - call.target, - members, - _apply_values(config, members), - config, - parent, - lambda_args, - compare=True, + values = _apply_values(config, members) + check = await _render_check( + name, call.target, members, values, config, parent, lambda_args ) return _apply_function( condition_id, cg.bool_, template_arg, lambda_args, - [f"return {parent}->{call.target.format(*exprs)};"], + [f"return {parent}->{check};"], ) register_condition(name, ApplyCondition, schema)(builder) @@ -1056,23 +1083,77 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: return False -async def build_automation( - trigger: MockObj, args: TemplateArgsType, config: ConfigType +async def _new_automation( + args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: - arg_types = [arg[0] for arg in args] - templ = cg.TemplateArguments(*arg_types) - obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, trigger) + """Create the Automation for ``config`` with its actions.""" + templ = cg.TemplateArguments(*(arg[0] for arg in args)) + obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) actions = await build_action_list(config[CONF_THEN], templ, args) cg.add(obj.add_actions(actions)) return obj +async def build_automation( + trigger: MockObj, args: TemplateArgsType, config: ConfigType +) -> MockObj: + return await _new_automation(args, config, trigger) + + +async def build_trigger_callback( + args: TemplateArgsType, + config: ConfigType, + params: TemplateArgsType, + forward: Sequence[str | Expression] | None = None, + when: str | ApplyCall | None = None, +) -> LambdaExpression: + """Build the Automation for ``config`` and return a stateless callback that triggers it. + + ``params`` are the parent callback's parameters, ``forward`` the expressions passed to + ``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``), + ``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none + of its keys is set. + """ + members: list[tuple[Any, Any, Any]] = [] + if when is not None: + call = when if isinstance(when, ApplyCall) else ApplyCall(when) + members = call.members + # A trigger callback has no parent for a str type to name. + if any(isinstance(t, str) and "{parent}" in t for _, t, _ in members): + raise ValueError(f"trigger filter {call.target!r}: a type names {{parent}}") + obj = await _new_automation(args, config) + lambda_args = _apply_lambda_args(params) + statements: list[str] = [] + if when is not None: + values = _apply_values(config, members) + if _apply_call_active(members, values): + check = await _render_check( + "trigger filter", + call.target, + members, + values, + config, + None, + lambda_args, + ) + statements.append(f"if (!({check}))\n return;") + if forward is None: + forward = [name for _, name in params] + statements.append(f"{parent_ref(obj)}->trigger({', '.join(map(str, forward))});") + return LambdaExpression( + ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + ) + + async def build_callback_automation( parent: MockObj, callback_method: str, args: TemplateArgsType, config: ConfigType, forwarder: MockObj | MockObjClass | None = None, + params: TemplateArgsType | None = None, + forward: Sequence[str | Expression] | None = None, + when: str | ApplyCall | None = None, ) -> None: """Build an Automation and register it as a callback on the parent. @@ -1084,6 +1165,9 @@ async def build_callback_automation( pointer-sized (single Automation* field) to fit inline in Callback::ctx_ and avoid heap allocation. + With ``params``, ``forward`` or ``when`` the callback is instead the stateless + lambda of ``build_trigger_callback``; ``forwarder`` cannot be combined with them. + :param parent: The component object (e.g., button, sensor). :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). :param args: Automation template args as list of (type, name) tuples. @@ -1092,22 +1176,56 @@ async def build_callback_automation( TriggerForwarder. Pass any struct type whose aggregate init takes a single Automation pointer (e.g., TriggerOnTrueForwarder). """ - arg_types = [arg[0] for arg in args] - templ = cg.TemplateArguments(*arg_types) - obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) - actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + if params is not None or forward is not None or when is not None: + if forwarder is not None: + raise ValueError( + "forwarder cannot be combined with params, forward or when" + ) + callback = await build_trigger_callback( + args, config, args if params is None else params, forward, when + ) + cg.add(getattr(parent, callback_method)(callback)) + return + obj = await _new_automation(args, config) # Use template forwarder structs for deduplication. The compiler generates # one operator() per forwarder type; different automation pointers are just # data in the struct. if forwarder is None: - forwarder = TriggerForwarder.template(templ) + forwarder = TriggerForwarder.template(*(arg[0] for arg in args)) # RawExpression for aggregate init — both forwarder and obj are codegen # MockObjs (not user input), and there's no Expression type for positional # aggregate initialization (StructInitializer uses named fields). cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) +async def build_parent_callback_automation( + parent: MockObj, callback_method: str, arg: tuple[Any, str], config: ConfigType +) -> None: + """Register an Automation that receives ``parent`` from a callback that carries nothing. + + ``arg`` is the automation's ``(type, name)``, e.g. ``(Fan.operator("ptr"), "x")``. + """ + await build_callback_automation( + parent, callback_method, [arg], config, params=[], forward=[parent_ref(parent)] + ) + + +async def build_trigger_automations( + parent: MockObj | None, + config: ConfigType, + entries: tuple[tuple[str, TemplateArgsType], ...], +) -> None: + """Instantiate each entry's Trigger class, with ``parent`` when given, and build its automations. + + ``entries`` are ``(conf_key, args)`` pairs; the class comes from the entry's ``CONF_TRIGGER_ID``. + """ + ctor_args = () if parent is None else (parent,) + for conf_key, args in entries: + for conf in config.get(conf_key, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], *ctor_args) + await build_automation(trigger, args, conf) + + @dataclass(frozen=True, slots=True) class CallbackAutomation: """A single callback automation entry for build_callback_automations.""" @@ -1116,6 +1234,9 @@ class CallbackAutomation: callback_method: str args: TemplateArgsType = field(default_factory=list) forwarder: MockObj | MockObjClass | None = None + params: TemplateArgsType | None = None + forward: Sequence[str | Expression] | None = None + when: str | ApplyCall | None = None async def build_callback_automations( @@ -1137,4 +1258,7 @@ async def build_callback_automations( entry.args, conf, forwarder=entry.forwarder, + params=entry.params, + forward=entry.forward, + when=entry.when, ) diff --git a/esphome/codegen.py b/esphome/codegen.py index e7c08048b9f..d57ec39aab7 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -39,6 +39,7 @@ from esphome.cpp_generator import ( # noqa: F401 new_variable, process_lambda, progmem_array, + progmem_string, safe_exp, set_cpp_standard, shared_progmem_array, diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 267e86c2edf..f5113d8098e 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -269,6 +269,13 @@ class FlashStringLiteral(Literal): return f"ESPHOME_F({cpp_string_escape(self.string)})" +def progmem_string(value: str) -> Expression: + """A ``std::string`` argument from a literal that stays in flash on ESP8266.""" + if CORE.is_esp8266: + return RawExpression(f"progmem_string({FlashStringLiteral(value)})") + return safe_exp(value) + + class IntLiteral(Literal): __slots__ = ("i",) diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 2bcd32375e6..ddd777fdb07 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -16,10 +16,15 @@ from esphome.automation import ( TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + build_callback_automation, build_callback_automations, + build_parent_callback_automation, + build_trigger_automations, + build_trigger_callback, has_non_synchronous_actions, literal_with_length, maybe_simple_id, + parent_ref, progmem_bytes, register_apply_action, register_apply_condition, @@ -29,11 +34,12 @@ from esphome.automation import ( register_parented_condition, register_simple_action, register_simple_condition, + string_ref_literal, templatable_bytes, ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.util import Registry, RegistryEntry @@ -327,7 +333,14 @@ async def test_build_callback_automations_single_entry( (CallbackAutomation("on_state", "add_on_state_callback", [(bool, "x")]),), ) mock_build_callback.assert_called_once_with( - parent, "add_on_state_callback", [(bool, "x")], conf, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf, + forwarder=None, + params=None, + forward=None, + when=None, ) @@ -347,10 +360,24 @@ async def test_build_callback_automations_multiple_configs( ) assert mock_build_callback.call_count == 2 mock_build_callback.assert_any_call( - parent, "add_on_state_callback", [(bool, "x")], conf1, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf1, + forwarder=None, + params=None, + forward=None, + when=None, ) mock_build_callback.assert_any_call( - parent, "add_on_state_callback", [(bool, "x")], conf2, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf2, + forwarder=None, + params=None, + forward=None, + when=None, ) @@ -378,9 +405,25 @@ async def test_build_callback_automations_multiple_entries( ) assert mock_build_callback.call_count == 2 assert mock_build_callback.call_args_list == [ - call(parent, "add_on_value_callback", [(float, "x")], conf_a, forwarder=None), call( - parent, "add_on_raw_value_callback", [(float, "x")], conf_b, forwarder=None + parent, + "add_on_value_callback", + [(float, "x")], + conf_a, + forwarder=None, + params=None, + forward=None, + when=None, + ), + call( + parent, + "add_on_raw_value_callback", + [(float, "x")], + conf_b, + forwarder=None, + params=None, + forward=None, + when=None, ), ] @@ -403,7 +446,14 @@ async def test_build_callback_automations_with_forwarder( ), ) mock_build_callback.assert_called_once_with( - parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + parent, + "add_on_state_callback", + [], + conf, + forwarder=TriggerOnTrueForwarder, + params=None, + forward=None, + when=None, ) @@ -437,7 +487,14 @@ async def test_build_callback_automations_mixed_entries( assert mock_build_callback.call_count == 3 assert mock_build_callback.call_args_list == [ call( - parent, "add_on_state_callback", [(bool, "x")], conf_state, forwarder=None + parent, + "add_on_state_callback", + [(bool, "x")], + conf_state, + forwarder=None, + params=None, + forward=None, + when=None, ), call( parent, @@ -445,6 +502,9 @@ async def test_build_callback_automations_mixed_entries( [], conf_press, forwarder=TriggerOnTrueForwarder, + params=None, + forward=None, + when=None, ), call( parent, @@ -452,6 +512,9 @@ async def test_build_callback_automations_mixed_entries( [], conf_release, forwarder=TriggerOnFalseForwarder, + params=None, + forward=None, + when=None, ), ] @@ -477,7 +540,14 @@ async def test_build_callback_automations_skips_missing_keys( ), ) mock_build_callback.assert_called_once_with( - parent, "add_on_state_callback", [], conf, forwarder=TriggerOnTrueForwarder + parent, + "add_on_state_callback", + [], + conf, + forwarder=TriggerOnTrueForwarder, + params=None, + forward=None, + when=None, ) @@ -495,7 +565,14 @@ async def test_build_callback_automations_defaults( (CallbackAutomation("on_press", "add_on_press_callback"),), ) mock_build_callback.assert_called_once_with( - parent, "add_on_press_callback", [], conf, forwarder=None + parent, + "add_on_press_callback", + [], + conf, + forwarder=None, + params=None, + forward=None, + when=None, ) @@ -512,9 +589,59 @@ class MockCodegen(NamedTuple): new_pvariable: MagicMock register_parented: AsyncMock add_global: MagicMock + add: MagicMock calls: MagicMock # new_pvariable and add_global attached, to check their order +@pytest.fixture +def mock_build_automation() -> Generator[AsyncMock]: + with patch("esphome.automation.build_automation", new_callable=AsyncMock) as mock: + yield mock + + +@pytest.mark.asyncio +async def test_build_trigger_automations_with_parent( + mock_build_automation: AsyncMock, +) -> None: + """Each entry's Trigger class is instantiated with the parent and built with its args.""" + parent = MockObj("var", "->") + on_conf = {"trigger_id": ID("trig_1"), "then": []} + set_conf = {"trigger_id": ID("trig_2"), "then": []} + config = {"on_turn_on": [on_conf], "on_speed_set": [set_conf]} + with patch("esphome.codegen.new_Pvariable") as new_pvariable: + new_pvariable.side_effect = lambda id_, *args: MockObj(str(id_), "->") + await build_trigger_automations( + parent, + config, + ( + ("on_turn_on", []), + ("on_turn_off", []), + ("on_speed_set", [(cg.int_, "x")]), + ), + ) + assert [c.args for c in new_pvariable.call_args_list] == [ + (ID("trig_1"), parent), + (ID("trig_2"), parent), + ] + calls = mock_build_automation.call_args_list + assert [(str(c.args[0]), c.args[1], c.args[2]) for c in calls] == [ + ("trig_1", [], on_conf), + ("trig_2", [(cg.int_, "x")], set_conf), + ] + + +@pytest.mark.asyncio +async def test_build_trigger_automations_without_parent( + mock_build_automation: AsyncMock, +) -> None: + """A None parent instantiates the Trigger class with no constructor arguments.""" + conf = {"trigger_id": ID("trig_1"), "then": []} + with patch("esphome.codegen.new_Pvariable") as new_pvariable: + await build_trigger_automations(None, {"on_boot": [conf]}, (("on_boot", []),)) + new_pvariable.assert_called_once_with(ID("trig_1")) + mock_build_automation.assert_awaited_once() + + @pytest.fixture def mock_cg() -> Generator[MockCodegen]: """Patch the codegen calls the shared builders make.""" @@ -525,6 +652,7 @@ def mock_cg() -> Generator[MockCodegen]: "esphome.codegen.register_parented", new_callable=AsyncMock ) as register_parented, patch("esphome.cpp_generator.add_global") as add_global, + patch("esphome.codegen.add") as add, ): get_variable.return_value = PARENT_OBJ new_pvariable.return_value = NEW_OBJ @@ -532,7 +660,7 @@ def mock_cg() -> Generator[MockCodegen]: calls.attach_mock(new_pvariable, "new_pvariable") calls.attach_mock(add_global, "add_global") yield MockCodegen( - get_variable, new_pvariable, register_parented, add_global, calls + get_variable, new_pvariable, register_parented, add_global, add, calls ) @@ -927,6 +1055,26 @@ async def test_apply_literal_with_length_is_plain_on_every_platform( assert "progmem_string" not in text +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("platform", "rendered"), + [("esp32", 'StringRef("ON", 2)'), ("esp8266", 'ESPHOME_F("ON")')], +) +async def test_apply_string_ref_literal_stays_in_flash_on_esp8266( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + rendered: str, +) -> None: + fields = ( + ApplyField( + "payload", "set_payload", cg.std_string, const_fn=string_ref_literal + ), + ) + await _run_apply_action(registries, fields, {"payload": "ON"}, platform=platform) + assert f"::{PARENT_OBJ}->set_payload({rendered});" in _apply_definition(mock_cg) + + @pytest.mark.asyncio async def test_register_apply_condition_predicate( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -1036,3 +1184,120 @@ async def test_templatable_bytes_rejects_oversized_payload() -> None: await templatable_bytes( [0] * 0x10000, [], var.set_code_template, var.set_code_static, "payload" ) + + +TRIGGER_CONF = {CONF_AUTOMATION_ID: ID("automation_1"), CONF_THEN: []} + + +def _squash(expr: object) -> str: + """Generated text with its whitespace folded, for one-line assertions.""" + return " ".join(str(expr).split()) + + +def test_parent_ref_is_global_scoped() -> None: + """A parent named through parent_ref cannot be shadowed by a trigger argument.""" + assert ( + str(parent_ref(MockObj("sel", "->")).option_at(RawExpression("i"))) + == "::sel->option_at(i)" + ) + + +@pytest.mark.asyncio +async def test_trigger_callback_forwards_params(mock_cg: MockCodegen) -> None: + """Without forward or when the callback passes its parameters straight to trigger().""" + text = str( + await build_trigger_callback( + [(cg.bool_, "state")], TRIGGER_CONF, [(cg.bool_, "state")] + ) + ) + assert text.startswith("[](const std::remove_cvref_t & state) -> void {") + assert f"::{NEW_OBJ}->trigger(state);" in text + assert "if (" not in text + + +@pytest.mark.asyncio +async def test_trigger_callback_reshapes_and_filters(mock_cg: MockCodegen) -> None: + """A filter returns early, forward picks the trigger args, a string constant is a plain literal.""" + when = ApplyCall("payload == {}", (("payload", cg.std_string),)) + params = [(cg.std_string, "topic"), (cg.std_string, "payload")] + text = _squash( + await build_trigger_callback( + [(cg.std_string, "x")], + {**TRIGGER_CONF, "payload": "hi"}, + params, + forward=["payload"], + when=when, + ) + ) + assert "& topic, const std::remove_cvref_t & payload) -> void" in text + assert f'if (!(payload == "hi")) return; ::{NEW_OBJ}->trigger(payload);' in text + # An absent optional key skips the filter, as for any ApplyCall. + text = str( + await build_trigger_callback( + [(cg.std_string, "x")], TRIGGER_CONF, params, forward=["payload"], when=when + ) + ) + assert "if (" not in text + + +@pytest.mark.asyncio +async def test_trigger_callback_filter_has_no_parent(mock_cg: MockCodegen) -> None: + """A filter type that names {parent} is rejected up front, a trigger callback has none.""" + when = ApplyCall("mode == {}", (("mode", "{parent}::Mode"),)) + with pytest.raises(ValueError, match="names {parent}"): + await build_trigger_callback( + [], {**TRIGGER_CONF, "mode": 1}, [(cg.int_, "mode")], forward=[], when=when + ) + + +@pytest.mark.asyncio +async def test_build_parent_callback_automation(mock_cg: MockCodegen) -> None: + """A no-argument callback registers a lambda that hands the parent to the automation.""" + parent = MockObj("fan", "->") + await build_parent_callback_automation( + parent, + "add_on_state_callback", + (cg.RawExpression("Fan *"), "x"), + TRIGGER_CONF, + ) + assert _squash(mock_cg.add.call_args.args[0]) == ( + f"fan->add_on_state_callback([]() -> void {{ ::{NEW_OBJ}->trigger(::fan); }})" + ) + + +@pytest.mark.asyncio +async def test_build_callback_automation_lambda(mock_cg: MockCodegen) -> None: + """Reshaping keywords switch the registration to the lambda; forwarder cannot join them.""" + parent = MockObj("sel", "->") + await build_callback_automation( + parent, + "add_cb", + [(cg.std_string, "x"), (cg.size_t, "i")], + TRIGGER_CONF, + params=[(cg.size_t, "index")], + forward=[parent_ref(parent).option_at(RawExpression("index")), "index"], + ) + assert _squash(mock_cg.add.call_args.args[0]) == ( + "sel->add_cb([](const std::remove_cvref_t & index) -> void { " + f"::{NEW_OBJ}->trigger(::sel->option_at(index), index); }})" + ) + with pytest.raises(ValueError, match="forwarder"): + await build_callback_automation( + parent, + "add_cb", + [], + TRIGGER_CONF, + forwarder=TriggerOnTrueForwarder, + when="x", + ) + + +@pytest.mark.asyncio +async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None: + """The forwarder path registers a pointer-sized TriggerForwarder, not a lambda.""" + await build_callback_automation( + MockObj("parent", "->"), "add_cb", [(cg.bool_, "x")], TRIGGER_CONF + ) + assert str(mock_cg.add.call_args.args[0]) == ( + f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" + ) From dd8315059c185c16a53eb162174fb63f34bbdcb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:56:50 -1000 Subject: [PATCH 35/79] [core] Document the trigger callback helpers in AGENTS.md (#20435) --- AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d5d33b32af6..8194cd50407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,6 +396,25 @@ file does, and it is the authority when they disagree. The most useful starting ) ``` + When the callback's parameters differ from the automation's arguments, or the trigger should only + fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less + lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build + `forward` from `MockObj` calls, not f-strings of C++: + ```python + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], + ) + ``` + `build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and + `build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`. + **C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation): ```cpp class MyComponent : public Component { From dbad1d462975d18dacc1880a4a13dd3985ec6f8c Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:58:33 +0200 Subject: [PATCH 36/79] [core][zigbee] Wake main loop on defer/set_timeout from background task (#20356) Co-authored-by: J. Nick Koston --- esphome/components/esp32/hal.h | 5 + esphome/components/esp8266/hal.h | 4 + esphome/components/host/hal.h | 13 +- esphome/components/libretiny/hal.h | 5 + esphome/components/rp2/hal.h | 5 + esphome/components/zephyr/hal.cpp | 1 + esphome/components/zephyr/hal.h | 8 ++ .../zigbee/time/zigbee_time_esp32.cpp | 2 - .../zigbee/time/zigbee_time_zephyr.cpp | 2 - esphome/components/zigbee/zigbee_esp32.cpp | 3 - esphome/components/zigbee/zigbee_zephyr.cpp | 12 +- esphome/core/scheduler.cpp | 123 +++++++++--------- esphome/core/wake/wake_esp8266.h | 13 +- esphome/core/wake/wake_freertos.cpp | 5 +- esphome/core/wake/wake_freertos.h | 4 +- esphome/core/wake/wake_host.cpp | 23 ++-- esphome/core/wake/wake_host.h | 1 + esphome/core/wake/wake_rp2.h | 8 +- esphome/core/wake/wake_zephyr.cpp | 4 +- esphome/core/wake/wake_zephyr.h | 1 + .../wake_test_component.cpp | 12 ++ .../wake_test_component/wake_test_component.h | 4 + .../fixtures/scheduler_background_wake.yaml | 31 +++++ .../test_scheduler_background_wake.py | 84 ++++++++++++ 24 files changed, 278 insertions(+), 95 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_background_wake.yaml create mode 100644 tests/integration/test_scheduler_background_wake.py diff --git a/esphome/components/esp32/hal.h b/esphome/components/esp32/hal.h index 2180f07f6c3..c01dcc858ed 100644 --- a/esphome/components/esp32/hal.h +++ b/esphome/components/esp32/hal.h @@ -9,6 +9,7 @@ #include #include +#include "esphome/core/main_task.h" #include "esphome/core/time_conversion.h" #ifndef PROGMEM @@ -31,6 +32,10 @@ __attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrC // NOLINTNEXTLINE(readability-redundant-declaration) extern "C" int64_t esp_timer_get_time(void); +/// Before setup() stores the handle every task counts as background; the wake is a no-op until then. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return xTaskGetCurrentTaskHandle() == esphome_main_task_handle; +} __attribute__((always_inline)) inline void yield() { vPortYield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index f3b33da6927..1a54065541e 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -40,6 +41,9 @@ void delay_microseconds_safe(uint32_t us); /// which is ISR-safe) so this helper is unused on this platform. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +/// SDK SYS callbacks and interrupt handlers cannot yield, so they are not the loop context. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return can_yield(); } + __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } void delay(uint32_t ms); diff --git a/esphome/components/host/hal.h b/esphome/components/host/hal.h index 12abf6684d3..c7676327729 100644 --- a/esphome/components/host/hal.h +++ b/esphome/components/host/hal.h @@ -3,6 +3,7 @@ #ifdef USE_HOST #include +#include #include #define IRAM_ATTR @@ -16,6 +17,16 @@ namespace esphome { /// Host has no ISR concept. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +inline pthread_t &main_loop_thread() { + static pthread_t thread{}; + return thread; +} + +/// arch_init() stores the thread before any component runs, so no unset case exists. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return pthread_equal(pthread_self(), main_loop_thread()) != 0; +} + __attribute__((always_inline)) inline void yield() { ::sched_yield(); } void delay(uint32_t ms); @@ -25,7 +36,7 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) uint32_t arch_get_cpu_cycle_count(); -__attribute__((always_inline)) inline void arch_init() {} +__attribute__((always_inline)) inline void arch_init() { main_loop_thread() = pthread_self(); } __attribute__((always_inline)) inline void arch_feed_wdt() {} __attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 48b94a5214a..838536b866d 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -8,6 +8,7 @@ #include #include +#include "esphome/core/main_task.h" #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an @@ -84,6 +85,10 @@ __attribute__((always_inline)) inline bool in_isr_context() { #endif } +/// Before setup() stores the handle every task counts as background; the wake is a no-op until then. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return xTaskGetCurrentTaskHandle() == esphome_main_task_handle; +} __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } diff --git a/esphome/components/rp2/hal.h b/esphome/components/rp2/hal.h index ec46937bab5..2a538f15713 100644 --- a/esphome/components/rp2/hal.h +++ b/esphome/components/rp2/hal.h @@ -4,6 +4,8 @@ #include +#include + #include "esphome/core/time_conversion.h" #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) @@ -40,6 +42,9 @@ __attribute__((always_inline)) inline bool in_isr_context() { return ipsr != 0; } +/// arduino-pico runs setup() and loop() on core 0; ESPHome never uses core 1. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return !in_isr_context() && get_core_num() == 0; } + __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index 10e8340a409..96c7df896cb 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -23,6 +23,7 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); // components/zephyr/hal.h. void arch_init() { + main_loop_thread() = k_current_get(); #ifdef CONFIG_WATCHDOG if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; diff --git a/esphome/components/zephyr/hal.h b/esphome/components/zephyr/hal.h index 11994b68b7b..8384225d33a 100644 --- a/esphome/components/zephyr/hal.h +++ b/esphome/components/zephyr/hal.h @@ -17,6 +17,14 @@ namespace esphome { /// Zephyr/nRF52: not currently consulted — wake path is platform-specific. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +inline k_tid_t &main_loop_thread() { + static k_tid_t thread = nullptr; + return thread; +} + +/// arch_init() stores the thread before any component runs, so no unset case exists. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return k_current_get() == main_loop_thread(); } + __attribute__((always_inline)) inline void yield() { ::k_yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::k_msleep(ms); } __attribute__((always_inline)) inline uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp index 1bc0923a1f7..909c714c5e3 100644 --- a/esphome/components/zigbee/time/zigbee_time_esp32.cpp +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -1,7 +1,6 @@ #include "zigbee_time_esp32.h" #if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME) #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome::zigbee { @@ -105,7 +104,6 @@ void ZigbeeTime::set_epoch_time(uint32_t utc) { ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast(utc)); this->synchronize_epoch_(utc); }); - App.wake_loop_threadsafe(); } void ZigbeeTime::dump_config() { diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index f0ef3f2af2c..7f0a5f979ce 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,7 +1,6 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome::zigbee { @@ -48,7 +47,6 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) { this->synchronize_epoch_(epoch); this->has_time_ = true; }); - App.wake_loop_threadsafe(); } void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 8b7b59acb0b..89417e748d2 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -53,7 +53,6 @@ void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_ if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); - App.wake_loop_threadsafe(); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -92,7 +91,6 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); - App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -118,7 +116,6 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } - App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index f1eadbe6f06..1b2d7392df9 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,7 +1,6 @@ #include "zigbee_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) #include "esphome/core/log.h" -#include "esphome/core/application.h" #include #include #include "esphome/core/hal.h" @@ -120,8 +119,6 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - App.wake_loop_threadsafe(); - // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { const auto &cb = global_zigbee->callbacks_[endpoint - 1]; @@ -138,7 +135,6 @@ void ZigbeeComponent::on_join_(bool factory_new) { ESP_LOGD(TAG, "Joined the network"); this->join_cb_.call(factory_new); }); - App.wake_loop_threadsafe(); } void ZigbeeComponent::on_start_() { @@ -146,7 +142,6 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); - App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT @@ -199,6 +194,7 @@ void ZigbeeComponent::setup() { zigbee_configure_sleepy_behavior(this->sleepy_); #endif zigbee_enable(); + this->disable_loop(); } #ifdef ESPHOME_LOG_HAS_CONFIG @@ -263,7 +259,10 @@ static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { zb_buf_free(bufid); } -void ZigbeeComponent::force_report() { this->force_report_ = true; } +void ZigbeeComponent::force_report() { + this->force_report_ = true; + this->enable_loop_soon_any_context(); +} void ZigbeeComponent::add_radio_sleep_time_ms(uint32_t ms) { this->radio_sleep_remainder_ += ms; @@ -277,6 +276,7 @@ void ZigbeeComponent::loop() { this->force_report_ = false; zb_buf_get_out_delayed_ext(send_attribute_report, 0, 0); } + this->disable_loop(); } void ZigbeeComponent::factory_reset() { diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5acd2eec273..5d0b96c38f2 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -138,79 +138,84 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } // Take lock early to protect scheduler_item_pool_head_ access - LockGuard guard{this->lock_}; + { + LockGuard guard{this->lock_}; - // Create and populate the scheduler item - SchedulerItem *item = this->get_item_from_pool_locked_(); - // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. - if (name_type == NameType::SELF_POINTER) { - item->source_name = source; - } else { - item->component = component; - } - item->set_name(name_type, static_name, hash_or_id); - item->type = type; - // Use destroy + placement-new instead of move-assignment. - // GCC's std::function::operator=(function&&) does a full swap dance even when the - // target is empty. Since recycled/new items always have an empty callback, we can - // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of - // swap/destructor code on Xtensa. - item->callback.~function(); - new (&item->callback) std::function(std::move(func)); - // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use - this->set_item_removed_(item, false); + // Create and populate the scheduler item + SchedulerItem *item = this->get_item_from_pool_locked_(); + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } + item->set_name(name_type, static_name, hash_or_id); + item->type = type; + // Use destroy + placement-new instead of move-assignment. + // GCC's std::function::operator=(function&&) does a full swap dance even when the + // target is empty. Since recycled/new items always have an empty callback, we can + // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of + // swap/destructor code on Xtensa. + item->callback.~function(); + new (&item->callback) std::function(std::move(func)); + // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use + this->set_item_removed_(item, false); - // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. - // Using a pointer lets both paths share the cancel + push_back epilogue. - auto *target = &this->to_add_; + // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. + // Using a pointer lets both paths share the cancel + push_back epilogue. + auto *target = &this->to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Special handling for defer() (delay = 0, type = TIMEOUT) - // Single-core platforms don't need thread-safe defer handling - if (delay == 0 && type == SchedulerItem::TIMEOUT) { - // Put in defer queue for guaranteed FIFO execution - target = &this->defer_queue_; - } else + // Special handling for defer() (delay = 0, type = TIMEOUT) + // Single-core platforms don't need thread-safe defer handling + if (delay == 0 && type == SchedulerItem::TIMEOUT) { + // Put in defer queue for guaranteed FIFO execution + target = &this->defer_queue_; + } else #endif /* not ESPHOME_THREAD_SINGLE */ - { - // Only non-defer items need a timestamp for scheduling - const uint64_t now_64 = millis_64(); + { + // Only non-defer items need a timestamp for scheduling + const uint64_t now_64 = millis_64(); - // Type-specific setup - if (type == SchedulerItem::INTERVAL) { - item->interval = delay; - // first execution happens immediately after a random smallish offset - uint32_t offset = this->calculate_interval_offset_(delay); - item->set_next_execution(now_64 + offset); + // Type-specific setup + if (type == SchedulerItem::INTERVAL) { + item->interval = delay; + // first execution happens immediately after a random smallish offset + uint32_t offset = this->calculate_interval_offset_(delay); + item->set_next_execution(now_64 + offset); #ifdef ESPHOME_LOG_HAS_VERBOSE - SchedulerNameLog name_log; - ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", - name_log.format(name_type, static_name, hash_or_id), delay, offset); + SchedulerNameLog name_log; + ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", + name_log.format(name_type, static_name, hash_or_id), delay, offset); #endif - } else { - item->interval = 0; - item->set_next_execution(now_64 + delay); - } + } else { + item->interval = 0; + item->set_next_execution(now_64 + delay); + } #ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_log_timer_(item, name_type, static_name, hash_or_id, delay, now_64); + this->debug_log_timer_(item, name_type, static_name, hash_or_id, delay, now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - } + } - // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) - // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. - if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); - } - target->push_back(item); - if (target == &this->to_add_) { - this->to_add_count_increment_locked_(); - } + // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) + // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. + if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); + } + target->push_back(item); + if (target == &this->to_add_) { + this->to_add_count_increment_locked_(); + } #ifndef ESPHOME_THREAD_SINGLE - else { - this->defer_count_increment_locked_(); - } + else { + this->defer_count_increment_locked_(); + } #endif + } + // A background insertion may shorten a sleep whose deadline was already computed. + if (!is_main_loop_thread()) [[unlikely]] + wake_scheduler_threadsafe(); } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 73b7a38a35e..2f7ffc292fe 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -11,10 +11,7 @@ namespace esphome { /// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); +inline void ESPHOME_ALWAYS_INLINE wake_scheduler_impl() { // Skip the post when a wake was already signalled and not yet consumed by // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, // which must not be poked per-byte from the software serial RX ISR (see @@ -26,11 +23,19 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { esp_schedule(); } +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + wake_scheduler_impl(); +} + /// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +inline void wake_scheduler_threadsafe() { wake_scheduler_impl(); } /// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 458ef51f89f..02c628718e6 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,9 +30,6 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome -extern "C" void esphome_wake_loop_threadsafe() { - esphome::wake_request_set(); - esphome_main_task_notify(); -} +extern "C" void esphome_wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h index 16afa38fda7..1df7d387656 100644 --- a/esphome/core/wake/wake_freertos.h +++ b/esphome/core/wake/wake_freertos.h @@ -34,9 +34,11 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); void wake_loop_any_context(); +inline void wake_scheduler_threadsafe() { esphome_main_task_notify(); } + inline void wake_loop_threadsafe() { wake_request_set(); - esphome_main_task_notify(); + wake_scheduler_threadsafe(); } namespace internal { diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 8cb382a77e0..9fc1c717032 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,13 +123,12 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } - // A socket woke select() early — open the component-phase gate so the - // owning component's loop() drains the data on this tick rather than - // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() - // already set the flag (wake socket fired); required when an application - // socket fired and nothing else set the flag. + // Application sockets need the component phase to drain queued work. + // The internal wake socket may only be signaling new scheduler work. if (ret > 0) { - wake_request_set(); + const bool only_wake_socket = ret == 1 && g_wake_socket_fd >= 0 && FD_ISSET(g_wake_socket_fd, &g_read_fds); + if (!only_wake_socket) + wake_request_set(); } return; } @@ -146,16 +145,20 @@ void wakeable_delay(uint32_t ms) { } } // namespace internal -void wake_loop_threadsafe() { - // Set flag before sending so the consumer's gate check on the next loop() - // entry observes the wake regardless of select() scheduling. - wake_request_set(); +void wake_scheduler_threadsafe() { if (internal::g_wake_socket_fd >= 0) { const char dummy = 1; ::send(internal::g_wake_socket_fd, &dummy, 1, 0); } } +void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); + wake_scheduler_threadsafe(); +} + void wake_setup() { // Create UDP socket for wake notifications. internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h index 9756ed4c39b..8c58797df25 100644 --- a/esphome/core/wake/wake_host.h +++ b/esphome/core/wake/wake_host.h @@ -13,6 +13,7 @@ namespace esphome { /// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. void wake_loop_threadsafe(); +void wake_scheduler_threadsafe(); /// Register a socket file descriptor with the host select() loop. Not /// thread-safe — main loop only. Returns false if fd is invalid or diff --git a/esphome/core/wake/wake_rp2.h b/esphome/core/wake/wake_rp2.h index 715e5aca0c8..4f5439dbb8c 100644 --- a/esphome/core/wake/wake_rp2.h +++ b/esphome/core/wake/wake_rp2.h @@ -11,12 +11,16 @@ namespace esphome { +inline void wake_scheduler_threadsafe() { + g_main_loop_woke = true; + __sev(); +} + inline void wake_loop_any_context() { // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed // to see it on its next gate check. wake_request_set(); - g_main_loop_woke = true; - __sev(); + wake_scheduler_threadsafe(); } inline void wake_loop_threadsafe() { wake_loop_any_context(); } diff --git a/esphome/core/wake/wake_zephyr.cpp b/esphome/core/wake/wake_zephyr.cpp index 577d53f5d9d..2c46fbc9512 100644 --- a/esphome/core/wake/wake_zephyr.cpp +++ b/esphome/core/wake/wake_zephyr.cpp @@ -21,9 +21,11 @@ K_SEM_DEFINE(esphome_wake_sem, 0, 1); // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) volatile uint8_t g_wake_requested = 0; +void wake_scheduler_threadsafe() { k_sem_give(&esphome_wake_sem); } + void wake_loop_threadsafe() { wake_request_set(); - k_sem_give(&esphome_wake_sem); + wake_scheduler_threadsafe(); } namespace internal { diff --git a/esphome/core/wake/wake_zephyr.h b/esphome/core/wake/wake_zephyr.h index c89cfc68e94..04cbfb230b1 100644 --- a/esphome/core/wake/wake_zephyr.h +++ b/esphome/core/wake/wake_zephyr.h @@ -11,6 +11,7 @@ namespace esphome { /// Zephyr: wakes the main loop via k_sem_give(). Thread- and ISR-safe. /// Defined in wake_zephyr.cpp. void wake_loop_threadsafe(); +void wake_scheduler_threadsafe(); inline void wake_loop_any_context() { wake_loop_threadsafe(); } diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp index b58f1c9adc8..71182a46ad5 100644 --- a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.cpp @@ -16,4 +16,16 @@ void WakeTestComponent::start_async_wake() { }).detach(); } +void WakeTestComponent::start_async_timeout(uint32_t delay_ms) { + const uint32_t start_time = millis(); + std::thread([this, start_time, delay_ms] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + const int start_loop_count = this->get_loop_count(); + this->set_timeout(delay_ms, [this, start_time, start_loop_count, delay_ms] { + ESP_LOGI(TAG, "SCHEDULER_WAKE_RESULT delay=%u elapsed=%u loop_delta=%d", static_cast(delay_ms), + static_cast(millis() - start_time), this->get_loop_count() - start_loop_count); + }); + }).detach(); +} + } // namespace esphome::wake_test_component diff --git a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h index c8e4e0a89fe..a1f06f8998c 100644 --- a/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h +++ b/tests/integration/fixtures/external_components/wake_test_component/wake_test_component.h @@ -18,6 +18,10 @@ class WakeTestComponent : public Component { // loop_interval_ has been raised high enough to gate it off otherwise. void start_async_wake(); + // Spawn a detached thread that inserts a timeout (or a defer when delay_ms + // is 0) while the main loop sleeps, and report how soon it ran. + void start_async_timeout(uint32_t delay_ms); + float get_setup_priority() const override { return setup_priority::DATA; } protected: diff --git a/tests/integration/fixtures/scheduler_background_wake.yaml b/tests/integration/fixtures/scheduler_background_wake.yaml new file mode 100644 index 00000000000..78e6864eb2d --- /dev/null +++ b/tests/integration/fixtures/scheduler_background_wake.yaml @@ -0,0 +1,31 @@ +esphome: + name: scheduler-background-wake + on_boot: + priority: -100 + then: + - lambda: |- + App.set_loop_interval(5000); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [wake_test_component] + +wake_test_component: + id: wake_test + +button: + - platform: template + name: Start Scheduler Timeout + on_press: + - lambda: id(wake_test)->start_async_timeout(100); + - platform: template + name: Start Scheduler Defer + on_press: + - lambda: id(wake_test)->start_async_timeout(0); diff --git a/tests/integration/test_scheduler_background_wake.py b/tests/integration/test_scheduler_background_wake.py new file mode 100644 index 00000000000..fa7d34f4943 --- /dev/null +++ b/tests/integration/test_scheduler_background_wake.py @@ -0,0 +1,84 @@ +"""A background scheduler insertion must interrupt the loop's current sleep. + +Covers both set_timeout and defer: a zero-delay insert from another thread +takes the separate defer queue on multi-threaded builds. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_background_wake( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + results: dict[int, asyncio.Future[tuple[int, int]]] = { + 100: loop.create_future(), + 0: loop.create_future(), + } + + def on_log_line(line: str) -> None: + match = re.search( + r"SCHEDULER_WAKE_RESULT delay=(\d+) elapsed=(\d+) loop_delta=(-?\d+)", + line, + ) + if match is None: + return + result = results[int(match.group(1))] + if not result.done(): + result.set_result((int(match.group(2)), int(match.group(3)))) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-background-wake" + entities, _ = await client.list_entities_services() + for object_id, delay in ( + ("start_scheduler_timeout", 100), + ("start_scheduler_defer", 0), + ): + start_button = require_entity( + entities, + object_id, + ButtonInfo, + description=f"{object_id} button", + ) + client.button_command(start_button.key) + + try: + elapsed, loop_delta = await asyncio.wait_for( + results[delay], timeout=10.0 + ) + except TimeoutError: + pytest.fail(f"background insert with delay={delay} did not fire") + + assert elapsed < 1000, ( + f"background insert with delay={delay} should interrupt the " + f"five-second sleep; it fired after {elapsed}ms" + ) + assert loop_delta == 0, ( + f"scheduler-only wake must not run component loops; observed " + f"loop_delta={loop_delta} for delay={delay}" + ) From 0652e6c3fb2df6ad1aa9cce4382ca3f73dd79880 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:02:22 -1000 Subject: [PATCH 37/79] [esp32][libretiny] Skip registering static destructors that never run (#20440) --- esphome/components/esp32/__init__.py | 5 +++++ esphome/components/esp32/atexit_stubs.cpp | 20 +++++++++++++++++++ esphome/components/libretiny/__init__.py | 3 +++ esphome/components/libretiny/atexit_stubs.cpp | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 esphome/components/esp32/atexit_stubs.cpp create mode 100644 esphome/components/libretiny/atexit_stubs.cpp diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index bb6333956ef..cfa79e3cf1f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2926,6 +2926,11 @@ async def to_code(config): # produce reproducible outputs and downstream tooling can reuse artifacts. add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + # --undefined: libsrc.a is scanned before the IDF libraries that also register them. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit") + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/components/esp32/atexit_stubs.cpp b/esphome/components/esp32/atexit_stubs.cpp new file mode 100644 index 00000000000..1230a566814 --- /dev/null +++ b/esphome/components/esp32/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables (about 400 bytes of RAM on ESP32). + */ + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP32 diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5a82cce7ad7..61eafe5f86d 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -492,6 +492,9 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: cg.add_build_flag("-Wl,--wrap=pvPortMalloc") diff --git a/esphome/components/libretiny/atexit_stubs.cpp b/esphome/components/libretiny/atexit_stubs.cpp new file mode 100644 index 00000000000..1d237e7f059 --- /dev/null +++ b/esphome/components/libretiny/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables. + */ + +#include "esphome/core/defines.h" + +#ifdef USE_LIBRETINY + +namespace esphome::libretiny {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_LIBRETINY From 76ba7f0c88460e081ae7b798f63211c57b6d362d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:10:06 -1000 Subject: [PATCH 38/79] [espidf] Cache the discovered component list once per target (#20432) --- esphome/build_gen/espidf.py | 49 ++++++++++++++++++----- esphome/espidf/toolchain.py | 29 ++++++-------- tests/unit_tests/build_gen/test_espidf.py | 46 +++++++++++++++++++-- tests/unit_tests/test_espidf_toolchain.py | 28 +++++++------ 4 files changed, 107 insertions(+), 45 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index e7a2dc1536b..759d9a0bef4 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -237,6 +237,35 @@ def idf_macro_matches(idf_path: Path) -> bool: return live == _EXPECTED_MACRO +def _cmake_args_for_write(minimal: bool) -> dict[str, str]: + """The cg.add_cmake_arg() values for one project write. + + The discovery (minimal) write lets every built-in component register so + the list it finds, cached per target, serves any exclusion set; the full + write drops the excluded names from that list and passes all of them to + IDF. Names outside the framework's components directory (managed + components) stay excluded on the discovery write too, since the list + only holds built-in ones. A checkout supplied through IDF_PATH caches + nothing, so its discovery keeps every exclusion. + """ + from esphome.espidf.toolchain import _esphome_manages_idf, _get_idf_path + + args = dict(CORE.cmake_args) + if ( + not minimal + or not _esphome_manages_idf() + or not (excluded := args.get("EXCLUDE_COMPONENTS")) + ): + return args + root = _get_idf_path() / "components" + outside = [name for name in excluded.split(";") if not (root / name).is_dir()] + if outside: + args["EXCLUDE_COMPONENTS"] = ";".join(outside) + else: + del args["EXCLUDE_COMPONENTS"] + return args + + def get_project_cmakelists( minimal: bool = False, builtin_components: list[str] | None = None ) -> str: @@ -300,11 +329,10 @@ def get_project_cmakelists( # 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 - # (discovery) writes too so excluded components never register. + # set when project.cmake seeds the component list. cmake_args = "\n".join( f"set({name} {_cmake_quote(value)})" - for name, value in sorted(CORE.cmake_args.items()) + for name, value in sorted(_cmake_args_for_write(minimal).items()) ) # Per-project list exposed as a CMake variable so converted PIO libs @@ -327,10 +355,10 @@ def get_project_cmakelists( # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. - # Excluded components are dropped here as well: a stale - # project_description.json from a build without exclusions may still - # list them, and requiring an excluded component pulls it back into - # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Excluded components are dropped here as well: the discovery list + # (cached per target) still holds them, and requiring an excluded + # component pulls it back into the build (IDF requirement expansion + # overrides EXCLUDE_COMPONENTS). # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the # two can never disagree within one generated file. builtin_components_property = ( @@ -554,10 +582,9 @@ def write_project( ) # Snapshot the exclusion set so has_outdated_files() can trigger a - # discovery reconfigure when it changes. Excluded components never - # register in project_description.json, so re-including one (e.g. a - # config gains mqtt) requires a fresh discovery pass before the - # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + # reconfigure when it changes. The ESPHOME_PROJECT_BUILTIN_COMPONENTS + # property drops excluded names, so re-including one (e.g. a config + # gains mqtt) needs a reconfigure to rebuild it from the discovery list. write_file_if_changed( CORE.relative_build_path("exclude_components.esphomeinternal"), ";".join(get_excluded_builtin_components()), diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index effb817f353..af3fda63495 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field import fnmatch -import hashlib import json import logging import os @@ -516,23 +515,21 @@ def _sync_component_mirror() -> None: def _builtin_component_cache_path() -> Path | None: - """Cache file for this build's built-in component list. + """Cache file for this target's built-in component list. The file lives inside the extracted framework directory so it is discarded together with that exact checkout (re-extract, source - override, clean-all); the target and the EXCLUDE_COMPONENTS set name it. - The sdkconfig is not part of the key: IDF components register regardless - of CONFIG_* options and only gate their sources on them. A checkout - supplied through IDF_PATH is not managed by ESPHome and is never cached. + override, clean-all). The discovery configure registers every built-in + component, so the list serves any EXCLUDE_COMPONENTS set (the project + write drops the excluded names) and any sdkconfig (IDF components + register regardless of CONFIG_* options and only gate their sources on + them). A checkout supplied through IDF_PATH is not managed by ESPHome + and is never cached. """ if not _esphome_manages_idf(): return None target = variant_to_idf_target(CORE.data[KEY_ESP32][KEY_VARIANT]) - excluded = CORE.cmake_args.get("EXCLUDE_COMPONENTS", "") - excluded_key = hashlib.sha256(excluded.encode()).hexdigest()[:12] - return ( - _get_idf_path() / ".esphome_component_lists" / f"{target}-{excluded_key}.json" - ) + return _get_idf_path() / ".esphome_component_lists" / f"{target}.json" def load_cached_builtin_components() -> list[str] | None: @@ -635,10 +632,9 @@ def has_outdated_files(): already deletes ``dependencies.lock`` on a change but that signal gets lost as soon as the lock is missing. - ``exclude_components.esphomeinternal`` -- the resolved - EXCLUDE_COMPONENTS set. Excluded components never register in - ``project_description.json``, so re-including one needs a fresh - discovery pass before it can appear in the builtin-components - property that ``src`` REQUIRES. + EXCLUDE_COMPONENTS set. The builtin-components property that + ``src`` REQUIRES drops excluded names, so re-including one needs a + reconfigure to rebuild that property from the discovery list. We deliberately don't watch: - The top-level/src ``CMakeLists.txt`` -- ESPHome owns those, and @@ -803,8 +799,7 @@ def run_compile(config, verbose: bool) -> int: Uses two-phase configure to auto-discover available components: 1. If no previous build, configure with minimal REQUIRES to discover - components (skipped when a cached list for this IDF/target/exclusion - set exists) + components (skipped when a cached list for this IDF/target exists) 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 2c95c61b372..c439b87d163 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import os from pathlib import Path from unittest.mock import patch @@ -350,17 +351,54 @@ def test_get_project_cmakelists_emits_exclude_components(tmp_path: Path) -> None assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_lcd" not in content -def test_get_project_cmakelists_minimal_emits_exclude_components() -> None: - """The discovery (minimal) write also excludes components so they never - register in project_description.json.""" +def test_get_project_cmakelists_minimal_excludes_only_managed_components( + tmp_path: Path, +) -> None: + """The discovery (minimal) write lets built-in components register so + the cached list serves every exclusion set; managed ones stay excluded.""" + (tmp_path / "components" / "unity").mkdir(parents=True) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity", "espressif__esp_hosted"} + register_exclude_components_cmake_arg() + + with patch("esphome.espidf.toolchain._get_idf_path", return_value=tmp_path): + minimal = _render(minimal=True) + full = _render() + + assert 'set(EXCLUDE_COMPONENTS "espressif__esp_hosted")' in minimal + assert 'set(EXCLUDE_COMPONENTS "espressif__esp_hosted;unity")' in full + + +def test_get_project_cmakelists_minimal_keeps_exclusions_for_custom_idf_path( + tmp_path: Path, +) -> None: + """Nothing is cached for a user-supplied IDF_PATH, so its discovery write + keeps every exclusion.""" + (tmp_path / "components" / "unity").mkdir(parents=True) CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} register_exclude_components_cmake_arg() - content = _render(minimal=True) + with ( + patch("esphome.espidf.toolchain._get_idf_path", return_value=tmp_path), + patch.dict(os.environ, {"IDF_PATH": str(tmp_path)}), + ): + content = _render(minimal=True) assert 'set(EXCLUDE_COMPONENTS "unity")' in content +def test_get_project_cmakelists_minimal_drops_exclude_line_when_all_builtin( + tmp_path: Path, +) -> None: + (tmp_path / "components" / "unity").mkdir(parents=True) + CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = {"unity"} + register_exclude_components_cmake_arg() + + with patch("esphome.espidf.toolchain._get_idf_path", return_value=tmp_path): + content = _render(minimal=True) + + assert "EXCLUDE_COMPONENTS" not in content + + def test_get_project_cmakelists_no_exclude_components_line_when_empty() -> None: """No EXCLUDE_COMPONENTS line at all when nothing is excluded.""" register_exclude_components_cmake_arg() diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 854c39a9ce1..2a799a4d84a 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -647,7 +647,7 @@ def test_run_compile_cache_hit_skips_discovery(setup_core: Path) -> None: @contextmanager -def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: +def _cache_env(tmp_path: Path) -> Iterator[Path]: """Patch everything the cache key derives from onto a temp IDF tree and yield that tree's path.""" idf_path = tmp_path / "idf" @@ -655,7 +655,6 @@ def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: with ( patch.object(toolchain, "_get_idf_path", return_value=idf_path), patch.dict(CORE.data, {KEY_ESP32: {KEY_VARIANT: "ESP32"}}), - patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": excluded}), ): yield idf_path @@ -663,7 +662,7 @@ def _cache_env(tmp_path: Path, excluded: str) -> Iterator[Path]: def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None: """A saved list is read back until it is dropped.""" _setup_build(setup_core) - with _cache_env(tmp_path, "fatfs") as idf_path: + with _cache_env(tmp_path) as idf_path: for name in ("lwip", "esp_timer"): (idf_path / "components" / name).mkdir() assert toolchain.load_cached_builtin_components() is None @@ -673,25 +672,28 @@ def test_component_cache_round_trip(setup_core: Path, tmp_path: Path) -> None: assert toolchain.load_cached_builtin_components() is None -def test_component_cache_misses_on_key_change_or_missing_component( +def test_component_cache_is_per_target_and_drops_missing_component( setup_core: Path, tmp_path: Path ) -> None: - """A different exclusion set uses another entry, an entry naming a + """One entry per target serves every exclusion set, an entry naming a component that no longer exists is ignored, and a custom IDF_PATH is never cached.""" _setup_build(setup_core) - with _cache_env(tmp_path, "fatfs") as idf_path: + with _cache_env(tmp_path) as idf_path: (idf_path / "components" / "lwip").mkdir() toolchain.save_cached_builtin_components(["lwip"]) path = toolchain._builtin_component_cache_path() assert path.parent == idf_path / ".esphome_component_lists" - assert path.name.startswith("esp32-") + assert path.name == "esp32.json" assert toolchain.load_cached_builtin_components() == ["lwip"] with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}): assert toolchain.load_cached_builtin_components() is None - with _cache_env(tmp_path, "fatfs;unity"): - assert toolchain.load_cached_builtin_components() is None - with _cache_env(tmp_path, "fatfs") as idf_path: + with ( + _cache_env(tmp_path), + patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": "fatfs;unity"}), + ): + assert toolchain.load_cached_builtin_components() == ["lwip"] + with _cache_env(tmp_path) as idf_path: path.write_text(json.dumps(["lwip", "gone"])) assert toolchain.load_cached_builtin_components() is None # A plain file with the right name is not a component directory. @@ -703,7 +705,7 @@ def test_component_cache_save_skips_empty_list_or_custom_idf_path( setup_core: Path, tmp_path: Path ) -> None: _setup_build(setup_core) - with _cache_env(tmp_path, "") as idf_path: + with _cache_env(tmp_path) as idf_path: toolchain.save_cached_builtin_components([]) with patch.dict(os.environ, {"IDF_PATH": str(idf_path)}): toolchain.save_cached_builtin_components(["lwip"]) @@ -715,7 +717,7 @@ def test_component_cache_write_failure_is_logged( ) -> None: _setup_build(setup_core) with ( - _cache_env(tmp_path, ""), + _cache_env(tmp_path), patch.object(toolchain, "write_file", side_effect=EsphomeError("disk full")), ): toolchain.save_cached_builtin_components(["lwip"]) @@ -725,7 +727,7 @@ def test_component_cache_write_failure_is_logged( def test_component_cache_ignores_corrupt_file(setup_core: Path, tmp_path: Path) -> None: _setup_build(setup_core) - with _cache_env(tmp_path, ""): + with _cache_env(tmp_path): path = toolchain._builtin_component_cache_path() path.parent.mkdir(parents=True) path.write_text("{not json") From 456e3d8979e640f318f3156f8d70b5c4cb5c45aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:10:18 -1000 Subject: [PATCH 39/79] [ci] Keep bus group partners on the same runner (#20431) --- script/split_components_for_ci.py | 22 ++++++++++++++++---- tests/script/test_split_components_for_ci.py | 18 ++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 26907936c17..663f2c6d5d7 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -262,8 +262,9 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """Spread items over enough runners to stay near target_seconds each. The runner count comes from the total estimate with every grouped build - counted once. Heaviest items go first, each to the runner that ends up - lightest, so grouped components follow the builds they can join. + counted once. Heaviest items go first, each to the runner it adds the + fewest seconds to among those ending near the lightest, so grouped + components follow the builds they can join. """ if not items: return [] @@ -274,10 +275,23 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] + # Among the runners ending within slack of the lightest, the one the item + # adds the fewest seconds to wins, so a component lands with the bus + # group partners it shares builds with; placed away from them it builds + # alone on every platform they only share there. + slack = target_seconds // 4 for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): - min(batches, key=lambda b: (b.seconds + b.added_seconds(item), b.seconds)).add( - item + costs = [(batch, batch.added_seconds(item)) for batch in batches] + ceiling = min(batch.seconds + added for batch, added in costs) + slack + batch, _ = min( + ( + (batch, added) + for batch, added in costs + if batch.seconds + added <= ceiling + ), + key=lambda cost: (cost[1], cost[0].seconds + cost[1]), ) + batch.add(item) return [batch.components for batch in batches if batch.components] diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 4da644d6798..718d754f016 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -249,6 +249,24 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: assert item.own_seconds == 55 +def test_balance_batches_keeps_partners_together() -> None: + """Two components sharing a build land on one runner even when the + lightest runner is the other one.""" + share = { + ("i2c", "esp32-idf"): split_components_for_ci._GroupedShare(55, frozenset()), + ("i2c", "esp8266-ard"): split_components_for_ci._GroupedShare(30, frozenset()), + } + items = [ + split_components_for_ci._BatchItem("partner_a", 0, dict(share)), + split_components_for_ci._BatchItem("partner_b", 0, dict(share)), + split_components_for_ci._BatchItem("alone", 80), + ] + + batches = split_components_for_ci.balance_batches(items, target_seconds=120) + + assert sorted(batches) == [["alone"], ["partner_a", "partner_b"]] + + def test_balance_batches_empty() -> None: """No components means no runners.""" assert split_components_for_ci.balance_batches([], 600) == [] From 94e6bd90d8c5e4f709f76b88dfcd5182dc31f8b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:19:22 -1000 Subject: [PATCH 40/79] [sensor] Merge the radar timeout and throttle filters into one (#20118) --- esphome/components/ld2410/sensor.py | 80 ++---------- esphome/components/ld2412/sensor.py | 80 ++---------- esphome/components/ld2450/sensor.py | 120 ++---------------- esphome/components/ld6002b/sensor.py | 20 +-- esphome/components/sensor/__init__.py | 57 ++++++++- esphome/components/sensor/filter.cpp | 33 ++++- esphome/components/sensor/filter.h | 13 ++ .../sensor/test_timeout_throttle.py | 24 ++++ .../sensor/timeout_throttle.yaml | 52 ++++++++ .../sensor_timeout_throttle_filter.yaml | 67 ++++++++++ .../test_sensor_timeout_throttle_filter.py | 61 +++++++++ 11 files changed, 333 insertions(+), 274 deletions(-) create mode 100644 tests/component_tests/sensor/test_timeout_throttle.py create mode 100644 tests/component_tests/sensor/timeout_throttle.yaml create mode 100644 tests/integration/fixtures/sensor_timeout_throttle_filter.yaml create mode 100644 tests/integration/test_sensor_timeout_throttle_filter.py diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index 5b523d09442..a8636f3df69 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -34,83 +34,35 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_LIGHTBULB, ), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), @@ -123,29 +75,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index e236a7d5f38..f350fb70505 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -34,84 +34,36 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_LIGHTBULB, unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor ), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -124,29 +76,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index e714d353d1f..6f2056330bd 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -46,41 +46,17 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ACCOUNT_GROUP, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_HUMAN_GREETING_PROXIMITY, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ACCOUNT_SWITCH, ), } @@ -92,85 +68,37 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_X): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ALPHA_X_BOX_OUTLINE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_Y): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ALPHA_Y_BOX_OUTLINE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_SPEED): sensor.sensor_schema( device_class=DEVICE_CLASS_SPEED, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SPEEDOMETER_SLOW, unit_of_measurement=UNIT_MILLIMETER_PER_SECOND, ), cv.Optional(CONF_ANGLE): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, unit_of_measurement=UNIT_DEGREES, accuracy_decimals=1, ), cv.Optional(CONF_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_RESOLUTION): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE, unit_of_measurement=UNIT_MILLIMETER, ), @@ -183,41 +111,17 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), } diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index 5653f8b9449..d2285187f6b 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -30,44 +30,32 @@ from .const import ( DEPENDENCIES = ["ld6002b"] -# The ld2450 defaults for a streamed value: hold the last reading for a second so a -# dropped frame does not read as absence, then rate-limit what reaches the frontend. -_VALUE_SENSOR_FILTERS = [ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, -] - TARGET_SCHEMA = cv.Schema( { cv.Optional(CONF_X): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_Y): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_Z): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( accuracy_decimals=0, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 4a00904c4b5..2b404442a4d 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation import esphome.codegen as cg @@ -43,6 +44,7 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, + CONF_TYPE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WEB_SERVER, @@ -222,6 +224,13 @@ def validate_send_first_at(value): FILTER_REGISTRY = Registry() validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) +# Default for streamed readings (radar sensors): hold the last value for a second so a dropped +# frame doesn't read as absence, then rate limit. Codegen turns the pair into one TimeoutThrottleFilter. +TIMEOUT_THROTTLE_FILTERS = [ + {"timeout": {"timeout": cv.TimePeriod(milliseconds=1000), "value": "last"}}, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, +] + def validate_datapoint(value): if isinstance(value, dict): @@ -293,6 +302,7 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_( TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) +TimeoutThrottleFilter = sensor_ns.class_("TimeoutThrottleFilter", TimeoutFilterLast) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) @@ -683,6 +693,12 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( ) +def _is_nan_only(values: Any) -> bool: + if not isinstance(values, list): + values = [values] + return bool(values) and all(isinstance(v, float) and math.isnan(v) for v in values) + + @FILTER_REGISTRY.register( "throttle_with_priority", ThrottleWithPriorityFilter, @@ -696,7 +712,7 @@ async def throttle_with_priority_filter_to_code(config, filter_id): # omits `value:`) to avoid the TemplatableFn array + NaN lambda the # generic ValueListFilter path requires. Behavior is identical: NaN sensor # readings always bypass the throttle. - if values and all(isinstance(v, float) and math.isnan(v) for v in values): + if _is_nan_only(values): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) @@ -941,6 +957,43 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +def _timeout_throttle_period( + first: ConfigType, second: ConfigType +) -> cv.TimePeriod | None: + """Period of a `timeout` (value `last`) directly followed by a NaN only `throttle_with_priority`.""" + timeout = first.get("timeout") + throttle = second.get("throttle_with_priority") + if not isinstance(timeout, dict) or not isinstance(throttle, dict): + return None + if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]: + return None + if not _is_nan_only(throttle[CONF_VALUE]): + return None + return timeout[CONF_TIMEOUT] + + +async def _build_chain_filters(config: list[ConfigType]) -> list: + """Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter.""" + filters = [] + i = 0 + while i < len(config): + if ( + i + 1 < len(config) + and (period := _timeout_throttle_period(config[i], config[i + 1])) + is not None + ): + filter_id = config[i][CONF_TYPE_ID].copy() + filter_id.type = TimeoutThrottleFilter + var = cg.new_Pvariable(filter_id, period) + await cg.register_component(var, {}) + filters.append(var) + i += 2 + continue + filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i])) + i += 1 + return filters + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] @@ -979,7 +1032,7 @@ async def setup_sensor_core_(var, config): cg.add(var.set_force_update(True)) if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") - filters = await build_filters(config[CONF_FILTERS]) + filters = await _build_chain_filters(config[CONF_FILTERS]) cg.add(var.set_filters(filters)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 28f7e6100d3..732db5b0b4f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -13,6 +13,17 @@ namespace esphome::sensor { ESPHOME_LOG_TAG(TAG, "sensor.filter"); +/// Shared pass check for throttle_with_priority (NaN only): passes and stamps `last_input` when the +/// period has elapsed, on the first value, or for NaN. +static bool throttle_nan_passes(uint32_t &last_input, uint32_t period, float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (last_input == 0 || now - last_input >= period || std::isnan(value)) { + last_input = now; + return true; + } + return false; +} + // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -266,11 +277,8 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleWithPriorityNanFilter::new_value(float value) { - const uint32_t now = App.get_loop_component_start_time(); - if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { - this->last_input_ = now; + if (throttle_nan_passes(this->last_input_, this->min_time_between_inputs_, value)) return value; - } return {}; } @@ -355,6 +363,23 @@ optional TimeoutFilterConfigured::new_value(float value) { return value; } +// TimeoutThrottleFilter +optional TimeoutThrottleFilter::new_value(float value) { + TimeoutFilterLast::new_value(value); + if (throttle_nan_passes(this->last_input_, this->time_period_, value)) + return value; + return {}; +} + +void TimeoutThrottleFilter::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + if (throttle_nan_passes(this->last_input_, this->time_period_, this->pending_value_)) + this->output(this->pending_value_); + this->disable_loop(); + } +} + // DebounceFilter optional DebounceFilter::new_value(float value) { App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index bc086e3805b..510ee94c6a7 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -479,6 +479,19 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; +/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` (NaN only) with the +/// same period, as one object. Output matches the two filter chain exactly. +class TimeoutThrottleFilter : public TimeoutFilterLast { + public: + explicit TimeoutThrottleFilter(uint32_t time_period) : TimeoutFilterLast(time_period) {} + + optional new_value(float value) override; + void loop() override; + + protected: + uint32_t last_input_{0}; // When the throttle stage last let a value through +}; + class DebounceFilter : public Filter { public: explicit DebounceFilter(uint32_t time_period); diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py new file mode 100644 index 00000000000..8154471e472 --- /dev/null +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -0,0 +1,24 @@ +"""timeout (value last) followed by throttle_with_priority with the same period becomes one filter.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_timeout_throttle_pair_is_merged( + generate_main: Callable[[str | Path], str], +) -> None: + main_cpp = generate_main("tests/component_tests/sensor/timeout_throttle.yaml") + + # Only the `merged` sensor's adjacent pair becomes one object + assert ( + len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) + == 1 + ) + merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) + assert merged is not None + assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + # Every other shape keeps its two filters + for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): + call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) + assert call is not None and len(call.group(1).split(",")) == 2, sensor_id diff --git a/tests/component_tests/sensor/timeout_throttle.yaml b/tests/component_tests/sensor/timeout_throttle.yaml new file mode 100644 index 00000000000..2557db683cb --- /dev/null +++ b/tests/component_tests/sensor/timeout_throttle.yaml @@ -0,0 +1,52 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: merged + filters: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: 1s + - platform: template + id: other_periods + filters: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: 2s + - platform: template + id: other_order + filters: + - throttle_with_priority: 1s + - timeout: + timeout: 1s + value: last + - platform: template + id: value_list + filters: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: + timeout: 1s + value: [0.0] + - platform: template + id: configured_value + filters: + - timeout: + timeout: 1s + value: 0.0 + - throttle_with_priority: 1s + - platform: template + id: inside_or + filters: + - or: + - timeout: + timeout: 1s + value: last + - throttle_with_priority: 1s diff --git a/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml b/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml new file mode 100644 index 00000000000..5f1f9556e0f --- /dev/null +++ b/tests/integration/fixtures/sensor_timeout_throttle_filter.yaml @@ -0,0 +1,67 @@ +esphome: + name: test-timeout-throttle + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +sensor: + - platform: template + name: "Source" + id: source_sensor + accuracy_decimals: 2 + + # The default radar pair; codegen merges it into one TimeoutThrottleFilter + - platform: copy + source_id: source_sensor + name: "Merged" + filters: + - timeout: + timeout: 200ms + value: last + - throttle_with_priority: 200ms + + # Same chain with an identity filter in between, which keeps the two filter objects + - platform: copy + source_id: source_sensor + name: "Reference" + filters: + - timeout: + timeout: 200ms + value: last + - multiply: 1.0 + - throttle_with_priority: 200ms + +script: + - id: drive + then: + # Burst faster than the period: first passes, the rest are dropped, the timeout republishes the last + - sensor.template.publish: {id: source_sensor, state: 1.0} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: 2.0} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: 3.0} + - delay: 450ms + # NaN inside a burst always passes + - sensor.template.publish: {id: source_sensor, state: 4.0} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: !lambda "return NAN;"} + - delay: 30ms + - sensor.template.publish: {id: source_sensor, state: 5.0} + - delay: 450ms + # Values right around the period + - sensor.template.publish: {id: source_sensor, state: 6.0} + - delay: 250ms + - sensor.template.publish: {id: source_sensor, state: 7.0} + - delay: 190ms + - sensor.template.publish: {id: source_sensor, state: 8.0} + - delay: 600ms + - sensor.template.publish: {id: source_sensor, state: 99.0} + +button: + - platform: template + name: "Drive" + on_press: + - script.execute: drive diff --git a/tests/integration/test_sensor_timeout_throttle_filter.py b/tests/integration/test_sensor_timeout_throttle_filter.py new file mode 100644 index 00000000000..739421ca3aa --- /dev/null +++ b/tests/integration/test_sensor_timeout_throttle_filter.py @@ -0,0 +1,61 @@ +"""The merged timeout + throttle_with_priority filter publishes exactly what the two filter chain does.""" + +from __future__ import annotations + +import asyncio +import math + +from aioesphomeapi import ButtonInfo, EntityState, SensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_sensor_timeout_throttle_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + loop = asyncio.get_running_loop() + values: dict[str, list[float]] = {"merged": [], "reference": []} + done = {name: loop.create_future() for name in values} + + def on_state(state: EntityState) -> None: + if not isinstance(state, SensorState): + return + name = key_to_sensor.get(state.key) + if name not in values: + return + values[name].append(math.nan if state.missing_state else state.state) + # 99 is published last and republished once by the timeout + if values[name].count(99.0) == 2 and not done[name].done(): + done[name].set_result(True) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + key_to_sensor = build_key_to_entity_mapping( + entities, {"merged": "Merged", "reference": "Reference"} + ) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + button = next( + e.key for e in entities if isinstance(e, ButtonInfo) and e.name == "Drive" + ) + client.button_command(button) + try: + await asyncio.wait_for(asyncio.gather(*done.values()), timeout=5.0) + except TimeoutError: + pytest.fail(f"Timed out: {values}") + + merged, reference = values["merged"], values["reference"] + assert len(merged) == len(reference), (merged, reference) + for got, want in zip(merged, reference, strict=True): + assert (math.isnan(got) and math.isnan(want)) or got == want, ( + merged, + reference, + ) + assert any(math.isnan(v) for v in merged) From 588fde91b252f55d5e19c95f634393ca3e423f5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:19:35 -1000 Subject: [PATCH 41/79] [template] Keep alarm codes in a shared flash table (#20084) --- .../template/alarm_control_panel/__init__.py | 9 ++++++-- .../template_alarm_control_panel.cpp | 16 ++++++++----- .../template_alarm_control_panel.h | 15 +++++------- .../template/config/alarm_codes.yaml | 23 +++++++++++++++++++ .../template/test_template_alarm_codes.py | 20 ++++++++++++++++ 5 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/template/config/alarm_codes.yaml create mode 100644 tests/component_tests/template/test_template_alarm_codes.py diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index 59624a5f53f..a708c83d09f 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -117,8 +117,13 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await alarm_control_panel.new_alarm_control_panel(config) await cg.register_component(var, config) - if CONF_CODES in config: - cg.add(var.set_codes(config[CONF_CODES])) + # Shared flash table ended by nullptr, so the panel stores only a pointer. + # requires_code_to_arm needs codes (validate_config) and defaults to false. + if codes := config.get(CONF_CODES): + table = cg.shared_progmem_array( + "alarm_codes", cg.const_char_ptr, [*codes, cg.nullptr] + ) + cg.add(var.set_codes(table)) if CONF_REQUIRES_CODE_TO_ARM in config: cg.add(var.set_requires_code_to_arm(config[CONF_REQUIRES_CODE_TO_ARM])) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 293f0255278..12ff0b5a4b4 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -36,6 +36,11 @@ static const LogString *sensor_type_to_string(AlarmSensorType type) { #endif void TemplateAlarmControlPanel::dump_config() { + size_t num_codes = 0; + if (this->codes_ != nullptr) { + while (this->codes_[num_codes] != nullptr) + num_codes++; + } ESP_LOGCONFIG(TAG, "TemplateAlarmControlPanel:\n" " Current State: %s\n" @@ -47,8 +52,8 @@ void TemplateAlarmControlPanel::dump_config() { " Pending Time: %" PRIu32 "s\n" " Trigger Time: %" PRIu32 "s\n" " Supported Features: %" PRIu32, - LOG_STR_ARG(alarm_control_panel_state_to_string(this->current_state_)), this->codes_.size(), - YESNO(!this->codes_.empty() && this->requires_code_to_arm_), (this->arming_away_time_ / 1000), + LOG_STR_ARG(alarm_control_panel_state_to_string(this->current_state_)), num_codes, + YESNO(this->codes_ != nullptr && this->requires_code_to_arm_), (this->arming_away_time_ / 1000), (this->arming_home_time_ / 1000), (this->arming_night_time_ / 1000), (this->pending_time_ / 1000), (this->trigger_time_ / 1000), this->get_supported_features()); #ifdef USE_BINARY_SENSOR @@ -194,13 +199,12 @@ void TemplateAlarmControlPanel::loop() { } bool TemplateAlarmControlPanel::is_code_valid_(optional code) { - if (!this->codes_.empty()) { + if (this->codes_ != nullptr) { if (code.has_value()) { ESP_LOGVV(TAG, "Checking code: %s", code.value().c_str()); - // Use strcmp for const char* comparison const char *code_cstr = code.value().c_str(); - for (const char *stored_code : this->codes_) { - if (strcmp(stored_code, code_cstr) == 0) + for (const char *const *stored = this->codes_; *stored != nullptr; stored++) { + if (strcmp(*stored, code_cstr) == 0) return true; } return false; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 5888ce5e29b..c977da10a9b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -61,7 +61,7 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void setup() override; void loop() override; uint32_t get_supported_features() const override; - bool get_requires_code() const override { return !this->codes_.empty(); } + bool get_requires_code() const override { return this->codes_ != nullptr; } bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -83,14 +83,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl AlarmSensorType type = ALARM_SENSOR_TYPE_DELAYED); #endif - /** Set the codes (from initializer list). + /** Set the codes. * - * @param codes The list of valid codes + * @param codes nullptr-terminated table of valid codes; must outlive the panel */ - void set_codes(std::initializer_list codes) { this->codes_ = codes; } - - // Deleted overload to catch incorrect std::string usage at compile time - void set_codes(std::initializer_list codes) = delete; + void set_codes(const char *const *codes) { this->codes_ = codes; } /** set requires a code to arm * @@ -150,8 +147,8 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t pending_time_; // the time in trigger uint32_t trigger_time_; - // a list of codes (const char* pointers to string literals in flash) - FixedVector codes_; + // nullptr-terminated table of codes, shared in flash + const char *const *codes_{nullptr}; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; diff --git a/tests/component_tests/template/config/alarm_codes.yaml b/tests/component_tests/template/config/alarm_codes.yaml new file mode 100644 index 00000000000..afeb45e1d29 --- /dev/null +++ b/tests/component_tests/template/config/alarm_codes.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +alarm_control_panel: + - platform: template + id: panel_a + name: Panel A + codes: + - "1234" + - "5678" + - platform: template + id: panel_b + name: Panel B + codes: + - "1234" + - "5678" + - platform: template + id: panel_none + name: Panel None + codes: [] diff --git a/tests/component_tests/template/test_template_alarm_codes.py b/tests/component_tests/template/test_template_alarm_codes.py new file mode 100644 index 00000000000..1a8e94be909 --- /dev/null +++ b/tests/component_tests/template/test_template_alarm_codes.py @@ -0,0 +1,20 @@ +"""Tests for the template alarm control panel codes codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_codes_share_one_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Identical code lists share one nullptr-terminated flash table; empty lists set none.""" + main_cpp = generate_main(component_config_path("alarm_codes.yaml")) + + assert ( + 'static constexpr const char * alarm_codes[] PROGMEM = {"1234", "5678", nullptr};' + in main_cpp + ) + assert "panel_a->set_codes(alarm_codes);" in main_cpp + assert "panel_b->set_codes(alarm_codes);" in main_cpp + assert "panel_none->set_codes(" not in main_cpp From f7b80e6d44b70fa444cf6d3184f609112f917f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:20:17 -1000 Subject: [PATCH 42/79] [dlms_meter] Remove deprecated provider option (#20385) --- esphome/components/dlms_meter/__init__.py | 75 +++++-------------- tests/component_tests/dlms_meter/__init__.py | 0 .../dlms_meter/test_provider.py | 17 +++++ tests/components/dlms_meter/common.yaml | 7 +- 4 files changed, 40 insertions(+), 59 deletions(-) create mode 100644 tests/component_tests/dlms_meter/__init__.py create mode 100644 tests/component_tests/dlms_meter/test_provider.py diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index f23b2de6c21..6d86da6ccf5 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,4 +1,3 @@ -import logging import re from typing import Any @@ -18,8 +17,6 @@ from esphome.const import ( from esphome.core import CORE from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] DOMAIN = "dlms_meter" @@ -75,56 +72,6 @@ def validate_custom_pattern(value: ConfigType) -> ConfigType: return value -def validate_provider_deprecation(config: ConfigType) -> ConfigType: - if CONF_PROVIDER in config: - provider = str(config[CONF_PROVIDER]).lower() - if provider == "netznoe": - _LOGGER.warning( - "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " - "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" - "Please remove the 'provider' key and explicitly replace it with the following:\n\n" - "custom_patterns:\n" - ' - pattern: "L, TSTR"\n' - ' name: "MeterID"\n' - ' default_obis: "0.0.96.1.0.255"\n' - ' - pattern: "F, TDTM"\n' - ' name: "DateTime"\n' - ' default_obis: "0.0.1.0.0.255"\n' - ) - patterns = config.get(CONF_CUSTOM_PATTERNS, []) - - # Ensure "L, TSTR" for MeterID is present - if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): - patterns.append( - { - CONF_PATTERN: "L, TSTR", - CONF_NAME: "MeterID", - CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], - CONF_PRIORITY: 0, - } - ) - - # Ensure "F, TDTM" for DateTime is present - if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): - patterns.append( - { - CONF_PATTERN: "F, TDTM", - CONF_NAME: "DateTime", - CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], - CONF_PRIORITY: 0, - } - ) - - config[CONF_CUSTOM_PATTERNS] = patterns - else: - _LOGGER.warning( - "The 'provider' option is deprecated and will be removed in 2026.11.0. " - "The dlms_parser library now handles quirks dynamically. " - "Please remove this option from your configuration." - ) - return config - - CUSTOM_PATTERN_SCHEMA = cv.All( custom_pattern_dict, cv.Schema( @@ -138,7 +85,7 @@ CUSTOM_PATTERN_SCHEMA = cv.All( validate_custom_pattern, ) -CONFIG_SCHEMA = cv.All( +CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), @@ -146,15 +93,27 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, - cv.Optional(CONF_PROVIDER): cv.string, + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_PROVIDER): cv.invalid( + "The 'provider' option has been removed in ESPHome 2026.11.0.\n" + "For 'provider: netznoe', replace it with:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n\n' + "For any other provider, remove the option" + ), cv.Optional( CONF_RECEIVE_TIMEOUT, default="1000ms" ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), - validate_provider_deprecation, + .extend(cv.COMPONENT_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) @@ -193,7 +152,7 @@ async def to_code(config: ConfigType) -> None: cg.ArrayInitializer( p[CONF_PATTERN], name_expr, - p.get(CONF_PRIORITY, 0), + p[CONF_PRIORITY], obis_expr, ) ) diff --git a/tests/component_tests/dlms_meter/__init__.py b/tests/component_tests/dlms_meter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/dlms_meter/test_provider.py b/tests/component_tests/dlms_meter/test_provider.py new file mode 100644 index 00000000000..9ce07f118ac --- /dev/null +++ b/tests/component_tests/dlms_meter/test_provider.py @@ -0,0 +1,17 @@ +"""Tests for the dlms_meter provider option removed in 2026.11.0.""" + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.dlms_meter import CONFIG_SCHEMA + + +def test_removed_provider_shows_the_netznoe_patterns() -> None: + with pytest.raises(Invalid) as exc_info: + CONFIG_SCHEMA({"provider": "netznoe"}) + errors = ( + exc_info.value.errors + if isinstance(exc_info.value, MultipleInvalid) + else [exc_info.value] + ) + assert any('pattern: "L, TSTR"' in str(error) for error in errors) diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 59d854a3ae5..4d32f4e02fe 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -4,10 +4,15 @@ dlms_meter: decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" auth_key: "11223344556677889900AABBCCDDEEFF" skip_crc: true - provider: "netznoe" custom_patterns: - "custom_pattern_1" - "custom_pattern_2" + - pattern: "L, TSTR" + name: "MeterID" + default_obis: "0.0.96.1.0.255" + - pattern: "F, TDTM" + name: "DateTime" + default_obis: "0.0.1.0.0.255" sensor: # Old Schema tests From 5cd7bf59931b8775e95e8bc84774e7a1ccbba16b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:20:46 -1000 Subject: [PATCH 43/79] [wireguard] Keep allowed IPs in a shared flash table (#20087) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/wireguard/__init__.py | 19 ++++++++------ esphome/components/wireguard/wireguard.cpp | 8 +++--- esphome/components/wireguard/wireguard.h | 8 +++--- .../wireguard/config/allowed_ips.yaml | 26 +++++++++++++++++++ .../wireguard/test_allowed_ips.py | 21 +++++++++++++++ 5 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/wireguard/config/allowed_ips.yaml create mode 100644 tests/component_tests/wireguard/test_allowed_ips.py diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 069b291574d..e9641a5957c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -121,16 +121,19 @@ async def to_code(config): ) ) + # Shared flash table ended by an empty entry, so the component stores only a pointer. + entries = [ + cg.StructInitializer( + AllowedIP, + ("ip", str(ip.network_address)), + ("netmask", str(ip.netmask)), + ) + for ip in allowed_ips + ] + end = cg.StructInitializer(AllowedIP, ("ip", cg.nullptr), ("netmask", cg.nullptr)) cg.add( var.set_allowed_ips( - [ - cg.StructInitializer( - AllowedIP, - ("ip", str(ip.network_address)), - ("netmask", str(ip.netmask)), - ) - for ip in allowed_ips - ] + cg.shared_progmem_array("wireguard_allowed_ips", AllowedIP, [*entries, end]) ) ) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index 73e34b6592e..01921f17e8a 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -154,8 +154,8 @@ void Wireguard::dump_config() { (this->preshared_key_ != nullptr ? preshared_key_masked : LOG_STR_LITERAL("NOT IN USE"))); // clang-format on ESP_LOGCONFIG(TAG, " Peer Allowed IPs:"); - for (const AllowedIP &allowed_ip : this->allowed_ips_) { - ESP_LOGCONFIG(TAG, " - %s/%s", allowed_ip.ip, allowed_ip.netmask); + for (const AllowedIP *it = this->allowed_ips_; it != nullptr && it->ip != nullptr; it++) { + ESP_LOGCONFIG(TAG, " - %s/%s", it->ip, it->netmask); } ESP_LOGCONFIG(TAG, " Peer Persistent Keepalive: %d%s", this->keepalive_, (this->keepalive_ > 0 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL(" (DISABLED)"))); @@ -249,8 +249,8 @@ void Wireguard::start_connection_() { ESP_LOGD(TAG, "Configuring allowed IPs list"); bool allowed_ips_ok = true; - for (const AllowedIP &ip : this->allowed_ips_) { - allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), ip.ip, ip.netmask) == ESP_OK); + for (const AllowedIP *it = this->allowed_ips_; it != nullptr && it->ip != nullptr; it++) { + allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), it->ip, it->netmask) == ESP_OK); } if (allowed_ips_ok) { diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index faf63cd444c..6125d2f52cf 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #ifdef USE_WIREGUARD #include -#include #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -58,9 +57,8 @@ class Wireguard final : public PollingComponent { void set_peer_public_key(const std::string &key) = delete; void set_preshared_key(const std::string &key) = delete; - void set_allowed_ips(std::initializer_list ips) { this->allowed_ips_ = ips; } - /// Prevent accidental use of std::string which would dangle - void set_allowed_ips(std::initializer_list> ips) = delete; + /// Table ended by an entry with a null ip; must outlive the component. + void set_allowed_ips(const AllowedIP *ips) { this->allowed_ips_ = ips; } void set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } void set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } @@ -105,7 +103,7 @@ class Wireguard final : public PollingComponent { const char *peer_public_key_{nullptr}; const char *preshared_key_{nullptr}; - FixedVector allowed_ips_; + const AllowedIP *allowed_ips_{nullptr}; uint16_t peer_port_; uint16_t keepalive_; diff --git a/tests/component_tests/wireguard/config/allowed_ips.yaml b/tests/component_tests/wireguard/config/allowed_ips.yaml new file mode 100644 index 00000000000..7f191f90fb7 --- /dev/null +++ b/tests/component_tests/wireguard/config/allowed_ips.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: test + password: password1 + +time: + - platform: sntp + id: sntp_time + +wireguard: + time_id: sntp_time + address: 172.16.34.100 + netmask: 255.255.255.0 + private_key: wPBMxtNYH3mChicrbpsRpZIasIdPq3yZuthn23FbGG8= + peer_public_key: Hs2JfikvYU03/Kv3YoAs1hrUIPPTEkpsZKSPUljE9yc= + peer_endpoint: wg.server.example + peer_allowed_ips: + - 172.16.34.0/24 + - 192.168.0.0/16 diff --git a/tests/component_tests/wireguard/test_allowed_ips.py b/tests/component_tests/wireguard/test_allowed_ips.py new file mode 100644 index 00000000000..ff490c516e2 --- /dev/null +++ b/tests/component_tests/wireguard/test_allowed_ips.py @@ -0,0 +1,21 @@ +"""Tests for the wireguard allowed IPs codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_allowed_ips_are_a_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Allowed IPs live in a flash table ended by an empty entry instead of a heap vector.""" + main_cpp = generate_main(component_config_path("allowed_ips.yaml")) + + table = main_cpp.split( + "wireguard::AllowedIP wireguard_allowed_ips[] PROGMEM = ", 1 + )[1] + table = table.split(";", 1)[0] + assert '.ip = "172.16.34.0"' in table + assert '.netmask = "255.255.0.0"' in table + assert ".ip = nullptr" in table + assert "->set_allowed_ips(wireguard_allowed_ips);" in main_cpp From 23d13d3a6ee5cd3e8efa59be2ec6b56e908e8d4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:21:16 -1000 Subject: [PATCH 44/79] [core] Stop re-exporting alloc_helpers.h from helpers.h (#20389) --- esphome/components/debug/debug_zephyr.cpp | 1 + esphome/core/alloc_helpers.h | 32 +++++++++++++ esphome/core/helpers.cpp | 19 -------- esphome/core/helpers.h | 56 ----------------------- script/ci-custom.py | 4 -- 5 files changed, 33 insertions(+), 79 deletions(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index f020c8cd772..cec2ab0be8b 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -1,6 +1,7 @@ #include "debug_component.h" #ifdef USE_ZEPHYR #include +#include "esphome/core/alloc_helpers.h" #include "esphome/core/log.h" #include #include diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h index 1da3162333b..6f824b5383c 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -10,12 +10,16 @@ /// Prefer the stack-based alternatives documented on each function instead. /// New code should avoid using these functions. +#include #include #include #include #include +#include #include +#include "esphome/core/helpers.h" + namespace esphome { // --- String helpers (allocating) --- @@ -70,6 +74,19 @@ std::string format_hex(const uint8_t *data, size_t length); /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. std::string format_hex(const std::vector &data); +/// Format an unsigned integer in lowercased hex, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +template::value, int> = 0> std::string format_hex(T val) { + val = convert_big_endian(val); + return format_hex(reinterpret_cast(&val), sizeof(T)); +} + +/// Format the std::array \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +template std::string format_hex(const std::array &data) { + return format_hex(data.data(), data.size()); +} + /// Format a byte array in pretty-printed, human-readable hex format. /// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); @@ -90,10 +107,25 @@ std::string format_hex_pretty(const std::vector &data, char separator /// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); +/// Format an unsigned integer in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +template::value, int> = 0> +std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { + val = convert_big_endian(val); + return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); +} + /// Format the byte array \p data of length \p len in binary. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); +/// Format an unsigned integer in binary, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +template::value, int> = 0> std::string format_bin(T val) { + val = convert_big_endian(val); + return format_bin(reinterpret_cast(&val), sizeof(T)); +} + // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6130f7e27df..1b332515f66 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -261,7 +261,6 @@ bool str_contains_ignore_case_p(const char *haystack, PGM_P needle) { } #endif // USE_ESP8266 -// str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { return buffer; @@ -274,8 +273,6 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { return buffer; } -// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp - size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len) { size_t total_len = name_len + 1 + suffix_len; @@ -309,8 +306,6 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { return chars; } -// format_mac_address_pretty moved to alloc_helpers.cpp - // Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. // When separator is set, it is written unconditionally after each byte and the last // one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. @@ -428,8 +423,6 @@ const char *json_escape_into_buffer(std::span buf, StringRef value, bool s return buf.data(); } -// format_hex (std::string returning overloads) moved to alloc_helpers.cpp - char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { return format_hex_internal(buffer, buffer_size, data, length, separator, 'A'); } @@ -465,8 +458,6 @@ char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint16_t *dat return buffer; } -// format_hex_pretty (all std::string returning overloads) moved to alloc_helpers.cpp - char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { if (buffer_size == 0) { return buffer; @@ -488,8 +479,6 @@ char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return buffer; } -// format_bin moved to alloc_helpers.cpp - ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { if (on == nullptr && ESPHOME_strcasecmp_P(str, ESPHOME_PSTR("on")) == 0) return PARSE_ON; @@ -537,8 +526,6 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -// value_accuracy_to_string moved to alloc_helpers.cpp - // Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. // Caller must guarantee value is finite and |value| * mult fits in uint32_t. @@ -639,8 +626,6 @@ static constexpr uint8_t base64_char_value(uint8_t c) { return INVALID_BASE64_CHAR; } -// base64_encode (both overloads) moved to alloc_helpers.cpp - size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); } @@ -668,8 +653,6 @@ size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *b return out; } -// base64_decode (vector-returning overload) moved to alloc_helpers.cpp - /// Decode base64/base64url string directly into vector of little-endian int32 values /// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted) /// @param out Output vector (cleared and filled with decoded int32 values) @@ -790,8 +773,6 @@ void HighFrequencyLoopRequester::stop() { this->started_ = false; } -// get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp - void get_mac_address_into_buffer(std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b88a9d70e93..a2ede273413 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -26,12 +26,6 @@ #include "esphome/core/optional.h" #include "esphome/core/time_conversion.h" -// Backward compatibility re-export of heap-allocating helpers. -// These functions have moved to alloc_helpers.h. External components should -// update their includes to use #include "esphome/core/alloc_helpers.h" directly. -// This re-export will be removed in 2026.11.0. -#include "esphome/core/alloc_helpers.h" - #ifdef USE_ESP8266 #include #include @@ -1105,13 +1099,8 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) { } #endif // USE_ESP8266 -// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 - -// str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } -// str_snake_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. constexpr char to_sanitized_char(char c) { @@ -1134,8 +1123,6 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s return str_sanitize_to(buffer, N, str); } -// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. /// This computes object_id hashes directly from names without creating an intermediate buffer. /// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. @@ -1150,8 +1137,6 @@ inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { return hash; } -// str_snprintf, str_sprintf moved to alloc_helpers.h - remove this comment before 2026.11.0 - #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) // Format strings must be wrapped with PSTR() macro @@ -1605,32 +1590,6 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { format_hex_to(output, MAC_ADDRESS_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE); } -// format_mac_address_pretty, format_hex (all overloads) moved to alloc_helpers.h -// Remove this comment and the template overloads below before 2026.11.0 - -/// Format an unsigned integer in lowercased hex, starting with the most significant byte. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -template::value, int> = 0> std::string format_hex(T val) { - val = convert_big_endian(val); - return format_hex(reinterpret_cast(&val), sizeof(T)); -} -/// Format the std::array \p data in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -template std::string format_hex(const std::array &data) { - return format_hex(data.data(), data.size()); -} - -// format_hex_pretty (all overloads) moved to alloc_helpers.h -// Remove this comment and the template overload below before 2026.11.0 - -/// Format an unsigned integer in pretty-printed, human-readable hex format. -/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. -template::value, int> = 0> -std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { - val = convert_big_endian(val); - return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); -} - /// Calculate buffer size needed for format_bin_to: "01234567...\0" = bytes * 8 + 1 constexpr size_t format_bin_size(size_t byte_count) { return byte_count * 8 + 1; } @@ -1684,15 +1643,6 @@ inline char *format_bin_to(char (&buffer)[N], T val) { return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); } -// format_bin moved to alloc_helpers.h - remove this comment and template overload before 2026.11.0 - -/// Format an unsigned integer in binary, starting with the most significant byte. -/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -template::value, int> = 0> std::string format_bin(T val) { - val = convert_big_endian(val); - return format_bin(reinterpret_cast(&val), sizeof(T)); -} - /// Return values for parse_on_off(). enum ParseOnOffState : uint8_t { PARSE_NONE = 0, @@ -1703,8 +1653,6 @@ enum ParseOnOffState : uint8_t { /// Parse a string that contains either on, off or toggle. ParseOnOffState parse_on_off(const char *str, const char *on = nullptr, const char *off = nullptr); -// value_accuracy_to_string moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Maximum buffer size for value_accuracy formatting (float ~15 chars + space + UOM ~40 chars + null) static constexpr size_t VALUE_ACCURACY_MAX_LEN = 64; @@ -1717,8 +1665,6 @@ size_t value_accuracy_with_uom_to_buf(std::span bu /// Derive accuracy in decimals from an increment step. int8_t step_to_accuracy_decimals(float step); -// base64_encode (both overloads), base64_decode (vector overload) moved to alloc_helpers.h -// Remove this comment before 2026.11.0 size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); @@ -2177,8 +2123,6 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) -// get_mac_address, get_mac_address_pretty moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Get the device MAC address into the given buffer, in lowercase hex notation. /// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null /// terminator). diff --git a/script/ci-custom.py b/script/ci-custom.py index bd9b4597539..1b3abc40f2d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1071,9 +1071,6 @@ HEAP_ALLOCATING_HELPERS = { # The definitions themselves "esphome/core/alloc_helpers.h", "esphome/core/alloc_helpers.cpp", - # Backward compatibility re-exports (remove before 2026.11.0) - "esphome/core/helpers.h", - "esphome/core/helpers.cpp", # Vendored third-party library "esphome/components/http_request/httplib.h", ], @@ -1126,7 +1123,6 @@ def lint_no_sprintf(fname, match): # Vendored library "esphome/components/http_request/httplib.h", # Deprecated helpers that return std::string - "esphome/core/helpers.cpp", "esphome/core/alloc_helpers.cpp", # The using declaration itself "esphome/core/helpers.h", From 3442bb6624880fe0506791f62d4841be1a2d2fb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:21:29 -1000 Subject: [PATCH 45/79] [core] Remove deprecated esphome::RingBuffer alias (#20390) --- esphome/core/ring_buffer.h | 22 ---------------------- esphome/writer.py | 5 +---- 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 esphome/core/ring_buffer.h diff --git a/esphome/core/ring_buffer.h b/esphome/core/ring_buffer.h deleted file mode 100644 index fbb089e9066..00000000000 --- a/esphome/core/ring_buffer.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#ifdef USE_ESP32 - -// Deprecated: include "esphome/components/ring_buffer/ring_buffer.h" and use -// esphome::ring_buffer::RingBuffer. This shim will be removed in 2026.11.0. -#if __has_include("esphome/components/ring_buffer/ring_buffer.h") -#include "esphome/components/ring_buffer/ring_buffer.h" -#else -#error \ - "esphome/components/ring_buffer/ring_buffer.h not found. Add 'ring_buffer' to your component's AUTO_LOAD list to use esphome::ring_buffer::RingBuffer." -#endif -#include "esphome/core/helpers.h" // for ESPDEPRECATED - -namespace esphome { - -using RingBuffer ESPDEPRECATED("Use esphome::ring_buffer::RingBuffer instead. Removed in 2026.11.0.", - "2026.5.0") = ring_buffer::RingBuffer; - -} // namespace esphome - -#endif diff --git a/esphome/writer.py b/esphome/writer.py index 0bd485bfd31..559d7d85828 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -212,14 +212,11 @@ BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" # Headers that must not be included bare from esphome.h or the clang-tidy -# all-headers file: X-macro files, headers main.cpp includes itself, and -# deprecated headers that only resolve when their new component is loaded. +# all-headers file: X-macro files and headers main.cpp includes itself. ESPHOME_H_EXCLUDE = { Path(ENTITY_TYPES_H_TARGET), # main.cpp includes it after defining esphome_controllers() Path("esphome/core/controller_dispatch.h"), - # moved to components/ring_buffer/, removed in 2026.11.0 - Path("esphome/core/ring_buffer.h"), # build machinery, not user API Path("esphome/core/pch_prefix.h"), } From 9d02ab3734d003d36fe63964f57e9f8a339daa37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:22:32 -1000 Subject: [PATCH 46/79] [remote_base] Keep constant IR codes in flash (#20083) --- esphome/components/remote_base/__init__.py | 32 ++++++++------ .../components/remote_base/aeha_protocol.h | 3 +- .../components/remote_base/haier_protocol.h | 2 +- .../components/remote_base/midea_protocol.h | 10 ++++- .../components/remote_base/mirage_protocol.h | 2 +- .../remote_transmitter/config/ir_codes.yaml | 43 +++++++++++++++++++ .../remote_transmitter/test_ir_codes.py | 24 +++++++++++ 7 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/ir_codes.yaml create mode 100644 tests/component_tests/remote_transmitter/test_ir_codes.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 872106e405d..f8e0b4d33c7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2061,11 +2061,20 @@ def midea_dumper(var, config): pass +async def _code_bytes( + value: Any, args: TemplateArgsType, set_template: MockObj, set_static: MockObj +) -> None: + """Constant codes share one PROGMEM table pool across the remote protocols.""" + await automation.templatable_bytes( + value, args, set_template, set_static, "remote_base_code" + ) + + @register_action("midea", MideaAction, MIDEA_SCHEMA) async def midea_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) + await _code_bytes( + config[CONF_CODE], args, var.set_code_template, var.set_code_static + ) # AEHA @@ -2117,10 +2126,9 @@ def aeha_dumper(var, config): async def aeha_action(var, config, args): template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) cg.add(var.set_address(template_)) - template_ = await cg.templatable( - config[CONF_DATA], args, cg.std_vector.template(cg.uint8) + await _code_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static ) - cg.add(var.set_data(template_)) templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) cg.add(var.set_carrier_frequency(templ)) @@ -2216,9 +2224,9 @@ def haier_dumper(var, config): @register_action("haier", HaierAction, HAIER_SCHEMA) async def haier_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) + await _code_bytes( + config[CONF_CODE], args, var.set_code_template, var.set_code_static + ) # ABBWelcome @@ -2359,9 +2367,9 @@ def mirage_dumper(var, config): @register_action("mirage", MirageAction, MIRAGE_SCHEMA) async def mirage_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) + await _code_bytes( + config[CONF_CODE], args, var.set_code_template, var.set_code_static + ) # Toto diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 98a55011552..57706a7e4f1 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -28,10 +28,9 @@ DECLARE_REMOTE_PROTOCOL(AEHA) template class AEHAAction : public RemoteTransmitterActionBase { public: TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(std::vector, data) + TEMPLATABLE_BYTES(data) TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - void set_data(const std::vector &data) { data_ = data; } void encode(RemoteTransmitData *dst, Ts... x) override { AEHAData data{}; data.address = this->address_.value(x...); diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index e1fd60411fc..90af88d2c78 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -25,7 +25,7 @@ DECLARE_REMOTE_PROTOCOL(Haier) template class HaierAction : public RemoteTransmitterActionBase { public: - TEMPLATABLE_VALUE(std::vector, code) + TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { HaierData data{}; diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 85bbef1cb1f..19521e8f6d2 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -4,6 +4,7 @@ #include #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" @@ -21,6 +22,9 @@ class MideaData { MideaData(const std::vector &data) { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } + MideaData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } uint8_t *data() { return this->data_.data(); } const uint8_t *data() const { return this->data_.data(); } @@ -75,10 +79,12 @@ class MideaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Midea) template class MideaAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(std::vector, code) + public: + TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - MideaData data(this->code_.value(x...)); + MideaData data = this->code_.is_static() ? MideaData(this->code_.data(), this->code_.size()) + : MideaData(this->code_.value(x...)); data.finalize(); MideaProtocol().encode(dst, data); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index a37fb93f4fd..958127ba664 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -25,7 +25,7 @@ DECLARE_REMOTE_PROTOCOL(Mirage) template class MirageAction : public RemoteTransmitterActionBase { public: - TEMPLATABLE_VALUE(std::vector, code) + TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { MirageData data{}; diff --git a/tests/component_tests/remote_transmitter/config/ir_codes.yaml b/tests/component_tests/remote_transmitter/config/ir_codes.yaml new file mode 100644 index 00000000000..69cf1fb039c --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/ir_codes.yaml @@ -0,0 +1,43 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Midea A + on_press: + - remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: Midea B + on_press: + - remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: AEHA + on_press: + - remote_transmitter.transmit_aeha: + address: 0x8008 + data: [0x00, 0x02, 0xFD, 0xFF, 0x00, 0x33] + - platform: template + name: Haier lambda + on_press: + - remote_transmitter.transmit_haier: + code: !lambda |- + return {0xA6, 0xDA, 0x00, 0x00}; + - platform: template + name: Haier static + on_press: + - remote_transmitter.transmit_haier: + code: [0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05] + - platform: template + name: Mirage static + on_press: + - remote_transmitter.transmit_mirage: + code: [0x56, 0x6C, 0x00, 0x00, 0x20, 0x1A, 0x00, 0x00, 0x0C, 0x00, 0x0C, 0x00, 0x00, 0x42] diff --git a/tests/component_tests/remote_transmitter/test_ir_codes.py b/tests/component_tests/remote_transmitter/test_ir_codes.py new file mode 100644 index 00000000000..9be34941b61 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_ir_codes.py @@ -0,0 +1,24 @@ +"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_ir_codes_use_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("ir_codes.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (remote_base_code\w*)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage + midea = tables[0] + assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2 + assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp) + assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp) + assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp) + assert "->set_code_template([](" in main_cpp + assert "std::vector{" not in main_cpp From 8084916e30f5c7f9a862856acb72a09cfe288e09 Mon Sep 17 00:00:00 2001 From: Steve Vigneau <67119938+c0nsumer@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:23:10 -0400 Subject: [PATCH 47/79] [remote_base] Add Onkyo RI Wired Serial Support (#18595) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 37 ++++++++++++ .../remote_base/onkyori_protocol.cpp | 59 +++++++++++++++++++ .../components/remote_base/onkyori_protocol.h | 33 +++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_transmitter/common-buttons.yaml | 10 ++++ 5 files changed, 144 insertions(+) create mode 100644 esphome/components/remote_base/onkyori_protocol.cpp create mode 100644 esphome/components/remote_base/onkyori_protocol.h diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index f8e0b4d33c7..a880169fb20 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1019,6 +1019,43 @@ async def nec_action(var, config, args): cg.add(var.set_command_repeats(template_)) +# Onkyo RI +OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, OnkyoRIAction, OnkyoRIDumper = ( + declare_protocol("OnkyoRI") +) +ONKYORI_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_int_range(0, 0xFFF)}) + + +@register_binary_sensor("onkyori", OnkyoRIBinarySensor, ONKYORI_SCHEMA) +def onkyori_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + OnkyoRIData, + ("data", config[CONF_DATA]), + ) + ) + ) + + +@register_trigger("onkyori", OnkyoRITrigger, OnkyoRIData) +def onkyori_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("onkyori", OnkyoRIDumper) +def onkyori_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +@register_action("onkyori", OnkyoRIAction, ONKYORI_SCHEMA) +async def onkyori_action( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: + template_ = await cg.templatable(config[CONF_DATA], args, cg.uint16) + cg.add(var.set_data(template_)) + + # Pioneer ( PioneerData, diff --git a/esphome/components/remote_base/onkyori_protocol.cpp b/esphome/components/remote_base/onkyori_protocol.cpp new file mode 100644 index 00000000000..6290e5bc507 --- /dev/null +++ b/esphome/components/remote_base/onkyori_protocol.cpp @@ -0,0 +1,59 @@ +#include "onkyori_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.onkyori"); + +static constexpr uint8_t NBITS = 12; +static constexpr uint16_t MAX_DATA = (1U << NBITS) - 1; +static constexpr uint32_t HEADER_HIGH_US = 3000; +static constexpr uint32_t HEADER_LOW_US = 1000; +static constexpr uint32_t BIT_HIGH_US = 1000; +static constexpr uint32_t BIT_ONE_LOW_US = 2000; +static constexpr uint32_t BIT_ZERO_LOW_US = 1000; +static constexpr uint32_t TRAILER_HIGH_US = 1000; + +void OnkyoRIProtocol::encode(RemoteTransmitData *dst, const OnkyoRIData &data) { + if (data.data > MAX_DATA) { + ESP_LOGE(TAG, "Data 0x%04X does not fit in a 12-bit RI frame, not sending", data.data); + return; + } + + // RI is a baseband signal on a wire, so it has no carrier to modulate. + dst->set_carrier_frequency(0); + dst->reserve(2 + NBITS * 2u + 1); + + dst->item(HEADER_HIGH_US, HEADER_LOW_US); + + for (uint32_t mask = 1UL << (NBITS - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (data.data & mask) != 0 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } + + dst->mark(TRAILER_HIGH_US); +} + +optional OnkyoRIProtocol::decode(RemoteReceiveData src) { + OnkyoRIData out{.data = 0}; + + if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) + return {}; + + for (uint8_t i = 0; i < NBITS; i++) { + out.data <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + out.data |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + + if (!src.expect_mark(TRAILER_HIGH_US)) + return {}; + + return out; +} + +void OnkyoRIProtocol::dump(const OnkyoRIData &data) { ESP_LOGI(TAG, "Received OnkyoRI: data=0x%03X", data.data); } + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.h b/esphome/components/remote_base/onkyori_protocol.h new file mode 100644 index 00000000000..c6f0f1dd97f --- /dev/null +++ b/esphome/components/remote_base/onkyori_protocol.h @@ -0,0 +1,33 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct OnkyoRIData { + uint16_t data; + + bool operator==(const OnkyoRIData &rhs) const { return data == rhs.data; } +}; + +class OnkyoRIProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const OnkyoRIData &data); + optional decode(RemoteReceiveData src); + void dump(const OnkyoRIData &data); +}; + +DECLARE_REMOTE_PROTOCOL(OnkyoRI) + +template class OnkyoRIAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, data) + + void encode(RemoteTransmitData *dst, Ts... x) override { + OnkyoRIData data{}; + data.data = this->data_.value(x...); + OnkyoRIProtocol().encode(dst, data); + } +}; + +} // namespace esphome::remote_base diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 5d12dc68d43..4dadec6a015 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -103,6 +103,11 @@ on_nexa: - logger.log: format: "on_nexa: %lu %u %u %u %u" args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"] +on_onkyori: + then: + - logger.log: + format: "on_onkyori: 0x%03X" + args: ["x.data"] on_panasonic: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index bdb5f2eea25..776a03e6809 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -252,6 +252,16 @@ button: channel: 1 button: 1 check: 1 + - platform: template + name: OnkyoRI + on_press: + remote_transmitter.transmit_onkyori: + data: 0xD9 + - platform: template + name: OnkyoRI 12-bit + on_press: + remote_transmitter.transmit_onkyori: + data: 0x170 - platform: template name: Toto on_press: From cfbdd58e4a9369d236dbcf123b8e9e2da872e9ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:23:28 -1000 Subject: [PATCH 48/79] [lvgl] Remove deprecated update_on_release option (#20387) --- esphome/components/lvgl/number/__init__.py | 26 ++++++++-------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index 0d8d99ab218..b23e77df22f 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -1,16 +1,10 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv -from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE +from esphome.const import CONF_RESTORE_VALUE from esphome.cpp_generator import MockObj -from ..defines import ( - CONF_ANIMATED, - CONF_TRIGGER, - CONF_UPDATE_ON_RELEASE, - CONF_WIDGET, - LOGGER, -) +from ..defines import CONF_ANIMATED, CONF_TRIGGER, CONF_UPDATE_ON_RELEASE, CONF_WIDGET from ..lv_validation import animated from ..lvcode import ( EVENT_ARG, @@ -31,20 +25,18 @@ CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend( cv.Required(CONF_WIDGET): cv.use_id(LvNumber), **VALUE_TRIGGER_SCHEMA, cv.Optional(CONF_ANIMATED, default=True): animated, - cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean, + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_UPDATE_ON_RELEASE): cv.invalid( + "The 'update_on_release' option has been removed in ESPHome 2026.11.0.\n" + "Use 'trigger: on_release' instead." + ), cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, } ) async def to_code(config): - trigger = config[CONF_TRIGGER] - if CONF_UPDATE_ON_RELEASE in config: - LOGGER.warning( - "Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead" - ) - if config[CONF_UPDATE_ON_RELEASE]: - trigger = CONF_ON_RELEASE widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] async with LambdaContext([], return_type=cg.float_) as value: @@ -70,6 +62,6 @@ async def to_code(config): lvgl_static.add_event_cb( widget.obj, await event.get_lambda(), - *TRIGGER_EVENT_MAP[trigger], + *TRIGGER_EVENT_MAP[config[CONF_TRIGGER]], ) ) From 7ab24bf8508c599040a1462b97717fdfeb727814 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:23:46 -1000 Subject: [PATCH 49/79] [nextion] Remove deprecated dump_device_info option (#20388) --- esphome/components/nextion/display.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 18f0a73928d..3a70be21caf 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -1,5 +1,3 @@ -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart @@ -43,8 +41,6 @@ from .base_component import ( CONF_WAKE_UP_PAGE, ) -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] DEPENDENCIES = ["uart"] @@ -56,15 +52,6 @@ def AUTO_LOAD() -> list[str]: return base -def _deprecated_dump_device_info(value): - _LOGGER.warning( - "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " - "Device info is now always logged at connection time. " - "Please remove this option from your configuration." - ) - return value - - def _validate_tft_upload(config): has_tft_url = CONF_TFT_URL in config for conf_key in ( @@ -91,9 +78,11 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=TimePeriod(milliseconds=255)), ), - # Deprecated — device info is now always logged. Remove before 2026.11.0. - cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All( - cv.boolean, _deprecated_dump_device_info + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_DUMP_DEVICE_INFO): cv.invalid( + "The 'dump_device_info' option has been removed in ESPHome 2026.11.0.\n" + "Device info is now always logged, so remove this option from your configuration." ), cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( From c859c347cb776bce4e296c22374e2fd0606ba7a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:24:32 -1000 Subject: [PATCH 50/79] [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) --- esphome/automation.py | 57 ++----------------- tests/component_tests/automation/__init__.py | 0 .../automation/config/condition_groups.yaml | 44 ++++++++++++++ .../automation/test_condition_groups.py | 25 ++++++++ 4 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 tests/component_tests/automation/__init__.py create mode 100644 tests/component_tests/automation/config/condition_groups.yaml create mode 100644 tests/component_tests/automation/test_condition_groups.py diff --git a/esphome/automation.py b/esphome/automation.py index 645fe566d41..859ec2f1f3c 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -699,52 +699,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition) @register_condition("and", AndCondition, validate_condition_list) -async def and_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - -@register_condition("or", OrCondition, validate_condition_list) -async def or_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("all", AndCondition, validate_condition_list) -async def all_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - +@register_condition("or", OrCondition, validate_condition_list) @register_condition("any", OrCondition, validate_condition_list) -async def any_condition_to_code( +@register_condition("xor", XorCondition, validate_condition_list) +async def condition_group_to_code( config: ConfigType, condition_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) + # A group of one is that condition for and, or and xor; skip the wrapper. + if len(conditions) == 1: + return conditions[0] return cg.new_Pvariable( condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions ) @@ -761,19 +729,6 @@ async def not_condition_to_code( return cg.new_Pvariable(condition_id, template_arg, condition) -@register_condition("xor", XorCondition, validate_condition_list) -async def xor_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("lambda", LambdaCondition, cv.returning_lambda) async def lambda_condition_to_code( config: ConfigType, diff --git a/tests/component_tests/automation/__init__.py b/tests/component_tests/automation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/automation/config/condition_groups.yaml b/tests/component_tests/automation/config/condition_groups.yaml new file mode 100644 index 00000000000..9624531862f --- /dev/null +++ b/tests/component_tests/automation/config/condition_groups.yaml @@ -0,0 +1,44 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +logger: + +binary_sensor: + - platform: template + id: single_and + on_press: + - if: + condition: + - lambda: return true; + then: + - logger.log: single and + - platform: template + id: single_or + on_press: + - if: + any: + - lambda: return true; + then: + - logger.log: single or + - platform: template + id: double_and + on_press: + - if: + condition: + - lambda: return true; + - lambda: return false; + then: + - logger.log: double and + - platform: template + id: single_xor + on_press: + - if: + condition: + xor: + - lambda: return true; + then: + - logger.log: single xor diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py new file mode 100644 index 00000000000..effe7b5b0bd --- /dev/null +++ b/tests/component_tests/automation/test_condition_groups.py @@ -0,0 +1,25 @@ +"""Tests for and/or condition group codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_single_condition_groups_are_unwrapped( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A group of one condition is passed to the action directly.""" + main_cpp = generate_main(component_config_path("condition_groups.yaml")) + + assert "IfAction(lambdacondition_id);" in main_cpp + assert "IfAction(lambdacondition_id_2);" in main_cpp + group = re.search( + r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + main_cpp, + ) + assert group is not None + assert f"IfAction({group.group(1)});" in main_cpp + # xor of one condition equals that condition, so it is unwrapped too. + assert "IfAction(lambdacondition_id_5);" in main_cpp + assert "XorCondition<" not in main_cpp From 05aeb2f0943bbd78f9c7dcbd59c916a08a22ec57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:25:36 -1000 Subject: [PATCH 51/79] [http_request] Test shared, lambda and trigger argument request headers (#20224) --- .../components/http_request/http_request.yaml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index eb8a6d33e7a..b82c0581d1b 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -71,6 +71,8 @@ esphome: # encode_json_ receives const copies of non-const reference args. - http_request.post: url: https://esphome.io + request_headers: + X-Body-Length: !lambda 'return body.empty() ? "0" : "1";' json: status: "ok" # Same with json: lambda variant, exercises json_func_ path @@ -102,3 +104,23 @@ script: then: - http_request.get: url: "http://google.com" + + - id: send_with_headers + parameters: + token: string + then: + - http_request.get: + url: https://esphome.io + request_headers: + Accept: application/json + X-Device: esphome + - http_request.get: + url: https://esphome.io/other + request_headers: + Accept: application/json + X-Device: esphome + - http_request.get: + url: https://esphome.io + request_headers: + Accept: application/json + X-Token-Set: !lambda 'return token.empty() ? "no" : "yes";' From 9ab0fa9669aa23bc169e6614087f84258dc1f4a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:36:28 -1000 Subject: [PATCH 52/79] [dlms_meter] Remove deprecated predefined sensor keys (#20386) --- esphome/components/dlms_meter/__init__.py | 48 ++--- .../components/dlms_meter/sensor/__init__.py | 178 ++++-------------- .../dlms_meter/text_sensor/__init__.py | 53 ++---- .../dlms_meter/test_removed_keys.py | 33 ++++ tests/components/dlms_meter/common.yaml | 49 ++--- 5 files changed, 112 insertions(+), 249 deletions(-) create mode 100644 tests/component_tests/dlms_meter/test_removed_keys.py diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 6d86da6ccf5..03aca53711e 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -172,43 +172,17 @@ async def to_code(config: ConfigType) -> None: hub_id = config[CONF_ID].id - sensor_count = 0 - for sens_conf in CORE.config.get(SENSOR_DOMAIN, []): - if ( - sens_conf.get("platform") == DOMAIN - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - if CONF_OBIS_CODE in sens_conf: - sensor_count += 1 - else: - from .sensor import NUMERIC_KEYS - - sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) - - text_sensor_count = 0 - for sens_conf in CORE.config.get(TEXT_SENSOR_DOMAIN, []): - if ( - sens_conf.get("platform") == DOMAIN - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - if CONF_OBIS_CODE in sens_conf: - text_sensor_count += 1 - else: - from .text_sensor import TEXT_KEYS - - text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) - - binary_sensor_count = 0 - for sens_conf in CORE.config.get(BINARY_SENSOR_DOMAIN, []): - if ( - sens_conf.get("platform") == DOMAIN - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - binary_sensor_count += 1 - - cg.add_define("DLMS_MAX_SENSORS", sensor_count) - cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) - cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + for domain, define in ( + (SENSOR_DOMAIN, "DLMS_MAX_SENSORS"), + (TEXT_SENSOR_DOMAIN, "DLMS_MAX_TEXT_SENSORS"), + (BINARY_SENSOR_DOMAIN, "DLMS_MAX_BINARY_SENSORS"), + ): + count = sum( + 1 + for conf in CORE.config.get(domain, []) + if conf.get("platform") == DOMAIN and conf[CONF_DLMS_METER_ID].id == hub_id + ) + cg.add_define(define, count) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 8ded150cd09..0c895ccefb2 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,159 +1,59 @@ -import logging - import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, - UNIT_AMPERE, - UNIT_VOLT, - UNIT_WATT, - UNIT_WATT_HOURS, -) from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["dlms_meter"] -NUMERIC_KEYS = { - "voltage_l1": "1.0.32.7.0.255", - "voltage_l2": "1.0.52.7.0.255", - "voltage_l3": "1.0.72.7.0.255", - "current_l1": "1.0.31.7.0.255", - "current_l2": "1.0.51.7.0.255", - "current_l3": "1.0.71.7.0.255", - "active_power_plus": "1.0.1.7.0.255", - "active_power_minus": "1.0.2.7.0.255", - "active_energy_plus": "1.0.1.8.0.255", - "active_energy_minus": "1.0.2.8.0.255", - "reactive_energy_plus": "1.0.3.8.0.255", - "reactive_energy_minus": "1.0.4.8.0.255", - "power_factor": "1.0.13.7.0.255", +# Removed in 2026.11.0 - kept to provide helpful error message +# Remove before 2027.5.0 +_MEASUREMENT = "state_class: measurement" +_VOLTAGE = f"unit_of_measurement: V, accuracy_decimals: 1, device_class: voltage, {_MEASUREMENT}" +_CURRENT = f"unit_of_measurement: A, accuracy_decimals: 2, device_class: current, {_MEASUREMENT}" +_POWER = ( + f"unit_of_measurement: W, accuracy_decimals: 0, device_class: power, {_MEASUREMENT}" +) +_ENERGY = ( + "unit_of_measurement: Wh, accuracy_decimals: 0, device_class: energy, " + "state_class: total_increasing" +) +_POWER_FACTOR = f"accuracy_decimals: 3, device_class: power_factor, {_MEASUREMENT}" +REMOVED_KEYS = { + "voltage_l1": ("1.0.32.7.0.255", _VOLTAGE), + "voltage_l2": ("1.0.52.7.0.255", _VOLTAGE), + "voltage_l3": ("1.0.72.7.0.255", _VOLTAGE), + "current_l1": ("1.0.31.7.0.255", _CURRENT), + "current_l2": ("1.0.51.7.0.255", _CURRENT), + "current_l3": ("1.0.71.7.0.255", _CURRENT), + "active_power_plus": ("1.0.1.7.0.255", _POWER), + "active_power_minus": ("1.0.2.7.0.255", _POWER), + "active_energy_plus": ("1.0.1.8.0.255", _ENERGY), + "active_energy_minus": ("1.0.2.8.0.255", _ENERGY), + "reactive_energy_plus": ("1.0.3.8.0.255", _ENERGY), + "reactive_energy_minus": ("1.0.4.8.0.255", _ENERGY), + "power_factor": ("1.0.13.7.0.255", _POWER_FACTOR), } -DYNAMIC_SCHEMA = sensor.sensor_schema().extend( + +CONFIG_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), cv.Required(CONF_OBIS_CODE): obis_code, + **{ + cv.Optional(key): cv.invalid( + f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " + f"Add a separate '- platform: dlms_meter' sensor with " + f'obis_code: "{obis}" and {settings}' + ) + for key, (obis, settings) in REMOVED_KEYS.items() + }, } ) -def deprecation_warning(config: ConfigType) -> ConfigType: - _LOGGER.warning( - "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " - "Please update your configuration to use the new schema with 'obis_code'." - ) - return config - - -OLD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), - } - ).extend(cv.COMPONENT_SCHEMA), - deprecation_warning, -) - - -CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) - - async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - - if obis := config.get(CONF_OBIS_CODE): - var = await sensor.new_sensor(config) - cg.add(hub.register_sensor(obis, var)) - else: - for key, obis_val in NUMERIC_KEYS.items(): - if sensor_config := config.get(key): - sens = await sensor.new_sensor(sensor_config) - cg.add(hub.register_sensor(obis_val, sens)) + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index c2ff0779eee..10872d1a50a 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,5 +1,3 @@ -import logging - import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv @@ -7,54 +5,33 @@ from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["dlms_meter"] -TEXT_KEYS = { +# Removed in 2026.11.0 - kept to provide helpful error message +# Remove before 2027.5.0 +REMOVED_KEYS = { "timestamp": "0.0.1.0.0.255", "meternumber": "0.0.96.1.0.255", } -DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( + +CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), cv.Required(CONF_OBIS_CODE): obis_code, + **{ + cv.Optional(key): cv.invalid( + f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " + f"Add a separate '- platform: dlms_meter' text sensor with " + f'obis_code: "{obis}" instead' + ) + for key, obis in REMOVED_KEYS.items() + }, } ) -def deprecation_warning(config: ConfigType) -> ConfigType: - _LOGGER.warning( - "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " - "Please update your configuration to use the new schema with 'obis_code'." - ) - return config - - -OLD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), - } - ).extend(cv.COMPONENT_SCHEMA), - deprecation_warning, -) - - -CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) - - async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - - if obis := config.get(CONF_OBIS_CODE): - var = await text_sensor.new_text_sensor(config) - cg.add(hub.register_text_sensor(obis, var)) - else: - for key, obis_val in TEXT_KEYS.items(): - if text_sensor_config := config.get(key): - sens = await text_sensor.new_text_sensor(text_sensor_config) - cg.add(hub.register_text_sensor(obis_val, sens)) + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(config[CONF_OBIS_CODE], var)) diff --git a/tests/component_tests/dlms_meter/test_removed_keys.py b/tests/component_tests/dlms_meter/test_removed_keys.py new file mode 100644 index 00000000000..f82ec79bff7 --- /dev/null +++ b/tests/component_tests/dlms_meter/test_removed_keys.py @@ -0,0 +1,33 @@ +"""Tests for the dlms_meter predefined keys removed in 2026.11.0.""" + +from collections.abc import Callable + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.dlms_meter.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA +from esphome.components.dlms_meter.text_sensor import ( + CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA, +) +from esphome.types import ConfigType + + +def _errors(schema: Callable[[ConfigType], ConfigType], config: ConfigType) -> str: + with pytest.raises(Invalid) as exc_info: + schema(config) + errors = ( + exc_info.value.errors + if isinstance(exc_info.value, MultipleInvalid) + else [exc_info.value] + ) + return "\n".join(str(error) for error in errors) + + +def test_removed_sensor_key_names_its_replacement() -> None: + errors = _errors(SENSOR_SCHEMA, {"voltage_l1": {"name": "Voltage"}}) + assert 'obis_code: "1.0.32.7.0.255" and unit_of_measurement: V' in errors + + +def test_removed_text_sensor_key_names_its_replacement() -> None: + errors = _errors(TEXT_SENSOR_SCHEMA, {"timestamp": {"name": "Timestamp"}}) + assert 'obis_code: "0.0.1.0.0.255"' in errors diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 4d32f4e02fe..fa6deff6bec 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -15,57 +15,36 @@ dlms_meter: default_obis: "0.0.1.0.0.255" sensor: - # Old Schema tests - platform: dlms_meter - voltage_l1: - name: "Voltage L1" - voltage_l2: - name: "Voltage L2" - voltage_l3: - name: "Voltage L3" - current_l1: - name: "Current L1" - current_l2: - name: "Current L2" - current_l3: - name: "Current L3" - active_power_plus: - name: "Active power taken from grid" - active_power_minus: - name: "Active power put into grid" - active_energy_plus: - name: "Active energy taken from grid" - active_energy_minus: - name: "Active energy put into grid" - reactive_energy_plus: - name: "Reactive energy taken from grid" - reactive_energy_minus: - name: "Reactive energy put into grid" - power_factor: - name: "Power factor" + obis_code: "1.0.32.7.0.255" + name: "Voltage L1" + unit_of_measurement: V + accuracy_decimals: 1 + device_class: voltage + state_class: measurement + - platform: dlms_meter + obis_code: "1.0.1.8.0.255" + name: "Active energy taken from grid" + unit_of_measurement: Wh + device_class: energy + state_class: total_increasing - # Dynamic Schema tests - platform: dlms_meter dlms_meter_id: dlms_meter_hub obis_code: "1-0:99.99.9" name: "Custom Dynamic Sensor" text_sensor: - # Old Schema tests - platform: dlms_meter - timestamp: - name: "timestamp" - meternumber: - name: "Meter Number" + obis_code: "0.0.1.0.0.255" + name: "timestamp" - # Dynamic Schema tests - platform: dlms_meter dlms_meter_id: dlms_meter_hub obis_code: "0-0:99.99.9" name: "Custom Dynamic Text Sensor" binary_sensor: - # Dynamic Schema tests (Binary sensors only use the dynamic schema) - platform: dlms_meter dlms_meter_id: dlms_meter_hub obis_code: "0-1:2.3.4" From 8416a2986295756b43ac80bfd9298bffcbf72a15 Mon Sep 17 00:00:00 2001 From: PolarGoose <35307286+PolarGoose@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:03:38 +0200 Subject: [PATCH 53/79] [dlms_meter] Add support for "Landis+Gyr E450 HDLC Cyprus". Adapt the code for new API of dlms_parser (#16942) Co-authored-by: J. Nick Koston --- esphome/components/dlms_meter/__init__.py | 125 +++++-------- .../dlms_meter/binary_sensor/__init__.py | 12 +- esphome/components/dlms_meter/dlms_meter.cpp | 165 +++++++++--------- esphome/components/dlms_meter/dlms_meter.h | 81 ++++----- .../components/dlms_meter/sensor/__init__.py | 12 +- .../dlms_meter/text_sensor/__init__.py | 12 +- esphome/idf_component.yml | 2 +- platformio.ini | 2 +- 8 files changed, 189 insertions(+), 222 deletions(-) diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 03aca53711e..eed702b6f1a 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -3,9 +3,6 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart -from esphome.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from esphome.components.sensor import DOMAIN as SENSOR_DOMAIN -from esphome.components.text_sensor import DOMAIN as TEXT_SENSOR_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -15,6 +12,7 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] @@ -30,19 +28,17 @@ CONF_SKIP_CRC = "skip_crc" CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" +DLMS_PARSER_VERSION = "2.2.0" + dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) +CustomPattern = dlms_meter_component_ns.struct("CustomPattern") +ObisId = cg.global_ns.namespace("dlms_parser").class_("ObisId") -def obis_code(value: Any) -> str: - # Normalize the OBIS code to the strict A.B.C.D.E.F format - bytes_list = parse_obis_code_bytes(value) - return ".".join(str(b) for b in bytes_list) - - -def parse_obis_code_bytes(value: Any) -> list[int]: +def obis_string_to_byte_list(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -60,29 +56,48 @@ def parse_obis_code_bytes(value: Any) -> list[int]: return bytes_list +def to_obis_id_struct(value: list[int]) -> cg.Expression: + return ObisId(*value) + + +_request_sensor_slot = cg.slot_counter("DLMS_MAX_SENSORS") +_request_text_sensor_slot = cg.slot_counter("DLMS_MAX_TEXT_SENSORS") +_request_binary_sensor_slot = cg.slot_counter("DLMS_MAX_BINARY_SENSORS") + + +def register_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_sensor_slot(str(hub)) + cg.add(hub.register_sensor(to_obis_id_struct(obis), var)) + + +def register_text_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_text_sensor_slot(str(hub)) + cg.add(hub.register_text_sensor(to_obis_id_struct(obis), var)) + + +def register_binary_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_binary_sensor_slot(str(hub)) + cg.add(hub.register_binary_sensor(to_obis_id_struct(obis), var)) + + def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(value: ConfigType) -> ConfigType: - if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: - raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") - return value - - CUSTOM_PATTERN_SCHEMA = cv.All( custom_pattern_dict, cv.Schema( { cv.Required(CONF_PATTERN): cv.string, - cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_NAME, default="CUSTOM"): cv.string, cv.Optional(CONF_PRIORITY, default=0): cv.int_, - cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + cv.Optional( + CONF_DEFAULT_OBIS, default="0.0.0.0.0.0" + ): obis_string_to_byte_list, } ), - validate_custom_pattern, ) CONFIG_SCHEMA = ( @@ -120,74 +135,30 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_ async def to_code(config: ConfigType) -> None: - dec_key_expr = cg.RawExpression("std::nullopt") - if dec_key := config.get(CONF_DECRYPTION_KEY): - key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] - dec_key_expr = cg.RawExpression( - f"std::array{{{', '.join(key_bytes)}}}" + custom_patterns = [ + cg.StructInitializer( + CustomPattern, + (CONF_PATTERN, pattern[CONF_PATTERN]), + (CONF_NAME, pattern[CONF_NAME]), + (CONF_PRIORITY, pattern[CONF_PRIORITY]), + (CONF_DEFAULT_OBIS, to_obis_id_struct(pattern[CONF_DEFAULT_OBIS])), ) - - auth_key_expr = cg.RawExpression("std::nullopt") - if auth_key := config.get(CONF_AUTH_KEY): - key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] - auth_key_expr = cg.RawExpression( - f"std::array{{{', '.join(key_bytes)}}}" - ) - - patterns = [] - if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): - for p in custom_patterns: - name_expr = cg.RawExpression("std::nullopt") - if name_val := p.get(CONF_NAME): - name_expr = name_val - - if obis_vals := p.get(CONF_DEFAULT_OBIS): - obis_expr = cg.RawExpression( - f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" - ) - else: - obis_expr = cg.RawExpression("std::nullopt") - - patterns.append( - cg.ArrayInitializer( - p[CONF_PATTERN], - name_expr, - p[CONF_PRIORITY], - obis_expr, - ) - ) - - patterns_expr = ( - cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") - ) + for pattern in config.get(CONF_CUSTOM_PATTERNS, []) + ] var = cg.new_Pvariable( config[CONF_ID], config[CONF_RECEIVE_TIMEOUT], config[CONF_SKIP_CRC], - dec_key_expr, - auth_key_expr, - patterns_expr, + config.get(CONF_DECRYPTION_KEY, cg.nullptr), + config.get(CONF_AUTH_KEY, cg.nullptr), + cg.ArrayInitializer(*custom_patterns, multiline=True), ) - hub_id = config[CONF_ID].id - - for domain, define in ( - (SENSOR_DOMAIN, "DLMS_MAX_SENSORS"), - (TEXT_SENSOR_DOMAIN, "DLMS_MAX_TEXT_SENSORS"), - (BINARY_SENSOR_DOMAIN, "DLMS_MAX_BINARY_SENSORS"), - ): - count = sum( - 1 - for conf in CORE.config.get(domain, []) - if conf.get("platform") == DOMAIN and conf[CONF_DLMS_METER_ID].id == hub_id - ) - cg.add_define(define, count) - await cg.register_component(var, config) await uart.register_uart_device(var, config) if CORE.is_esp32: - esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION) else: - cg.add_library("esphome/dlms_parser", "1.1.0") + cg.add_library("esphome/dlms_parser", DLMS_PARSER_VERSION) diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index a15e58b9571..2a51ad09909 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -3,14 +3,20 @@ from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_binary_sensor, +) DEPENDENCIES = ["dlms_meter"] CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, } ) @@ -18,4 +24,4 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await binary_sensor.new_binary_sensor(config) - cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) + register_binary_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index c34f7126603..32d1817e0e7 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,6 +1,11 @@ +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + #include "dlms_meter.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include #include namespace esphome::dlms_meter { @@ -31,54 +36,40 @@ static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list a } } -DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, - std::optional> decryption_key, - std::optional> authentication_key, +DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const bool skip_crc_check, + const char *decryption_key, const char *authentication_key, std::vector custom_patterns) : receive_timeout_ms_(receive_timeout_ms), skip_crc_check_(skip_crc_check), custom_patterns_(std::move(custom_patterns)), - parser_(&decryptor_) { + parser_([this](const dlms_parser::AxdrCapture &capture) { this->on_data_(capture); }, &this->decryptor_) { dlms_parser::Logger::set_log_function(log_callback); - if (decryption_key.has_value()) { -#ifdef DLMS_METER_NO_CRYPTO - ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); -#else - auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); - if (opt_key) { - this->parser_.set_decryption_key(*opt_key); + if (decryption_key != nullptr) { + const auto key = dlms_parser::Aes128GcmDecryptionKey::from_hex(decryption_key); + if (key.has_value()) { + this->parser_.set_decryption_key(*key); } else { ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); } -#endif } - if (authentication_key.has_value()) { -#ifdef DLMS_METER_NO_CRYPTO - ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); -#else - auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); - if (opt_key) { - this->parser_.set_authentication_key(*opt_key); + if (authentication_key != nullptr) { + const auto key = dlms_parser::Aes128GcmAuthenticationKey::from_hex(authentication_key); + if (key.has_value()) { + this->parser_.set_authentication_key(*key); } else { ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); } -#endif } this->parser_.set_skip_crc_check(this->skip_crc_check_); this->parser_.load_default_patterns(); - for (const auto &pattern : this->custom_patterns_) { - if (pattern.default_obis.has_value() && pattern.name.has_value()) { - this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, - pattern.default_obis.value()); - } else if (pattern.name.has_value()) { - this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); - } else { - this->parser_.register_pattern(pattern.pattern.c_str()); - } + + for (const auto &custom_pattern : this->custom_patterns_) { + this->parser_.register_pattern(custom_pattern.name, custom_pattern.pattern, custom_pattern.priority, + custom_pattern.default_obis); } } @@ -89,36 +80,36 @@ void DlmsMeterComponent::dump_config() { 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_) { - if (pattern.default_obis.has_value() && pattern.name.has_value()) { - const auto &obis = pattern.default_obis.value(); - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", - pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], - obis[3], obis[4], obis[5]); - } else if (pattern.name.has_value()) { - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), - pattern.name->c_str(), pattern.priority); - } else { - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); - } + std::array obis_buf; + + for (const auto &custom_pattern : this->custom_patterns_) { + const auto default_obis = custom_pattern.default_obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %.*s)", custom_pattern.pattern, + custom_pattern.name, custom_pattern.priority, static_cast(default_obis.size()), + default_obis.data()); } #ifdef USE_SENSOR - for (const auto &entry : this->sensors_) { - LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif + #ifdef USE_TEXT_SENSOR - for (const auto &entry : this->text_sensors_) { - LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif + #ifdef USE_BINARY_SENSOR - for (const auto &entry : this->binary_sensors_) { - LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif } @@ -166,71 +157,73 @@ void DlmsMeterComponent::read_rx_buffer_() { void DlmsMeterComponent::process_frame_() { ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { - this->on_data_(obis_code, float_val, str_val, is_numeric); - }; - - this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}); this->bytes_accumulated_ = 0; } -void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { +void DlmsMeterComponent::on_data_(const dlms_parser::AxdrCapture &capture) { int updated_count = 0; + if (capture.is_numeric()) { +#if defined(USE_SENSOR) || defined(USE_BINARY_SENSOR) + const float value = capture.value_as_float_with_scaler_applied(); +#endif #ifdef USE_SENSOR - if (is_numeric) { - for (auto &item : this->sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(float_val); + for (const auto &item : this->sensors_) { + if (item.obis == capture.obis) { + item.sensor->publish_state(value); updated_count++; } } - } #endif - -#ifdef USE_TEXT_SENSOR - if (!is_numeric && str_val != nullptr) { - for (auto &item : this->text_sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(str_val); - updated_count++; - } - } - } -#endif - #ifdef USE_BINARY_SENSOR - if (is_numeric) { - bool state = float_val != 0.0f; - for (auto &item : this->binary_sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(state); + for (const auto &item : this->binary_sensors_) { + if (item.obis == capture.obis) { + item.sensor->publish_state(value != 0.0f); updated_count++; } } - } #endif + } else { +#ifdef USE_TEXT_SENSOR + for (const auto &item : this->text_sensors_) { + if (item.obis == capture.obis) { + std::array value_buf; + const auto value = capture.value_as_string(value_buf); + item.sensor->publish_state(value.data(), value.size()); + updated_count++; + } + } +#endif + } if (updated_count == 0) { - ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + std::array obis_buf; + const auto obis = capture.obis.to_string(obis_buf); + ESP_LOGV(TAG, "Received OBIS %.*s, but no sensors are registered for it.", static_cast(obis.size()), + obis.data()); } } #ifdef USE_SENSOR -void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { - this->sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor) { + this->sensors_.push_back({obis, sensor}); } #endif + #ifdef USE_TEXT_SENSOR -void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { - this->text_sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis, sensor}); } #endif + #ifdef USE_BINARY_SENSOR -void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { - this->binary_sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis, sensor}); } #endif } // namespace esphome::dlms_meter + +#endif diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index fc4721843fd..2d7898f4d3e 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -1,9 +1,13 @@ #pragma once +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/application.h" -#include "esphome/core/log.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" @@ -19,12 +23,6 @@ #include -#include -#include -#include -#include -#include - #if __has_include() #include #elif !defined(USE_ESP8266) && __has_include() @@ -35,9 +33,12 @@ #elif __has_include() #include #else -#define DLMS_METER_NO_CRYPTO +#error "The platform doesn't provide a compatible encryption library for dlms_parser" #endif +namespace esphome::dlms_meter { + +// Entity storage is sized by codegen; a platform with no entities emits no define #ifndef DLMS_MAX_SENSORS static constexpr uint8_t DLMS_MAX_SENSORS = 0; #endif @@ -48,82 +49,64 @@ static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; #endif -namespace esphome::dlms_meter { - -#ifdef DLMS_METER_NO_CRYPTO -// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) -class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { - public: - void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} - bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, - std::span aad, std::span tag) override { - return false; - } -}; -#endif - -#if __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; -#elif !defined(USE_ESP8266) && __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; -#elif __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; -#else -using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; -#endif - #ifdef USE_SENSOR struct SensorItem { - std::string obis_code; + dlms_parser::ObisId obis; sensor::Sensor *sensor; }; #endif #ifdef USE_TEXT_SENSOR struct TextSensorItem { - std::string obis_code; + dlms_parser::ObisId obis; text_sensor::TextSensor *sensor; }; #endif #ifdef USE_BINARY_SENSOR struct BinarySensorItem { - std::string obis_code; + dlms_parser::ObisId obis; binary_sensor::BinarySensor *sensor; }; #endif struct CustomPattern { - std::string pattern; - std::optional name; - int priority{0}; - std::optional> default_obis; + const char *pattern; + const char *name; + int priority; + dlms_parser::ObisId default_obis; }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#else +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#endif + class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: - DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, - std::optional> decryption_key, - std::optional> authentication_key, - std::vector custom_patterns); + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, const char *decryption_key, + const char *authentication_key, std::vector custom_patterns); void setup() override; void dump_config() override; void loop() override; #ifdef USE_SENSOR - void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); + void register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor); #endif #ifdef USE_TEXT_SENSOR - void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); + void register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor); #endif #ifdef USE_BINARY_SENSOR - void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); + void register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor); #endif protected: void read_rx_buffer_(); void flush_rx_buffer_(); void process_frame_(); - void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); + void on_data_(const dlms_parser::AxdrCapture &capture); std::array rx_buffer_; size_t bytes_accumulated_{0}; @@ -149,3 +132,5 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice { }; } // namespace esphome::dlms_meter + +#endif diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 0c895ccefb2..15b9deb012c 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -3,7 +3,13 @@ from esphome.components import sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_sensor, +) DEPENDENCIES = ["dlms_meter"] @@ -40,7 +46,7 @@ REMOVED_KEYS = { CONFIG_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, **{ cv.Optional(key): cv.invalid( f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " @@ -56,4 +62,4 @@ CONFIG_SCHEMA = sensor.sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await sensor.new_sensor(config) - cg.add(hub.register_sensor(config[CONF_OBIS_CODE], var)) + register_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 10872d1a50a..4e2e48487a8 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -3,7 +3,13 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_text_sensor, +) DEPENDENCIES = ["dlms_meter"] @@ -18,7 +24,7 @@ REMOVED_KEYS = { CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, **{ cv.Optional(key): cv.invalid( f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " @@ -34,4 +40,4 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await text_sensor.new_text_sensor(config) - cg.add(hub.register_text_sensor(config[CONF_OBIS_CODE], var)) + register_text_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3d9f1431f67..8ac76cd41fa 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -2,7 +2,7 @@ dependencies: bblanchon/arduinojson: version: "7.4.3" esphome/dlms_parser: - version: 1.1.0 + version: 2.2.0 esphome/esp-audio-libs: version: 4.0.1 esphome/esp-micro-speech-features: diff --git a/platformio.ini b/platformio.ini index 56a12d8b31c..7534b748d1b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -106,7 +106,7 @@ build_unflags = ; esp32 envs (which would double-include them). [common:idf-component-libs] lib_deps = - esphome/dlms_parser@1.1.0 ; dlms_meter + esphome/dlms_parser@2.2.0 ; dlms_meter bblanchon/ArduinoJson@7.4.3 ; json lvgl/lvgl@9.5.0 ; lvgl From a613073ee3d0ac82d73fe1e09eb0dbfa65281be2 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:06:10 -1000 Subject: [PATCH 54/79] Bump bundled esphome-device-builder to 1.23.0 (#20446) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0282011f504..9edbd6e4f9b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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.22.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 RUN \ platformio settings set enable_telemetry No \ From a4a09043ec1c1b5dd1f35c785125d751d56f2b43 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:32:24 +0200 Subject: [PATCH 55/79] [deep_sleep][gpio][core][power_supply] Prepare for gpio hold (2/3) (#18966) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/const/__init__.py | 1 + .../deep_sleep/deep_sleep_esp32.cpp | 17 ++++++++----- esphome/components/esp32/gpio.h | 8 ++++++ esphome/components/gpio/output/__init__.py | 25 ++++++++++++++++++- .../gpio/output/gpio_binary_output.h | 7 ++++-- .../components/gpio/switch/gpio_switch.cpp | 5 ++-- .../components/power_supply/power_supply.cpp | 14 ++++++++++- .../components/power_supply/power_supply.h | 1 + esphome/core/defines.h | 1 + esphome/core/gpio.h | 9 +++++++ 10 files changed, 76 insertions(+), 12 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6eeab52e297..b54d797f096 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -27,6 +27,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HOLD_STATE = "hold_state" CONF_HOST = "host" CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index eef392ad5cf..b71a4fa1383 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -111,7 +111,17 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); - // Single pin wakeup (ext0) - ESP32, S2, S3 only +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP +#ifdef USE_GPIO_HOLD + // Variants without per-pin deep sleep hold need the global enable + gpio_deep_sleep_hold_en(); +#else + if (this->wakeup_pin_ != nullptr) + gpio_deep_sleep_hold_en(); +#endif +#endif + + // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) @@ -124,11 +134,6 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); -#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP - // Some ESP32 variants support holding a single GPIO during deep sleep without this function - // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep - gpio_deep_sleep_hold_en(); -#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index aeff5af51c6..9c661b4d9ea 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -31,6 +31,14 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { bool is_inverted() const override { return this->pin_flags_.inverted; } gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } +#ifdef USE_GPIO_HOLD + bool is_held() const override { + if (!this->get_hold()) + return false; + esp_reset_reason_t reason = esp_reset_reason(); + return reason == ESP_RST_DEEPSLEEP || reason == ESP_RST_SW; + } +#endif protected: void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index ab242c643fc..645dd61f802 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -1,8 +1,10 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_PIN +from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY +import esphome.final_validate as fv from esphome.types import ConfigType from .. import gpio_ns @@ -17,6 +19,27 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) +def _require_hold(ps_config: ConfigType) -> ConfigType: + if not ps_config[CONF_PIN].get(CONF_HOLD_STATE): + raise cv.Invalid( + f"The power supply pin must also set {CONF_HOLD_STATE} " + f"when an output it powers sets it", + [CONF_PIN], + ) + return ps_config + + +def _final_validate(config: ConfigType) -> None: + if not config[CONF_PIN].get(CONF_HOLD_STATE): + return + if (ps_id := config.get(CONF_POWER_SUPPLY)) is None: + return + fv.id_declaration_match_schema(_require_hold)(ps_id) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 496afd131b2..06e36474820 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -11,9 +11,12 @@ class GPIOBinaryOutput final : public output::BinaryOutput, public Component { void set_pin(GPIOPin *pin) { pin_ = pin; } void setup() override { - this->turn_off(); + const bool held = this->pin_->is_held(); + if (!held) + this->turn_off(); this->pin_->setup(); - this->turn_off(); + if (!held) + this->turn_off(); } void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 4daea8d877b..450a97eb190 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -12,8 +12,9 @@ float GPIOSwitch::get_setup_priority() const { return setup_priority::HARDWARE; void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - // write state before setup - this->control(initial_state); + // write state before setup unless the pin kept it across the reset + if (!this->pin_->is_held()) + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs this->control(initial_state); diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 3abffc88e15..8fa1b92c259 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -9,7 +9,12 @@ static constexpr uint32_t POWER_OFF_TIMEOUT_ID = 0; void PowerSupply::setup() { this->pin_->setup(); - this->pin_->digital_write(false); + if (this->pin_->is_held()) { + // Rail stayed on across the reset; drop it unless something asks for it + this->schedule_off_if_idle_(); + } else { + this->pin_->digital_write(false); + } if (this->enable_on_boot_) this->request_high_power(); } @@ -47,6 +52,10 @@ void PowerSupply::unrequest_high_power() { return; } this->active_requests_--; + this->schedule_off_if_idle_(); +} + +void PowerSupply::schedule_off_if_idle_() { if (this->active_requests_ == 0) { this->set_timeout(POWER_OFF_TIMEOUT_ID, this->keep_on_time_, [this]() { ESP_LOGV(TAG, "Disabling"); @@ -54,7 +63,10 @@ void PowerSupply::unrequest_high_power() { }); } } + void PowerSupply::on_powerdown() { + if (this->pin_->get_hold()) + return; this->active_requests_ = 0; this->pin_->digital_write(false); } diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index eaf77af32e9..dc6b368ea8d 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -39,6 +39,7 @@ class PowerSupply final : public Component { uint32_t keep_on_time_; int16_t active_requests_{0}; // use signed integer to make catching negative requests easier. bool enable_on_boot_{false}; + void schedule_off_if_idle_(); }; class PowerSupplyRequester { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 12f61467d65..d4932ae7495 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -474,6 +474,7 @@ #define USE_USB_UART_CP210X #define USE_USB_UART_FT23XX #define USE_USB_UART_PL2303 +#define USE_GPIO_HOLD // USB identity on serial proxy ports needs the usb_host stack #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index 43db3b7c0c5..7daf1917a6d 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -2,6 +2,7 @@ #include #include +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -27,6 +28,7 @@ enum Flags : uint8_t { FLAG_OPEN_DRAIN = 0x04, FLAG_PULLUP = 0x08, FLAG_PULLDOWN = 0x10, + FLAG_HOLD = 0x20, }; class FlagsHelper { @@ -79,6 +81,13 @@ class GPIOPin { virtual size_t dump_summary(char *buffer, size_t len) const; virtual bool is_internal() { return false; } +#ifdef USE_GPIO_HOLD + bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } + virtual bool is_held() const { return false; } +#else + bool get_hold() const { return false; } + bool is_held() const { return false; } +#endif }; /// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) From e5f5ca07c4b661c054e9f14195396fa5eb722505 Mon Sep 17 00:00:00 2001 From: Ali Alaei <49282631+aalaei@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:50:50 +0200 Subject: [PATCH 56/79] [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319) Co-authored-by: tomaszduda23 --- esphome/components/zephyr_mcumgr/ota/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index d4f84e30f24..ed7069a5403 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -147,14 +147,15 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_MGMT_NOTIFICATION_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_STATUS_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK", True) + # OS management group (reset, MCUMGR_PARAMETERS query, etc.) isn't transport-specific -- + # every mcumgr transport needs it, not just BLE. + zephyr_add_prj_conf("MCUMGR_GRP_OS", True) + zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) transport = config[CONF_TRANSPORT] if transport[CONF_BLE]: zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True) zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) - zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: From 4e4056d79095d7b5eb0dd84e68ea30cd37255fae Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:11:48 +0200 Subject: [PATCH 57/79] [uart] Share uart_tcp's write pacing and uart_id matcher (#20464) --- .../cdc_acm_uart/bridge/__init__.py | 15 +---- esphome/components/uart/__init__.py | 19 ++++++ esphome/components/uart/uart_component.cpp | 19 ++++++ esphome/components/uart/uart_component.h | 4 ++ esphome/components/uart_tcp/__init__.py | 14 +---- esphome/components/uart_tcp/uart_tcp.cpp | 14 +---- .../components/uart/uart_paced_write_room.cpp | 62 +++++++++++++++++++ tests/unit_tests/components/test_uart.py | 24 +++++++ 8 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 tests/components/uart/uart_paced_write_room.cpp create mode 100644 tests/unit_tests/components/test_uart.py diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index cee048df5da..133ea92a483 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -36,19 +36,6 @@ CONFIG_SCHEMA = cv.All( ) -def _subtree_references_uart(node: object, uart_id: str) -> bool: - """Return True if any dict in the subtree has a uart_id entry naming this bus.""" - if isinstance(node, dict): - return any( - (key == CONF_UART_ID and str(value) == uart_id) - or _subtree_references_uart(value, uart_id) - for key, value in node.items() - ) - if isinstance(node, list): - return any(_subtree_references_uart(item, uart_id) for item in node) - return False - - def _reject_debug(uart_conf: ConfigType) -> ConfigType: # The worker tasks use the IDF driver directly, so the uart debugger never sees # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. @@ -86,7 +73,7 @@ def _final_validate(config: ConfigType) -> ConfigType: for domain, domain_conf in full_config.items(): if domain == BRIDGE_DOMAIN: continue - if _subtree_references_uart(domain_conf, owned_id): + if uart.subtree_references_uart(domain_conf, owned_id): raise cv.Invalid( f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " f"requires exclusive use of its {label}.", diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 25fbfb75994..2250b9d5088 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -533,6 +533,25 @@ def final_validate_device_schema( ) +def subtree_references_uart( + node: object, uart_id: str, conf_key: str = CONF_UART_ID +) -> bool: + """Return True if any dict in the subtree has a conf_key entry naming this bus. + + For the final validation of a component that needs a UART for itself. Bare + `id:` references (a uart.write action) and lambdas are not found. + """ + if isinstance(node, dict): + return any( + (key == conf_key and str(value) == uart_id) + or subtree_references_uart(value, uart_id, conf_key) + for key, value in node.items() + ) + if isinstance(node, list): + return any(subtree_references_uart(item, uart_id, conf_key) for item in node) + return False + + async def register_uart_device(var, config): """Register a UART device, setting up all the internal values. diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 65161746351..82bc5927024 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -1,9 +1,15 @@ #include "uart_component.h" +#include "esphome/core/application.h" + +#include namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); +// Keeps the pacing product in 32 bits up to about 10 Mbaud. +static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; + bool UARTComponent::check_read_timeout_(size_t len) { if (this->available() >= len) return true; @@ -19,6 +25,19 @@ bool UARTComponent::check_read_timeout_(size_t len) { return true; } +size_t UARTComponent::paced_write_room(uint32_t last_write_ms) { + size_t room = this->available_for_write(); + if (room != SIZE_MAX) { + return room; + } + // A pass woken early writes little. + uint32_t span = + std::min({App.get_loop_component_start_time() - last_write_ms, App.get_loop_interval(), MAX_PACE_SPAN_MS}); + // 10 bits per byte on the line. + uint32_t paced = this->baud_rate_ / 10 * span / 1000; + return std::max(1, paced); +} + void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { uint8_t bytelength = this->data_bits_ + this->stop_bits_ + 1; if (this->parity_ != UARTParityOptions::UART_CONFIG_PARITY_NONE) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 8f143b79777..2543cb54768 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -86,6 +86,10 @@ class UARTComponent { // Platforms that cannot tell return SIZE_MAX: write_array() takes everything and may block. virtual size_t available_for_write() { return SIZE_MAX; } + // Bytes a write can take now. Where available_for_write() cannot tell (SIZE_MAX), paces to the line time since + // last_write_ms, a loop start time: at most one loop interval and 4 s, at least one byte. + size_t paced_write_room(uint32_t last_write_ms); + // Pure virtual method to block until all bytes have been written to the UART bus. // @return UARTFlushResult indicating whether the flush was confirmed, timed out, failed, or assumed successful. virtual UARTFlushResult flush() = 0; diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 601fdeb327e..9d62d48d7c2 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -71,18 +71,6 @@ CONFIG_SCHEMA = cv.All( ) -def _subtree_references_uart(node: object, uart_id: str) -> bool: - if isinstance(node, dict): - return any( - (key == CONF_UART_ID and str(value) == uart_id) - or _subtree_references_uart(value, uart_id) - for key, value in node.items() - ) - if isinstance(node, list): - return any(_subtree_references_uart(item, uart_id) for item in node) - return False - - def _reject_dummy_receiver(uart_conf: ConfigType) -> ConfigType: debug = uart_conf.get(CONF_DEBUG) if isinstance(debug, dict) and debug.get(CONF_DUMMY_RECEIVER): @@ -111,7 +99,7 @@ def _final_validate(config: ConfigType) -> ConfigType: # Bare `id:` references (a uart.write action) and lambdas are not caught. if not CORE.testing_mode: for domain, domain_conf in full_config.items(): - if domain != DOMAIN and _subtree_references_uart(domain_conf, uart_id): + if domain != DOMAIN and uart.subtree_references_uart(domain_conf, uart_id): raise cv.Invalid( f"The UART '{uart_id}' is also used by '{domain}'. " "uart_tcp requires exclusive use of that UART.", diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index bc75eb04c77..d634ac579f2 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -11,9 +11,6 @@ namespace esphome::uart_tcp { ESPHOME_LOG_TAG(TAG, "uart_tcp"); -// Keeps the pacing product in 32 bits up to about 10 Mbaud. -static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; - void UartTcp::setup() { this->link_.begin(TAG); #ifdef USE_SOCKET_TCP_LISTENER @@ -75,16 +72,7 @@ void UartTcp::sync_link_() { void UartTcp::read_socket_() { // A hardware write blocks until the driver takes every byte. Leave what does // not fit in the socket, so TCP flow control throttles the peer. - size_t room = this->parent_->available_for_write(); - if (room == SIZE_MAX) { - // Capacity unknown on this platform; pace to the UART time since the last write, - // at most one loop interval and 4 s, so a pass woken early by the socket writes little. - uint32_t span = std::min( - {App.get_loop_component_start_time() - this->last_write_ms_, App.get_loop_interval(), MAX_PACE_SPAN_MS}); - // 10 bits per byte on the line. - uint32_t paced = this->parent_->get_baud_rate() / 10 * span / 1000; - room = std::max(1, paced); - } + size_t room = this->parent_->paced_write_room(this->last_write_ms_); if (room == 0) { this->rx_pending_ = true; return; diff --git a/tests/components/uart/uart_paced_write_room.cpp b/tests/components/uart/uart_paced_write_room.cpp new file mode 100644 index 00000000000..ed64695dd03 --- /dev/null +++ b/tests/components/uart/uart_paced_write_room.cpp @@ -0,0 +1,62 @@ +#include + +#include "common.h" +#include "esphome/core/application.h" + +namespace esphome::uart::testing { + +class RoomUART : public MockUARTComponent { + public: + size_t available_for_write() override { return this->room; } + size_t room{SIZE_MAX}; +}; + +class PacedWriteRoom : public ::testing::Test { + protected: + void SetUp() override { + App.set_loop_interval(16); + this->uart_.set_baud_rate(9600); + } + void TearDown() override { + App.set_loop_interval(16); + this->at_(0); + } + // Publishes now_ms as the loop start time, as Application::loop() does. + void at_(uint32_t now_ms) { LoopBlockingGuard dispatch{nullptr, nullptr, now_ms}; } + + RoomUART uart_; +}; + +TEST_F(PacedWriteRoom, KnownRoomIsReturned) { + this->uart_.room = 7; + this->at_(1000); + EXPECT_EQ(this->uart_.paced_write_room(0), 7u); + this->uart_.room = 0; + EXPECT_EQ(this->uart_.paced_write_room(0), 0u); +} + +TEST_F(PacedWriteRoom, UnknownRoomTakesOneLoopInterval) { + this->at_(1000); + // 9600 baud at 10 bits per byte for 16 ms. + EXPECT_EQ(this->uart_.paced_write_room(0), 15u); +} + +TEST_F(PacedWriteRoom, EarlyPassTakesTheTimeSinceTheLastWrite) { + this->at_(1005); + EXPECT_EQ(this->uart_.paced_write_room(1000), 4u); +} + +TEST_F(PacedWriteRoom, AtLeastOneByte) { + this->at_(1000); + EXPECT_EQ(this->uart_.paced_write_room(1000), 1u); +} + +TEST_F(PacedWriteRoom, SpanCapsAtFourSeconds) { + App.set_loop_interval(10000); + this->uart_.set_baud_rate(300); + this->at_(6000); + // 30 bytes/s for 4 s. + EXPECT_EQ(this->uart_.paced_write_room(0), 120u); +} + +} // namespace esphome::uart::testing diff --git a/tests/unit_tests/components/test_uart.py b/tests/unit_tests/components/test_uart.py new file mode 100644 index 00000000000..4b062b4729e --- /dev/null +++ b/tests/unit_tests/components/test_uart.py @@ -0,0 +1,24 @@ +"""Tests for the uart helpers shared by components that own a UART.""" + +from esphome.components.uart import subtree_references_uart +from esphome.core import ID + + +def test_finds_a_nested_uart_id() -> None: + config = {"modbus": [{"id": ID("hub"), "uart_id": ID("bus")}]} + assert subtree_references_uart(config, "bus") + + +def test_ignores_other_uarts_and_other_keys() -> None: + config = {"modbus": [{"uart_id": ID("other")}], "sensor": [{"id": ID("bus")}]} + assert not subtree_references_uart(config, "bus") + + +def test_finds_another_key() -> None: + config = {"modbus_tcp_uart": [{"tcp_uart_id": ID("bus")}]} + assert subtree_references_uart(config, "bus", "tcp_uart_id") + assert not subtree_references_uart(config, "bus") + + +def test_handles_scalars() -> None: + assert not subtree_references_uart("bus", "bus") From 1b49e79bf8da3f10dc19c31b33f3ff268f7ee763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:35:38 -1000 Subject: [PATCH 58/79] [microphone] Build on_data with build_callback_automation (#20451) --- esphome/components/microphone/__init__.py | 18 ++++-------------- esphome/components/microphone/automation.h | 17 ----------------- 2 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 esphome/components/microphone/automation.h diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6fa14cdd8b5..a83827ef5d7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -12,7 +12,6 @@ from esphome.const import ( CONF_ID, CONF_MICROPHONE, CONF_ON_DATA, - CONF_TRIGGER_ID, ) from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -30,17 +29,12 @@ microphone_ns = cg.esphome_ns.namespace("microphone") Microphone = microphone_ns.class_("Microphone") MicrophoneSource = microphone_ns.class_("MicrophoneSource") -DataTrigger = microphone_ns.class_( - "DataTrigger", - automation.Trigger.template(cg.std_vector.template(cg.uint8).operator("ref")), -) - async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_data_callback", [(cg.std_vector.template(cg.uint8).operator("ref").operator("const"), "x")], conf, ) @@ -54,11 +48,7 @@ async def register_microphone(var: MockObj, config: ConfigType) -> None: MICROPHONE_SCHEMA = cv.Schema.extend(audio.AUDIO_COMPONENT_SCHEMA).extend( { - cv.Optional(CONF_ON_DATA): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DataTrigger), - } - ), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), } ) diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h deleted file mode 100644 index 0fac18d8283..00000000000 --- a/esphome/components/microphone/automation.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "microphone.h" - -#include - -namespace esphome::microphone { - -class DataTrigger final : public Trigger &> { - public: - explicit DataTrigger(Microphone *mic) { - mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); - } -}; - -} // namespace esphome::microphone From 5d2433f69ad99b3b95d1457bb4cb23ede78409fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:36:57 -1000 Subject: [PATCH 59/79] [graphical_display_menu] Build on_redraw with build_callback_automation (#20452) --- .../graphical_display_menu/__init__.py | 17 +++---------- .../graphical_display_menu.cpp | 2 +- .../graphical_display_menu.h | 12 +--------- .../common-triggers.yaml | 24 +++++++++++++++++++ .../test-triggers.esp32-idf.yaml | 6 +++++ 5 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 tests/components/graphical_display_menu/common-triggers.yaml create mode 100644 tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index 59e8f66c501..a398aea6192 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FONT, CONF_FOREGROUND_COLOR, CONF_ID, - CONF_TRIGGER_ID, ) from esphome.types import ConfigType @@ -29,9 +28,6 @@ MenuItemValueArguments = graphical_display_menu_ns.struct("MenuItemValueArgument MenuItemValueArgumentsConstPtr = MenuItemValueArguments.operator("ptr").operator( "const" ) -GraphicalDisplayMenuOnRedrawTrigger = graphical_display_menu_ns.class_( - "GraphicalDisplayMenuOnRedrawTrigger", automation.Trigger -) CODEOWNERS = ["@MrMDavidson"] DOMAIN = "graphical_display_menu" @@ -49,13 +45,7 @@ CONFIG_SCHEMA = DISPLAY_MENU_BASE_SCHEMA.extend( cv.Optional(CONF_MENU_ITEM_VALUE): cv.templatable(cv.string), cv.Optional(CONF_FOREGROUND_COLOR): cv.use_id(color.ColorStruct), cv.Optional(CONF_BACKGROUND_COLOR): cv.use_id(color.ColorStruct), - cv.Optional(CONF_ON_REDRAW): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - GraphicalDisplayMenuOnRedrawTrigger - ) - } - ), + cv.Optional(CONF_ON_REDRAW): automation.validate_automation({}), } ) ) @@ -92,9 +82,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_background_color(background_color)) for conf in config.get(CONF_ON_REDRAW, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(GraphicalDisplayMenuConstPtr, "it")], conf + await automation.build_callback_automation( + var, "add_on_redraw_callback", [(GraphicalDisplayMenuConstPtr, "it")], conf ) await display_menu_to_code(var, config) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index 8761e5fb4cc..5f5b3e8d425 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -241,6 +241,6 @@ void GraphicalDisplayMenu::draw_item(const display_menu_base::MenuItem *item, co "draw_item should be called."); } -void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(); } +void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(this); } } // namespace esphome::graphical_display_menu diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index 13c0f9d73f8..1f1d8ff3fc5 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -70,17 +70,7 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen Color foreground_color_{COLOR_ON}; Color background_color_{COLOR_OFF}; - CallbackManager on_redraw_callbacks_{}; -}; - -class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { - public: - explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { - parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - GraphicalDisplayMenu *parent_; + LazyCallbackManager on_redraw_callbacks_{}; }; } // namespace graphical_display_menu diff --git a/tests/components/graphical_display_menu/common-triggers.yaml b/tests/components/graphical_display_menu/common-triggers.yaml new file mode 100644 index 00000000000..ea4daeb1629 --- /dev/null +++ b/tests/components/graphical_display_menu/common-triggers.yaml @@ -0,0 +1,24 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: graphical_display_menu_triggers_display + model: SSD1306_128X64 + reset_pin: ${reset_pin} + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +font: + - file: "gfonts://Roboto" + id: graphical_display_menu_triggers_font + size: 20 + +graphical_display_menu: + id: graphical_display_menu_triggers_menu + display: graphical_display_menu_triggers_display + font: graphical_display_menu_triggers_font + on_redraw: + then: + lambda: 'ESP_LOGI("graphical_display_menu", "redraw, active: %d", it->is_active());' + items: + - type: label + text: "Label" diff --git a/tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml b/tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..2adb772294c --- /dev/null +++ b/tests/components/graphical_display_menu/test-triggers.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + reset_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + graphical_display_menu: !include common-triggers.yaml From 79fd5edc7f44cc9a93b7a8be3dcea96c54b14f15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:37:10 -1000 Subject: [PATCH 60/79] [core] Bind InternalGPIOPin to the platform pin class instead of a virtual base (#20455) --- esphome/components/esp32/__init__.py | 8 - esphome/components/esp32/gpio.cpp | 11 +- esphome/components/esp32/gpio.h | 19 +- esphome/components/esp32/gpio.py | 1 - esphome/components/esp8266/gpio.cpp | 3 +- esphome/components/esp8266/gpio.h | 18 +- esphome/components/host/gpio.cpp | 3 +- esphome/components/host/gpio.h | 18 +- esphome/components/libretiny/gpio_arduino.cpp | 3 +- esphome/components/libretiny/gpio_arduino.h | 18 +- esphome/components/rp2/gpio.cpp | 3 +- esphome/components/rp2/gpio.h | 18 +- esphome/components/zephyr/gpio.cpp | 3 +- esphome/components/zephyr/gpio.h | 21 +- esphome/core/defines.h | 1 - esphome/core/gpio.h | 184 ++++-------------- esphome/core/gpio_pin.h | 147 ++++++++++++++ .../deep_sleep/test-timer-only.esp32-idf.yaml | 4 + .../power_supply/test_setup_priority.cpp | 25 +-- 19 files changed, 278 insertions(+), 230 deletions(-) create mode 100644 esphome/core/gpio_pin.h create mode 100644 tests/components/deep_sleep/test-timer-only.esp32-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cfa79e3cf1f..aa4f78de69d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -15,7 +15,6 @@ from esphome.components.const import ( CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, CONF_IGNORE_NOT_FOUND, ) -from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -3967,10 +3966,3 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state - - -# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which -# are instantiated solely by the pin schema codegen (esp32_pin_to_code) -FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} -) diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 6e4de6aa515..d9c9b861858 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,9 +1,10 @@ #include "esphome/core/defines.h" -// Also defines the core ISRInternalGPIOPin methods; those are only reachable -// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. -#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) +// Always built: InternalGPIOPin is this class, so components that merely hold a +// pointer to it reference these methods even in configs without a pin. +#ifdef USE_ESP32 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "driver/gpio.h" #include "driver/rtc_io.h" @@ -68,7 +69,7 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ESP32InternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { gpio_int_type_t idf_type = GPIO_INTR_ANYEDGE; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -207,4 +208,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO +#endif // USE_ESP32 diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 9c661b4d9ea..954b213d7f8 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -1,7 +1,8 @@ #pragma once #ifdef USE_ESP32 -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" +#include #include namespace esphome::esp32 { @@ -10,7 +11,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin final : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public GPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } @@ -24,11 +25,15 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return this->pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return this->pin_; } gpio::Flags get_flags() const override { return this->flags_; } - bool is_inverted() const override { return this->pin_flags_.inverted; } + bool is_inverted() const { return this->pin_flags_.inverted; } + bool is_internal() override { return true; } gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } #ifdef USE_GPIO_HOLD @@ -41,7 +46,7 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { #endif protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; // Memory layout: 8 bytes total on 32-bit systems // - 3 bytes for members below diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 98aac209ec4..321dd3d498f 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -257,7 +257,6 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): - cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index ae09bdb91c3..1d97b2c1e56 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP8266 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::esp8266 { @@ -74,7 +75,7 @@ ISRInternalGPIOPin ESP8266GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ESP8266GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ESP8266GPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { uint8_t arduino_mode = 0; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index 57ef06106a2..84968d97527 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -2,12 +2,12 @@ #ifdef USE_ESP8266 -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" #include namespace esphome::esp8266 { -class ESP8266GPIOPin final : public InternalGPIOPin { +class ESP8266GPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -18,14 +18,18 @@ class ESP8266GPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/host/gpio.cpp b/esphome/components/host/gpio.cpp index c1fd56e945a..b57bd411883 100644 --- a/esphome/components/host/gpio.cpp +++ b/esphome/components/host/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_HOST #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome { @@ -20,7 +21,7 @@ ISRInternalGPIOPin HostGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void HostGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void HostGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { ESP_LOGD(TAG, "Attaching interrupt %p to pin %d and mode %d", func, pin_, (uint32_t) type); } void HostGPIOPin::pin_mode(gpio::Flags flags) { ESP_LOGD(TAG, "Setting pin %d mode to %02X", pin_, (uint32_t) flags); } diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index bd2d09257bb..fd3ebefe1f6 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -2,11 +2,11 @@ #ifdef USE_HOST -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::host { -class HostGPIOPin final : public InternalGPIOPin { +class HostGPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -17,14 +17,18 @@ class HostGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index ff333aacef8..9884c37b85c 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -1,6 +1,7 @@ #ifdef USE_LIBRETINY #include "gpio_arduino.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::libretiny { @@ -37,7 +38,7 @@ ISRInternalGPIOPin ArduinoInternalGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ArduinoInternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ArduinoInternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = (PinStatus) 255; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index da477fde363..b5ccf544ba3 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -1,11 +1,11 @@ #pragma once #ifdef USE_LIBRETINY -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::libretiny { -class ArduinoInternalGPIOPin final : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -16,14 +16,18 @@ class ArduinoInternalGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index 93bdba1fca6..2eeb460e954 100644 --- a/esphome/components/rp2/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_RP2 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome { @@ -40,7 +41,7 @@ ISRInternalGPIOPin RP2GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/rp2/gpio.h b/esphome/components/rp2/gpio.h index 538fef619a2..b1d4c9a8f61 100644 --- a/esphome/components/rp2/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -3,11 +3,11 @@ #ifdef USE_RP2 #include -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::rp2 { -class RP2GPIOPin final : public InternalGPIOPin { +class RP2GPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -18,14 +18,18 @@ class RP2GPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 4400f0a0c5c..57a31e302e5 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,5 +1,6 @@ #ifdef USE_ZEPHYR #include "gpio.h" +#include "esphome/core/hal.h" #include #include #include "esphome/core/log.h" @@ -79,7 +80,7 @@ ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ZephyrGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { if (!device_is_ready(this->gpio_)) { ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); return; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 71d1620a677..dd26e34eb8b 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -1,7 +1,7 @@ #pragma once #ifdef USE_ZEPHYR -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" #include #include namespace esphome::zephyr { @@ -16,7 +16,7 @@ struct ZephyrGPIOInterrupt { void *arg{nullptr}; }; -class ZephyrGPIOPin final : public InternalGPIOPin { +class ZephyrGPIOPin final : public GPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; @@ -32,14 +32,18 @@ class ZephyrGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return this->pin_; } - bool is_inverted() const override { return this->inverted_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return this->pin_; } + bool is_inverted() const { return this->inverted_; } + bool is_internal() override { return true; } gpio::Flags get_flags() const override { return flags_; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; const device *gpio_{nullptr}; const char *pin_name_prefix_{nullptr}; gpio::Flags flags_{}; @@ -48,8 +52,7 @@ class ZephyrGPIOPin final : public InternalGPIOPin { bool inverted_{}; bool value_{false}; - // attach_interrupt()/detach_interrupt() are const (matching the base class), so - // the interrupt state they manage has to be mutable. + // attach_interrupt_()/detach_interrupt() are const, so their interrupt state is mutable. mutable ZephyrGPIOInterrupt interrupt_{}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4932ae7495..731c0d20171 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -364,7 +364,6 @@ #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER #define USE_ESP32_VASPRINTF_STUB -#define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index 7daf1917a6d..8ed0d70f6be 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,150 +1,44 @@ #pragma once -#include -#include +#include "esphome/core/gpio_pin.h" -#include "esphome/core/defines.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" +// Each platform has exactly one internal pin class, so InternalGPIOPin is an alias for +// it instead of an abstract base class: calls through the alias are direct and can be inlined. +#if defined(USE_ESP32) +#include "esphome/components/esp32/gpio.h" +namespace esphome { +using InternalGPIOPin = esp32::ESP32InternalGPIOPin; +} +#elif defined(USE_ESP8266) +#include "esphome/components/esp8266/gpio.h" +namespace esphome { +using InternalGPIOPin = esp8266::ESP8266GPIOPin; +} +#elif defined(USE_LIBRETINY) +#include "esphome/components/libretiny/gpio_arduino.h" +namespace esphome { +using InternalGPIOPin = libretiny::ArduinoInternalGPIOPin; +} +#elif defined(USE_RP2) +#include "esphome/components/rp2/gpio.h" +namespace esphome { +using InternalGPIOPin = rp2::RP2GPIOPin; +} +#elif defined(USE_HOST) +#include "esphome/components/host/gpio.h" +namespace esphome { +using InternalGPIOPin = host::HostGPIOPin; +} +#elif defined(USE_ZEPHYR) +#include "esphome/components/zephyr/gpio.h" +namespace esphome { +using InternalGPIOPin = zephyr::ZephyrGPIOPin; +} +#else +#error "gpio.h: no internal pin class for this platform" +#endif namespace esphome { - -/// Maximum buffer size for dump_summary output -inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; - -#ifdef USE_ESP8266 -#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) -#else -#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) -#endif - -// put GPIO flags in a namespace to not pollute esphome namespace -namespace gpio { - -enum Flags : uint8_t { - // Can't name these just INPUT because of Arduino defines :( - FLAG_NONE = 0x00, - FLAG_INPUT = 0x01, - FLAG_OUTPUT = 0x02, - FLAG_OPEN_DRAIN = 0x04, - FLAG_PULLUP = 0x08, - FLAG_PULLDOWN = 0x10, - FLAG_HOLD = 0x20, -}; - -class FlagsHelper { - public: - constexpr FlagsHelper(Flags val) : val_(val) {} - constexpr operator Flags() const { return val_; } - - protected: - Flags val_; -}; -constexpr FlagsHelper operator&(Flags lhs, Flags rhs) { - return static_cast(static_cast(lhs) & static_cast(rhs)); -} -constexpr FlagsHelper operator|(Flags lhs, Flags rhs) { - return static_cast(static_cast(lhs) | static_cast(rhs)); -} - -enum InterruptType : uint8_t { - INTERRUPT_RISING_EDGE = 1, - INTERRUPT_FALLING_EDGE = 2, - INTERRUPT_ANY_EDGE = 3, - INTERRUPT_LOW_LEVEL = 4, - INTERRUPT_HIGH_LEVEL = 5, -}; - -} // namespace gpio - -class GPIOPin { - public: - virtual void setup() = 0; - - virtual void pin_mode(gpio::Flags flags) = 0; - - /** - * @brief Retrieve GPIO pin flags. - * - * @return The GPIO flags describing the pin mode and properties. - */ - virtual gpio::Flags get_flags() const = 0; - - virtual bool digital_read() = 0; - - virtual void digital_write(bool value) = 0; - - /// Write a summary of this pin to the provided buffer. - /// @param buffer The buffer to write to - /// @param len The size of the buffer (must be > 0) - /// @return The number of characters that would be written (excluding null terminator), - /// which may exceed len-1 if truncation occurred (snprintf semantics) - virtual size_t dump_summary(char *buffer, size_t len) const; - - virtual bool is_internal() { return false; } -#ifdef USE_GPIO_HOLD - bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } - virtual bool is_held() const { return false; } -#else - bool get_hold() const { return false; } - bool is_held() const { return false; } -#endif -}; - -/// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) -class ISRInternalGPIOPin { - public: - ISRInternalGPIOPin() = default; - ISRInternalGPIOPin(void *arg) : arg_(arg) {} - bool digital_read(); - void digital_write(bool value); - void clear_interrupt(); - void pin_mode(gpio::Flags flags); - - protected: - void *arg_{nullptr}; -}; - -class InternalGPIOPin : public GPIOPin { - public: - template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { - this->attach_interrupt(reinterpret_cast(func), arg, type); - } - - virtual void detach_interrupt() const = 0; - - virtual ISRInternalGPIOPin to_isr() const = 0; - - virtual uint8_t get_pin() const = 0; - - bool is_internal() override { return true; } - - virtual bool is_inverted() const = 0; - - protected: - virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; -}; - -// Inline default implementation for GPIOPin::dump_summary. -// Writes an empty summary; subclasses override to provide pin details. -inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len > 0) - buffer[0] = '\0'; - return 0; -} - -// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp -inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { - char buffer[GPIO_SUMMARY_MAX_LEN]; - size_t len = pin->dump_summary(buffer, sizeof(buffer)); - len = std::min(len, sizeof(buffer) - 1); - esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); -} - -// log_pin function declarations - implementation in gpio.cpp -#ifdef USE_ESP8266 -void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); -#else -void log_pin(const char *tag, const char *prefix, GPIOPin *pin); -#endif - +static_assert(InternalGPIOPinContract, + "The platform's internal pin class is missing part of the InternalGPIOPin surface " + "(esphome/core/gpio_pin.h)"); } // namespace esphome diff --git a/esphome/core/gpio_pin.h b/esphome/core/gpio_pin.h new file mode 100644 index 00000000000..6c9acfa8d6a --- /dev/null +++ b/esphome/core/gpio_pin.h @@ -0,0 +1,147 @@ +#pragma once +#include +#include +#include +#include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { + +/// Maximum buffer size for dump_summary output +inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; + +#ifdef USE_ESP8266 +#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) +#else +#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) +#endif + +// put GPIO flags in a namespace to not pollute esphome namespace +namespace gpio { + +enum Flags : uint8_t { + // Can't name these just INPUT because of Arduino defines :( + FLAG_NONE = 0x00, + FLAG_INPUT = 0x01, + FLAG_OUTPUT = 0x02, + FLAG_OPEN_DRAIN = 0x04, + FLAG_PULLUP = 0x08, + FLAG_PULLDOWN = 0x10, + FLAG_HOLD = 0x20, +}; + +class FlagsHelper { + public: + constexpr FlagsHelper(Flags val) : val_(val) {} + constexpr operator Flags() const { return val_; } + + protected: + Flags val_; +}; +constexpr FlagsHelper operator&(Flags lhs, Flags rhs) { + return static_cast(static_cast(lhs) & static_cast(rhs)); +} +constexpr FlagsHelper operator|(Flags lhs, Flags rhs) { + return static_cast(static_cast(lhs) | static_cast(rhs)); +} + +enum InterruptType : uint8_t { + INTERRUPT_RISING_EDGE = 1, + INTERRUPT_FALLING_EDGE = 2, + INTERRUPT_ANY_EDGE = 3, + INTERRUPT_LOW_LEVEL = 4, + INTERRUPT_HIGH_LEVEL = 5, +}; + +} // namespace gpio + +class GPIOPin { + public: + virtual void setup() = 0; + + virtual void pin_mode(gpio::Flags flags) = 0; + + /** + * @brief Retrieve GPIO pin flags. + * + * @return The GPIO flags describing the pin mode and properties. + */ + virtual gpio::Flags get_flags() const = 0; + + virtual bool digital_read() = 0; + + virtual void digital_write(bool value) = 0; + + /// Write a summary of this pin to the provided buffer. + /// @param buffer The buffer to write to + /// @param len The size of the buffer (must be > 0) + /// @return The number of characters that would be written (excluding null terminator), + /// which may exceed len-1 if truncation occurred (snprintf semantics) + virtual size_t dump_summary(char *buffer, size_t len) const; + + /// Only the platform pin class behind the InternalGPIOPin alias may return true: + /// callers static_cast to InternalGPIOPin on it. + virtual bool is_internal() { return false; } +#ifdef USE_GPIO_HOLD + bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } + virtual bool is_held() const { return false; } +#else + bool get_hold() const { return false; } + bool is_held() const { return false; } +#endif +}; + +/// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) +class ISRInternalGPIOPin { + public: + ISRInternalGPIOPin() = default; + ISRInternalGPIOPin(void *arg) : arg_(arg) {} + bool digital_read(); + void digital_write(bool value); + void clear_interrupt(); + void pin_mode(gpio::Flags flags); + + protected: + void *arg_{nullptr}; +}; + +// What callers of the InternalGPIOPin alias (bound in esphome/core/gpio.h) rely on beyond +// GPIOPin. The class must be final so calls to the GPIOPin virtuals through the alias are direct. +// int is arbitrary: any T * exercises the public attach_interrupt template. +template +concept InternalGPIOPinContract = std::derived_from && std::is_final_v && + requires(const T pin, void (*func)(int *), int *arg, gpio::InterruptType type) { + { pin.get_pin() } -> std::same_as; + { pin.is_inverted() } -> std::same_as; + { pin.to_isr() } -> std::same_as; + { pin.detach_interrupt() } -> std::same_as; + { pin.attach_interrupt(func, arg, type) } -> std::same_as; +}; + +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. +inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { + if (len > 0) + buffer[0] = '\0'; + return 0; +} + +// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp +inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { + char buffer[GPIO_SUMMARY_MAX_LEN]; + size_t len = pin->dump_summary(buffer, sizeof(buffer)); + len = std::min(len, sizeof(buffer) - 1); + esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); +} + +// log_pin function declarations - implementation in gpio.cpp +#ifdef USE_ESP8266 +void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); +#else +void log_pin(const char *tag, const char *prefix, GPIOPin *pin); +#endif + +} // namespace esphome diff --git a/tests/components/deep_sleep/test-timer-only.esp32-idf.yaml b/tests/components/deep_sleep/test-timer-only.esp32-idf.yaml new file mode 100644 index 00000000000..ad93ea225fd --- /dev/null +++ b/tests/components/deep_sleep/test-timer-only.esp32-idf.yaml @@ -0,0 +1,4 @@ +# No pin anywhere in the config: the pin class methods deep_sleep calls must still link +deep_sleep: + run_duration: 10s + sleep_duration: 50s diff --git a/tests/components/power_supply/test_setup_priority.cpp b/tests/components/power_supply/test_setup_priority.cpp index 401fc72654e..736fdf99b0c 100644 --- a/tests/components/power_supply/test_setup_priority.cpp +++ b/tests/components/power_supply/test_setup_priority.cpp @@ -1,3 +1,4 @@ +#ifdef USE_HOST #include #include "esphome/components/power_supply/power_supply.h" @@ -6,28 +7,9 @@ namespace esphome::power_supply::testing { -// Minimal dummy internal GPIO pin implementation for testing -class DummyInternalPin : public InternalGPIOPin { - public: - DummyInternalPin() = default; - void setup() override {} - void pin_mode(esphome::gpio::Flags) override {} - esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; } - bool digital_read() override { return false; } - void digital_write(bool) override {} - void detach_interrupt() const override {} - ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); } - uint8_t get_pin() const override { return 0; } - bool is_inverted() const override { return false; } - - protected: - // Implement protected attach_interrupt required by InternalGPIOPin - void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {} -}; - TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { power_supply::PowerSupply ps; - DummyInternalPin pin; + InternalGPIOPin pin; ps.set_pin(&pin); ps.set_enable_on_boot(true); @@ -37,7 +19,7 @@ TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { power_supply::PowerSupply ps; - DummyInternalPin pin; + InternalGPIOPin pin; ps.set_pin(&pin); ps.set_enable_on_boot(false); @@ -45,3 +27,4 @@ TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { } } // namespace esphome::power_supply::testing +#endif // USE_HOST From 59963dd246715a999feb13b7943beb64b6f40770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:38:16 -1000 Subject: [PATCH 61/79] [espidf] Declare the map byproduct only when IDF has not (#20462) --- esphome/build_gen/espidf.py | 17 +++++++++++++---- tests/unit_tests/build_gen/test_espidf.py | 13 +++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 759d9a0bef4..a826a2b6283 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -425,15 +425,24 @@ project({CORE.name}) # 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. +# IDF's size target depends on the map, so ninja has to know the link +# writes it for size to build in the same run as all. IDF declares the +# map itself since espressif/esp-idf#19201 (and in cmakev2), and ninja +# rejects two rules for one output, so declare it here only when IDF has +# not. add_custom_command(OUTPUT) marks its outputs GENERATED. +get_source_file_property(esphome_map_declared + ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map GENERATED) +if(esphome_map_declared) + set(esphome_map_byproducts) +else() + set(esphome_map_byproducts BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map) +endif() 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 + ${{esphome_map_byproducts}} WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} VERBATIM ) diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index c439b87d163..9d92988da0f 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -242,9 +242,18 @@ def test_get_project_cmakelists_drops_empty_lwip_sources() -> None: def test_get_project_cmakelists_declares_map_as_link_byproduct() -> None: - """The link declares the map so size can build in the same ninja run.""" + """The map is a link byproduct only when IDF has not declared it itself.""" content = _render() - assert "BYPRODUCTS ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map" in content + assert ( + "get_source_file_property(esphome_map_declared\n" + " ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map GENERATED)" + ) in content + assert ( + "set(esphome_map_byproducts BYPRODUCTS " + "${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map)" + ) in content + assert " ${esphome_map_byproducts}\n WORKING_DIRECTORY" in content + assert "BYPRODUCTS ${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.map\n" not in content def test_get_project_cmakelists_uses_supplied_builtin_components() -> None: From 95dc9a749b07a0250f8a1c34448e020efd888208 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:39:00 -1000 Subject: [PATCH 62/79] [display_menu_base] Build the menu item triggers with build_parent_callback_automation (#20443) --- .../components/display_menu_base/__init__.py | 128 ++++-------------- .../components/display_menu_base/automation.h | 58 -------- 2 files changed, 30 insertions(+), 156 deletions(-) delete mode 100644 esphome/components/display_menu_base/automation.h diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9d046dbc19a..791242cda12 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -20,9 +20,10 @@ from esphome.const import ( CONF_ON_VALUE, CONF_SWITCH, CONF_TEXT, - CONF_TRIGGER_ID, CONF_TYPE, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@numo68"] DOMAIN = "display_menu_base" @@ -86,26 +87,6 @@ MENU_MODES = { CONF_JOYSTICK: MenuMode.MENU_MODE_JOYSTICK, } -DisplayMenuOnEnterTrigger = display_menu_base_ns.class_( - "DisplayMenuOnEnterTrigger", automation.Trigger -) - -DisplayMenuOnLeaveTrigger = display_menu_base_ns.class_( - "DisplayMenuOnLeaveTrigger", automation.Trigger -) - -DisplayMenuOnValueTrigger = display_menu_base_ns.class_( - "DisplayMenuOnValueTrigger", automation.Trigger -) - -DisplayMenuOnNextTrigger = display_menu_base_ns.class_( - "DisplayMenuOnNextTrigger", automation.Trigger -) - -DisplayMenuOnPrevTrigger = display_menu_base_ns.class_( - "DisplayMenuOnPrevTrigger", automation.Trigger -) - def validate_format(format): if re.search(r"^%[+-]*(\d+)?(\.\d+)?[fg]$", format) is None: @@ -129,44 +110,20 @@ MENU_ITEM_COMMON_SCHEMA = cv.Schema( MENU_ITEM_ENTER_LEAVE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( { - cv.Optional(CONF_ON_ENTER): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnEnterTrigger - ), - } - ), - cv.Optional(CONF_ON_LEAVE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnLeaveTrigger - ), - } - ), + cv.Optional(CONF_ON_ENTER): automation.validate_automation({}), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}), } ) MENU_ITEM_VALUE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnValueTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), } ) MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA = MENU_ITEM_ENTER_LEAVE_SCHEMA.extend( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnValueTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), } ) @@ -230,20 +187,8 @@ MENU_ITEM_SCHEMA = cv.typed_schema( cv.GenerateID(CONF_ID): cv.declare_id(MenuItemCustom), cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_ON_NEXT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnNextTrigger - ), - } - ), - cv.Optional(CONF_ON_PREV): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnPrevTrigger - ), - } - ), + cv.Optional(CONF_ON_NEXT): automation.validate_automation({}), + cv.Optional(CONF_ON_PREV): automation.validate_automation({}), } ), }, @@ -256,20 +201,8 @@ DISPLAY_MENU_BASE_SCHEMA = cv.Schema( cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.GenerateID(CONF_ROOT_ITEM_ID): cv.declare_id(MenuItemMenu), cv.Optional(CONF_MODE, default=CONF_ROTARY): cv.enum(MENU_MODES), - cv.Optional(CONF_ON_ENTER): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnEnterTrigger - ), - } - ), - cv.Optional(CONF_ON_LEAVE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnLeaveTrigger - ), - } - ), + cv.Optional(CONF_ON_ENTER): automation.validate_automation({}), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}), cv.Required(CONF_ITEMS): cv.All( cv.ensure_list(MENU_ITEM_SCHEMA), cv.Length(min=1) ), @@ -302,6 +235,24 @@ automation.register_apply_condition( ) +# Each item callback carries nothing; the automation receives the item itself. +_ITEM_CALLBACKS = ( + (CONF_ON_ENTER, "add_on_enter_callback"), + (CONF_ON_LEAVE, "add_on_leave_callback"), + (CONF_ON_VALUE, "add_on_value_callback"), + (CONF_ON_NEXT, "add_on_next_callback"), + (CONF_ON_PREV, "add_on_prev_callback"), +) + + +async def _build_item_automations(item: MockObj, config: ConfigType) -> None: + for conf_key, callback_method in _ITEM_CALLBACKS: + for conf in config.get(conf_key, []): + await automation.build_parent_callback_automation( + item, callback_method, (MenuItemConstPtr, "it"), conf + ) + + async def menu_item_to_code(menu, config, parent): if config[CONF_TYPE] in MENU_ITEMS_WITH_SPECIALIZED_CLASSES: item = cg.new_Pvariable(config[CONF_ID]) @@ -340,21 +291,7 @@ async def menu_item_to_code(menu, config, parent): cg.add(item.set_switch_variable(var)) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) - for conf in config.get(CONF_ON_ENTER, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_LEAVE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_NEXT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_PREV, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + await _build_item_automations(item, config) async def display_menu_to_code(menu, config): @@ -364,9 +301,4 @@ async def display_menu_to_code(menu, config): cg.add(menu.set_mode(config[CONF_MODE])) for c in config[CONF_ITEMS]: await menu_item_to_code(menu, c, root_item) - for conf in config.get(CONF_ON_ENTER, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_LEAVE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + await _build_item_automations(root_item, config) diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h deleted file mode 100644 index 477a971d479..00000000000 --- a/esphome/components/display_menu_base/automation.h +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "display_menu_base.h" - -namespace esphome::display_menu_base { - -class DisplayMenuOnEnterTrigger final : public Trigger { - public: - explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnLeaveTrigger final : public Trigger { - public: - explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnValueTrigger final : public Trigger { - public: - explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnNextTrigger final : public Trigger { - public: - explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { - parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItemCustom *parent_; -}; - -class DisplayMenuOnPrevTrigger final : public Trigger { - public: - explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { - parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItemCustom *parent_; -}; - -} // namespace esphome::display_menu_base From f1a23a02c24203c306c3a7c9f970abe71ee5e1cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:39:06 -1000 Subject: [PATCH 63/79] [time] Build on_time_sync with build_callback_automation (#20441) --- esphome/components/time/__init__.py | 23 +++++++------------ esphome/components/time/automation.cpp | 4 ---- esphome/components/time/automation.h | 8 ------- tests/component_tests/time/test_triggers.py | 6 ++--- .../time/test-sync.esp8266-ard.yaml | 8 +++++++ 5 files changed, 19 insertions(+), 30 deletions(-) create mode 100644 tests/components/time/test-sync.esp8266-ard.yaml diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index cedb9b3c9b5..a017109e7d3 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -46,7 +46,6 @@ DOMAIN = "time" time_ns = cg.esphome_ns.namespace("time") RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent) CronTrigger = time_ns.class_("CronTrigger", automation.Trigger.template(), cg.Component) -SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Component) # C++ types for pre-parsed timezone struct generation DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) @@ -357,11 +356,7 @@ TIME_SCHEMA = cv.Schema( }, validate_cron_keys, ), - cv.Optional(CONF_ON_TIME_SYNC): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SyncTrigger), - } - ), + cv.Optional(CONF_ON_TIME_SYNC): automation.validate_automation({}), } ).extend( # ``visibility=ADVANCED`` flags the inherited ``update_interval`` @@ -424,8 +419,7 @@ async def setup_time_core_(time_var, config): _emit_parsed_timezone_fields(parsed) on_time = config.get(CONF_ON_TIME, []) - on_time_sync = config.get(CONF_ON_TIME_SYNC, []) - if on_time or on_time_sync: + if on_time: cg.add_define("USE_TIME_TRIGGERS") for conf in on_time: @@ -447,11 +441,10 @@ async def setup_time_core_(time_var, config): await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) - for conf in on_time_sync: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) - - await cg.register_component(trigger, conf) - await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_TIME_SYNC, []): + await automation.build_callback_automation( + time_var, "add_on_time_sync_callback", [], conf + ) async def register_time(time_var, config): @@ -478,8 +471,8 @@ automation.register_apply_condition( # posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a -# timezone is configured or detected; automation.cpp holds the on_time and -# on_time_sync triggers and is #ifdef'd on USE_TIME_TRIGGERS. +# timezone is configured or detected; automation.cpp holds the on_time +# trigger and is #ifdef'd on USE_TIME_TRIGGERS. FILTER_SOURCE_FILES = filter_source_files_from_defines( { "posix_tz.cpp": "USE_TIME_TIMEZONE", diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index 26956703e7f..1c3d4625667 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -94,10 +94,6 @@ void CronTrigger::add_days_of_week(const std::vector &days_of_week) { } float CronTrigger::get_setup_priority() const { return setup_priority::HARDWARE; } -SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) { - rtc->add_on_time_sync_callback([this]() { this->trigger(); }); -} - } // namespace esphome::time #endif // USE_TIME_TRIGGERS diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 5f980690a41..5e70000ac3f 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -43,14 +43,6 @@ class CronTrigger final : public Trigger<>, public Component { RealTimeClock *rtc_; optional last_check_; }; - -class SyncTrigger final : public Trigger<>, public Component { - public: - explicit SyncTrigger(RealTimeClock *rtc); - - protected: - RealTimeClock *rtc_; -}; } // namespace esphome::time #endif // USE_TIME_TRIGGERS diff --git a/tests/component_tests/time/test_triggers.py b/tests/component_tests/time/test_triggers.py index 34fd5a31578..d6c3eed584f 100644 --- a/tests/component_tests/time/test_triggers.py +++ b/tests/component_tests/time/test_triggers.py @@ -1,5 +1,5 @@ -"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an -on_time or on_time_sync automation exists, so the define must follow them.""" +"""automation.cpp (CronTrigger) is only compiled when an on_time automation +exists, so the define must follow it; on_time_sync needs no class.""" from collections.abc import Callable from pathlib import Path @@ -14,7 +14,7 @@ from esphome.core import CORE [ ("no_triggers.yaml", False), ("on_time.yaml", True), - ("on_time_sync.yaml", True), + ("on_time_sync.yaml", False), ], ) def test_triggers_define_follows_automations( diff --git a/tests/components/time/test-sync.esp8266-ard.yaml b/tests/components/time/test-sync.esp8266-ard.yaml new file mode 100644 index 00000000000..6240fcd73e7 --- /dev/null +++ b/tests/components/time/test-sync.esp8266-ard.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + on_time_sync: + - logger.log: Time synchronized From e8943975035e93923830820428b06b3677bab8f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:39:25 -1000 Subject: [PATCH 64/79] [datetime] Build on_value with build_callback_automation (#20445) --- esphome/components/datetime/__init__.py | 20 +++++++++---------- esphome/components/datetime/datetime_base.h | 10 ---------- .../components/datetime/common-triggers.yaml | 10 ++++++++++ .../datetime/test-triggers.esp8266-ard.yaml | 3 +++ 4 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 tests/components/datetime/common-triggers.yaml create mode 100644 tests/components/datetime/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index de79c05a9f0..20fd258cb00 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -43,9 +43,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase) -DateTimeStateTrigger = datetime_ns.class_( - "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) -) OnTimeTrigger = datetime_ns.class_( "OnTimeTrigger", automation.Trigger, cg.Component, cg.Parented.template(TimeEntity) @@ -75,11 +72,7 @@ def _validate_time_present(config: ConfigType) -> ConfigType: _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( cv.Schema( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DateTimeStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), } ) @@ -144,9 +137,16 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: await mqtt.register_mqtt_component(mqtt_, config) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) + # The state callback carries nothing; the automation gets the value as an ESPTime. for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.ESPTime, "x")], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.ESPTime, "x")], + conf, + params=[], + forward=[automation.parent_ref(var).state_as_esptime()], + ) if CONF_TIME_ID in config: rtc = await cg.get_variable(config[CONF_TIME_ID]) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index f99debb692d..fdbb1930068 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,14 +31,4 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger final : public Trigger { - public: - explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { - parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); - } - - protected: - DateTimeBase *parent_; -}; - } // namespace esphome::datetime diff --git a/tests/components/datetime/common-triggers.yaml b/tests/components/datetime/common-triggers.yaml new file mode 100644 index 00000000000..e93e8b2b514 --- /dev/null +++ b/tests/components/datetime/common-triggers.yaml @@ -0,0 +1,10 @@ +datetime: + - platform: template + name: Datetime Trigger Test + id: datetime_trigger_test + type: datetime + optimistic: true + on_value: + - logger.log: + format: "New value %04d-%02d-%02d" + args: [x.year, x.month, x.day_of_month] diff --git a/tests/components/datetime/test-triggers.esp8266-ard.yaml b/tests/components/datetime/test-triggers.esp8266-ard.yaml new file mode 100644 index 00000000000..3829bedb1af --- /dev/null +++ b/tests/components/datetime/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + datetime: !include common.yaml + triggers: !include common-triggers.yaml From bc44e8b5504fc01775355d443bea0d5be5cc7b70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:40:40 -1000 Subject: [PATCH 65/79] [display] Test on_page_change with each from and to combination (#20457) --- .../display/common-page-change.yaml | 26 +++++++++++++++++++ .../display/test-page-change.esp32-idf.yaml | 6 +++++ 2 files changed, 32 insertions(+) create mode 100644 tests/components/display/common-page-change.yaml create mode 100644 tests/components/display/test-page-change.esp32-idf.yaml diff --git a/tests/components/display/common-page-change.yaml b/tests/components/display/common-page-change.yaml new file mode 100644 index 00000000000..649cad767be --- /dev/null +++ b/tests/components/display/common-page-change.yaml @@ -0,0 +1,26 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + model: SSD1306_128X64 + reset_pin: ${reset_pin} + id: display_page_change_display + pages: + - id: display_page_change_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: display_page_change_page2 + lambda: |- + it.rectangle(0, 0, 10, 10); + on_page_change: + - then: + lambda: 'ESP_LOGD("display", "any change, %p %p", from, to);' + - from: display_page_change_page1 + then: + lambda: 'ESP_LOGD("display", "left page 1, %p %p", from, to);' + - to: display_page_change_page1 + then: + lambda: 'ESP_LOGD("display", "entered page 1, %p %p", from, to);' + - from: display_page_change_page1 + to: display_page_change_page2 + then: + lambda: 'ESP_LOGD("display", "1 -> 2, %p %p", from, to);' diff --git a/tests/components/display/test-page-change.esp32-idf.yaml b/tests/components/display/test-page-change.esp32-idf.yaml new file mode 100644 index 00000000000..b44cc076b29 --- /dev/null +++ b/tests/components/display/test-page-change.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + reset_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + display: !include common-page-change.yaml From fdc163a30d764045ce16d0a5f7cbb88f29bbeaf3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:42:43 -1000 Subject: [PATCH 66/79] [tuya] Build on_datapoint_update with build_trigger_callback (#20453) --- esphome/components/tuya/__init__.py | 85 +++++++------------------- esphome/components/tuya/automation.cpp | 65 -------------------- esphome/components/tuya/automation.h | 53 ---------------- esphome/components/tuya/tuya.cpp | 19 ++++-- esphome/components/tuya/tuya.h | 10 ++- tests/components/tuya/common.yaml | 45 +++++++++++++- 6 files changed, 87 insertions(+), 190 deletions(-) delete mode 100644 esphome/components/tuya/automation.cpp delete mode 100644 esphome/components/tuya/automation.h diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 6957b57e0e0..bf392c78268 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,7 +2,8 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID +from esphome.cpp_generator import MockObj DEPENDENCIES = ["uart"] DOMAIN = "tuya" @@ -17,6 +18,8 @@ tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) Tuya = tuya_ns.class_("Tuya", cg.Component, uart.UARTDevice) +TuyaDatapoint = tuya_ns.struct("TuyaDatapoint") + DPTYPE_ANY = "any" DPTYPE_RAW = "raw" DPTYPE_BOOL = "bool" @@ -26,60 +29,18 @@ DPTYPE_STRING = "string" DPTYPE_ENUM = "enum" DPTYPE_BITMASK = "bitmask" +# Automation argument type, plus the expected TuyaDatapointType and the field forwarded DATAPOINT_TYPES = { - DPTYPE_ANY: tuya_ns.struct("TuyaDatapoint"), - DPTYPE_RAW: cg.std_vector.template(cg.uint8), - DPTYPE_BOOL: cg.bool_, - DPTYPE_INT: cg.int_, - DPTYPE_UINT: cg.uint32, - DPTYPE_STRING: cg.std_string, - DPTYPE_ENUM: cg.uint8, - DPTYPE_BITMASK: cg.uint32, + DPTYPE_ANY: (TuyaDatapoint, None, None), + DPTYPE_RAW: (cg.std_vector.template(cg.uint8), TuyaDatapointType.RAW, "value_raw"), + DPTYPE_BOOL: (cg.bool_, TuyaDatapointType.BOOLEAN, "value_bool"), + DPTYPE_INT: (cg.int_, TuyaDatapointType.INTEGER, "value_int"), + DPTYPE_UINT: (cg.uint32, TuyaDatapointType.INTEGER, "value_uint"), + DPTYPE_STRING: (cg.std_string, TuyaDatapointType.STRING, "value_string"), + DPTYPE_ENUM: (cg.uint8, TuyaDatapointType.ENUM, "value_enum"), + DPTYPE_BITMASK: (cg.uint32, TuyaDatapointType.BITMASK, "value_bitmask"), } -DATAPOINT_TRIGGERS = { - DPTYPE_ANY: tuya_ns.class_( - "TuyaDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_ANY]), - ), - DPTYPE_RAW: tuya_ns.class_( - "TuyaRawDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_RAW]), - ), - DPTYPE_BOOL: tuya_ns.class_( - "TuyaBoolDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_BOOL]), - ), - DPTYPE_INT: tuya_ns.class_( - "TuyaIntDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_INT]), - ), - DPTYPE_UINT: tuya_ns.class_( - "TuyaUIntDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_UINT]), - ), - DPTYPE_STRING: tuya_ns.class_( - "TuyaStringDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_STRING]), - ), - DPTYPE_ENUM: tuya_ns.class_( - "TuyaEnumDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_ENUM]), - ), - DPTYPE_BITMASK: tuya_ns.class_( - "TuyaBitmaskDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_BITMASK]), - ), -} - - -def assign_declare_id(value): - value = value.copy() - value[CONF_TRIGGER_ID] = cv.declare_id( - DATAPOINT_TRIGGERS[value[CONF_DATAPOINT_TYPE]] - )(value[CONF_TRIGGER_ID].id) - return value - CONF_TUYA_ID = "tuya_id" CONFIG_SCHEMA = ( @@ -93,15 +54,11 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_STATUS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_ON_DATAPOINT_UPDATE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DATAPOINT_TRIGGERS[DPTYPE_ANY] - ), cv.Required(CONF_SENSOR_DATAPOINT): cv.uint8_t, cv.Optional(CONF_DATAPOINT_TYPE, default=DPTYPE_ANY): cv.one_of( - *DATAPOINT_TRIGGERS, lower=True + *DATAPOINT_TYPES, lower=True ), - }, - extra_validators=assign_declare_id, + } ), } ) @@ -124,9 +81,11 @@ async def to_code(config): for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) for conf in config.get(CONF_ON_DATAPOINT_UPDATE, []): - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], var, conf[CONF_SENSOR_DATAPOINT] - ) - await automation.build_automation( - trigger, [(DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]], "x")], conf + type_, expected, field = DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]] + forward = None + if expected is not None: + forward = [getattr(MockObj("x", ".").expect_type(expected), field)] + callback = await automation.build_trigger_callback( + [(type_, "x")], conf, params=[(TuyaDatapoint, "x")], forward=forward ) + cg.add(var.register_listener(conf[CONF_SENSOR_DATAPOINT], callback)) diff --git a/esphome/components/tuya/automation.cpp b/esphome/components/tuya/automation.cpp deleted file mode 100644 index a7eacf8e9a3..00000000000 --- a/esphome/components/tuya/automation.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "esphome/core/log.h" - -#include "automation.h" - -ESPHOME_LOG_TAG(TAG, "tuya.automation"); - -namespace esphome::tuya { - -void check_expected_datapoint(const TuyaDatapoint &dp, TuyaDatapointType expected) { - if (dp.type != expected) { - ESP_LOGW(TAG, "Tuya sensor %u expected datapoint type %#02hhX but got %#02hhX", dp.id, - static_cast(expected), static_cast(dp.type)); - } -} - -TuyaRawDatapointUpdateTrigger::TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::RAW); - this->trigger(dp.value_raw); - }); -} - -TuyaBoolDatapointUpdateTrigger::TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::BOOLEAN); - this->trigger(dp.value_bool); - }); -} - -TuyaIntDatapointUpdateTrigger::TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::INTEGER); - this->trigger(dp.value_int); - }); -} - -TuyaUIntDatapointUpdateTrigger::TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::INTEGER); - this->trigger(dp.value_uint); - }); -} - -TuyaStringDatapointUpdateTrigger::TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::STRING); - this->trigger(dp.value_string); - }); -} - -TuyaEnumDatapointUpdateTrigger::TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::ENUM); - this->trigger(dp.value_enum); - }); -} - -TuyaBitmaskDatapointUpdateTrigger::TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::BITMASK); - this->trigger(dp.value_bitmask); - }); -} - -} // namespace esphome::tuya diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h deleted file mode 100644 index 0cd63a76be7..00000000000 --- a/esphome/components/tuya/automation.h +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "tuya.h" - -#include - -namespace esphome::tuya { - -class TuyaDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); - } -}; - -class TuyaRawDatapointUpdateTrigger final : public Trigger> { - public: - explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaBoolDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaIntDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaUIntDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaStringDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaEnumDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -} // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 784aea5c50b..b23dcc946bd 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -482,7 +482,7 @@ void Tuya::handle_datapoints_(const uint8_t *buffer, size_t len) { // Run through listeners for (auto &listener : this->listeners_) { if (listener.datapoint_id == datapoint.id) - listener.on_datapoint(datapoint); + listener.on_datapoint.call(datapoint); } } } @@ -796,20 +796,27 @@ void Tuya::send_datapoint_command_(uint8_t datapoint_id, TuyaDatapointType datap this->send_command_(TuyaCommand{.cmd = TuyaCommandType::DATAPOINT_DELIVER, .payload = buffer}); } -void Tuya::register_listener(uint8_t datapoint_id, const std::function &func) { - auto listener = TuyaDatapointListener{ +void Tuya::register_listener_(uint8_t datapoint_id, Callback func) { + this->listeners_.push_back(TuyaDatapointListener{ .datapoint_id = datapoint_id, .on_datapoint = func, - }; - this->listeners_.push_back(listener); + }); // Run through existing datapoints for (auto &datapoint : this->datapoints_) { if (datapoint.id == datapoint_id) - func(datapoint); + func.call(datapoint); } } +const TuyaDatapoint &TuyaDatapoint::expect_type(TuyaDatapointType expected) const { + if (this->type != expected) { + ESP_LOGW(TAG, "Tuya sensor %u expected datapoint type %#02hhX but got %#02hhX", this->id, + static_cast(expected), static_cast(this->type)); + } + return *this; +} + TuyaInitState Tuya::get_init_state() { return this->init_state_; } } // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index b8bf4e0ab18..f074d301629 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -37,11 +37,14 @@ struct TuyaDatapoint { }; std::string value_string; std::vector value_raw; + + /// Log a warning when this datapoint is not of type `expected`, and return it. + const TuyaDatapoint &expect_type(TuyaDatapointType expected) const; }; struct TuyaDatapointListener { uint8_t datapoint_id; - std::function on_datapoint; + Callback on_datapoint; }; enum class TuyaCommandType : uint8_t { @@ -91,7 +94,9 @@ class Tuya final : public Component, public uart::UARTDevice { void setup() override; void loop() override; void dump_config() override; - void register_listener(uint8_t datapoint_id, const std::function &func); + template void register_listener(uint8_t datapoint_id, F &&func) { + this->register_listener_(datapoint_id, Callback::create(std::forward(func))); + } void set_raw_datapoint_value(uint8_t datapoint_id, const std::vector &value); void set_boolean_datapoint_value(uint8_t datapoint_id, bool value); void set_integer_datapoint_value(uint8_t datapoint_id, uint32_t value); @@ -117,6 +122,7 @@ class Tuya final : public Component, public uart::UARTDevice { } protected: + void register_listener_(uint8_t datapoint_id, Callback func); void handle_char_(uint8_t c); void handle_datapoints_(const uint8_t *buffer, size_t len); optional get_datapoint_(uint8_t datapoint_id); diff --git a/tests/components/tuya/common.yaml b/tests/components/tuya/common.yaml index f52d47e7a00..1d0fc473acc 100644 --- a/tests/components/tuya/common.yaml +++ b/tests/components/tuya/common.yaml @@ -10,7 +10,50 @@ tuya: - sensor_datapoint: 6 datapoint_type: raw then: - - logger.log: Datapoint 6 updated + - logger.log: + format: "Datapoint 6 updated, %u bytes" + args: [x.size()] + - sensor_datapoint: 1 + then: + - logger.log: + format: "Datapoint %u updated" + args: [x.id] + - sensor_datapoint: 1 + datapoint_type: bool + then: + - logger.log: + format: "Datapoint 1 is %s" + args: ['x ? "on" : "off"'] + - sensor_datapoint: 3 + datapoint_type: int + then: + - logger.log: + format: "Datapoint 3 is %d" + args: [x] + - sensor_datapoint: 3 + datapoint_type: uint + then: + - logger.log: + format: "Datapoint 3 is %u" + args: [x] + - sensor_datapoint: 7 + datapoint_type: string + then: + - logger.log: + format: "Datapoint 7 is %s" + args: [x.c_str()] + - sensor_datapoint: 8 + datapoint_type: enum + then: + - logger.log: + format: "Datapoint 8 is %u" + args: [x] + - sensor_datapoint: 9 + datapoint_type: bitmask + then: + - logger.log: + format: "Datapoint 9 is %u" + args: [x] binary_sensor: - platform: tuya From 02bfa0781961e2ff1b0a78bc2dd9ea2a8907edae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:42:55 -1000 Subject: [PATCH 67/79] [opentherm] Build before_send and before_process_response with build_callback_automation (#20450) --- esphome/components/opentherm/__init__.py | 44 ++++++++--------------- esphome/components/opentherm/automation.h | 23 ------------ tests/components/opentherm/common.yaml | 2 ++ 3 files changed, 16 insertions(+), 53 deletions(-) delete mode 100644 esphome/components/opentherm/automation.h diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 439ec032457..cfcdfe77833 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TRIGGER_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 from esphome.core import CORE from . import const, generate, schema, validate @@ -27,14 +27,16 @@ CONF_SYNC_MODE = "sync_mode" CONF_BEFORE_SEND = "before_send" CONF_BEFORE_PROCESS_RESPONSE = "before_process_response" -# Triggers -BeforeSendTrigger = generate.opentherm_ns.class_( - "BeforeSendTrigger", - automation.Trigger.template(generate.OpenthermData.operator("ref")), -) -BeforeProcessResponseTrigger = generate.opentherm_ns.class_( - "BeforeProcessResponseTrigger", - automation.Trigger.template(generate.OpenthermData.operator("ref")), +_DATA_ARGS = [(generate.OpenthermData.operator("ref"), "x")] +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_BEFORE_SEND, "add_on_before_send_callback", _DATA_ARGS + ), + automation.CallbackAutomation( + CONF_BEFORE_PROCESS_RESPONSE, + "add_on_before_process_response_callback", + _DATA_ARGS, + ), ) CONFIG_SCHEMA = cv.All( @@ -51,17 +53,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SUMMER_MODE_ACTIVE, False): cv.boolean, cv.Optional(CONF_DHW_BLOCK, False): cv.boolean, cv.Optional(CONF_SYNC_MODE, False): cv.boolean, - cv.Optional(CONF_BEFORE_SEND): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BeforeSendTrigger), - } - ), + cv.Optional(CONF_BEFORE_SEND): automation.validate_automation({}), cv.Optional(CONF_BEFORE_PROCESS_RESPONSE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - BeforeProcessResponseTrigger - ), - } + {} ), } ) @@ -132,14 +126,4 @@ async def to_code(config: dict[str, Any]) -> None: generate.define_setting_readers(const.SETTING, settings) generate.add_messages(var, settings, schema.SETTINGS) - for conf in config.get(CONF_BEFORE_SEND, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(generate.OpenthermData.operator("ref"), "x")], conf - ) - - for conf in config.get(CONF_BEFORE_PROCESS_RESPONSE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(generate.OpenthermData.operator("ref"), "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h deleted file mode 100644 index 365992b2805..00000000000 --- a/esphome/components/opentherm/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "hub.h" -#include "opentherm.h" - -namespace esphome::opentherm { - -class BeforeSendTrigger final : public Trigger { - public: - BeforeSendTrigger(OpenthermHub *hub) { - hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); - } -}; - -class BeforeProcessResponseTrigger final : public Trigger { - public: - BeforeProcessResponseTrigger(OpenthermHub *hub) { - hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::opentherm diff --git a/tests/components/opentherm/common.yaml b/tests/components/opentherm/common.yaml index fb5fb39eb84..1ae9ea4a4dd 100644 --- a/tests/components/opentherm/common.yaml +++ b/tests/components/opentherm/common.yaml @@ -25,6 +25,8 @@ opentherm: then: - lambda: |- ESP_LOGW("OT", ">> Sending message %d", x.id); + if (x.id == 0) + x.valueHB |= 0x01; before_process_response: then: - lambda: |- From 073394b1eb50e265df13ba7bc8fac5aba54daa1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:43:22 -1000 Subject: [PATCH 68/79] [core] Let a trigger filter name its parent with `{parent}` (#20448) --- esphome/automation.py | 39 +++++++++----- tests/unit_tests/test_automation.py | 83 ++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 859ec2f1f3c..6b6af4ed72e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -264,13 +264,17 @@ def string_ref_literal(config: ConfigType, value: str) -> str: return f"StringRef({literal_with_length(config, value)})" +def _names_parent(text: str) -> bool: + return any(f == "parent" for _, f, _, _ in string.Formatter().parse(text)) + + @dataclass(frozen=True) class ApplyCall: """One statement from config keys, e.g. ``"set_range({}, {})"`` with ``((CONF_LOW, cg.float_), ...)``. Each arg is ``(conf_key, type_)`` or ``(conf_key, type_, const_fn)``. A ``conf_key`` may be a - path into nested sections. A plain ``str`` ``type_`` is raw C++ type text and may use - ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an + path into nested sections. ``target`` and a plain ``str`` ``type_`` (raw C++ type text) may + name the parent object as ``{parent}``. ``const_fn(config, value)`` renders a constant's argument text; a lambda or an id bypasses it. The statement is skipped when none of its keys is set, always emitted when it has no keys, and a partial set is a config error. """ @@ -282,13 +286,13 @@ class ApplyCall: fields = [ f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None ] - if any(fields): + if any(f not in ("", "parent") for f in fields): raise ValueError( - f"apply target {self.target!r}: only bare {{}} placeholders" + f"apply target {self.target!r}: only {{}} and {{parent}} placeholders" ) - if len(fields) != len(self.args): + if (count := fields.count("")) != len(self.args): raise ValueError( - f"apply target {self.target!r} has {len(fields)} " + f"apply target {self.target!r} has {count} " f"placeholder(s) for {len(self.args)} config key(s)" ) if any(len(arg) not in (2, 3) for arg in self.args): @@ -296,6 +300,12 @@ class ApplyCall: f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" ) + @property + def names_parent(self) -> bool: + return _names_parent(self.target) or any( + isinstance(arg[1], str) and _names_parent(arg[1]) for arg in self.args + ) + @property def members(self) -> list[tuple[Any, Any, Any]]: """Each arg as ``(conf_key, type_, const_fn or None)``.""" @@ -494,7 +504,7 @@ def register_apply_action( exprs = await _render_values( name, target, members, values, config, parent, lambda_args ) - statements.append(f"{receiver}{target.format(*exprs)};") + statements.append(f"{receiver}{target.format(*exprs, parent=parent)};") if call: statements = [ f"auto apply_call = {parent}->{call}();", @@ -526,7 +536,7 @@ async def _render_check( exprs = await _render_values( name, target, members, values, config, parent, lambda_args, compare=True ) - return target.format(*exprs) + return target.format(*exprs, parent=parent) def register_apply_condition( @@ -1061,21 +1071,22 @@ async def build_trigger_callback( params: TemplateArgsType, forward: Sequence[str | Expression] | None = None, when: str | ApplyCall | None = None, + parent: MockObj | None = None, ) -> LambdaExpression: """Build the Automation for ``config`` and return a stateless callback that triggers it. ``params`` are the parent callback's parameters, ``forward`` the expressions passed to ``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``), ``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none - of its keys is set. + of its keys is set. ``when`` may name ``parent`` as ``{parent}``, e.g. + ``"{parent}->is_fully_open()"``. """ members: list[tuple[Any, Any, Any]] = [] if when is not None: call = when if isinstance(when, ApplyCall) else ApplyCall(when) members = call.members - # A trigger callback has no parent for a str type to name. - if any(isinstance(t, str) and "{parent}" in t for _, t, _ in members): - raise ValueError(f"trigger filter {call.target!r}: a type names {{parent}}") + if parent is None and call.names_parent: + raise ValueError(f"trigger filter {call.target!r} names {{parent}}") obj = await _new_automation(args, config) lambda_args = _apply_lambda_args(params) statements: list[str] = [] @@ -1088,7 +1099,7 @@ async def build_trigger_callback( members, values, config, - None, + None if parent is None else str(parent_ref(parent)), lambda_args, ) statements.append(f"if (!({check}))\n return;") @@ -1137,7 +1148,7 @@ async def build_callback_automation( "forwarder cannot be combined with params, forward or when" ) callback = await build_trigger_callback( - args, config, args if params is None else params, forward, when + args, config, args if params is None else params, forward, when, parent ) cg.add(getattr(parent, callback_method)(callback)) return diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index ddd777fdb07..3cd70c082d5 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -943,6 +943,30 @@ async def test_apply_action_call_shape( assert positions == sorted(positions) +@pytest.mark.asyncio +async def test_apply_target_names_parent( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """{parent} in a target is the global-scoped parent, also beside a call object.""" + fields = (ApplyCall("set_peer({parent}->id(), {})", (("level", cg.float_),)),) + await _run_apply_action(registries, fields, {"level": 0.5}) + assert ( + f"::{PARENT_OBJ}->set_peer(::{PARENT_OBJ}->id(), 0.5f);" + in _apply_definition(mock_cg) + ) + mock_cg.new_pvariable.reset_mock() + await _run_apply_action(registries, fields, {"level": 0.5}, call="make_call") + assert f"apply_call.set_peer(::{PARENT_OBJ}->id(), 0.5f);" in _apply_definition( + mock_cg + ) + mock_cg.new_pvariable.reset_mock() + await _run_apply_condition(registries, "is_peer({parent}->id())", {}) + assert ( + f"return ::{PARENT_OBJ}->is_peer(::{PARENT_OBJ}->id());" + in _apply_definition(mock_cg) + ) + + @pytest.mark.asyncio async def test_apply_field_nested_key_const_fn_and_type_string( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -979,8 +1003,10 @@ async def test_apply_field_nested_key_const_fn_and_type_string( def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: with pytest.raises(ValueError, match="2 placeholder"): ApplyCall("set_range({}, {})", (("low", cg.float_),)) - with pytest.raises(ValueError, match="only bare"): - ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)) + with pytest.raises(ValueError, match="only {} and {parent}"): + ApplyCall("if ({}) {other}->reset()", (("reset", cg.bool_),)) + assert ApplyCall("if ({}) {parent}->reset()", (("reset", cg.bool_),)).names_parent + assert not ApplyCall("set_flags({{parent}})").names_parent ApplyCall("set_flags({{{}}})", (("flags", cg.int_),)) with pytest.raises(ValueError, match="each arg is"): ApplyCall("set_kp({})", (("kp", cg.float_, None, "extra"),)) @@ -1242,12 +1268,57 @@ async def test_trigger_callback_reshapes_and_filters(mock_cg: MockCodegen) -> No @pytest.mark.asyncio async def test_trigger_callback_filter_has_no_parent(mock_cg: MockCodegen) -> None: - """A filter type that names {parent} is rejected up front, a trigger callback has none.""" - when = ApplyCall("mode == {}", (("mode", "{parent}::Mode"),)) - with pytest.raises(ValueError, match="names {parent}"): + """A filter that names {parent} is rejected up front when no parent is given.""" + for when in ( + ApplyCall("mode == {}", (("mode", "{parent}::Mode"),)), + "{parent}->is_failed() == false", + ): + with pytest.raises(ValueError, match="names {parent}"): + await build_trigger_callback( + [], + {**TRIGGER_CONF, "mode": 1}, + [(cg.int_, "mode")], + forward=[], + when=when, + ) + + +@pytest.mark.asyncio +async def test_trigger_callback_filter_names_parent(mock_cg: MockCodegen) -> None: + """With the parent given, {parent} in a filter is the global-scoped parent.""" + text = _squash( await build_trigger_callback( - [], {**TRIGGER_CONF, "mode": 1}, [(cg.int_, "mode")], forward=[], when=when + [], + TRIGGER_CONF, + [(cg.int_, "state")], + forward=[], + when="state == 1 && {parent}->is_failed() == false", + parent=MockObj("improv", "->"), ) + ) + assert ( + "if (!(state == 1 && ::improv->is_failed() == false)) return; " + f"::{NEW_OBJ}->trigger();" + ) in text + + +@pytest.mark.asyncio +async def test_build_callback_automations_when_names_parent( + mock_cg: MockCodegen, +) -> None: + """A table entry names the parent through {parent}, so it can live at module level.""" + entries = ( + CallbackAutomation( + "on_open", "add_on_state_callback", when="{parent}->is_fully_open()" + ), + ) + await build_callback_automations( + MockObj("valve", "->"), {"on_open": [TRIGGER_CONF]}, entries + ) + assert _squash(mock_cg.add.call_args.args[0]) == ( + "valve->add_on_state_callback([]() -> void { " + f"if (!(::valve->is_fully_open())) return; ::{NEW_OBJ}->trigger(); }})" + ) @pytest.mark.asyncio From 4d187a2bb70e832022eb93c531c29bec532003a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:43:22 -1000 Subject: [PATCH 69/79] [valve] Build on_open and on_closed with build_callback_automation (#20442) --- esphome/components/valve/__init__.py | 39 +++++++------------ esphome/components/valve/automation.h | 37 ------------------ tests/components/valve/common-triggers.yaml | 9 +++++ .../valve/test-triggers.esp8266-ard.yaml | 3 ++ 4 files changed, 25 insertions(+), 63 deletions(-) delete mode 100644 esphome/components/valve/automation.h create mode 100644 tests/components/valve/common-triggers.yaml create mode 100644 tests/components/valve/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index b000f6dd3f2..41e25d63b15 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_POSITION_STATE_TOPIC, CONF_STATE, CONF_STOP, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_GAS, @@ -62,17 +61,18 @@ VALVE_OPERATIONS = { } validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) -# Actions -ValvePublishAction = valve_ns.class_("ValvePublishAction", automation.Action) - -# Triggers -ValveOpenTrigger = valve_ns.class_("ValveOpenTrigger", automation.Trigger.template()) -ValveClosedTrigger = valve_ns.class_( - "ValveClosedTrigger", automation.Trigger.template() -) - CONF_ON_CLOSED = "on_closed" +# The state callback carries nothing; each automation fires only in its end state. +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN, "add_on_state_callback", when="{parent}->is_fully_open()" + ), + automation.CallbackAutomation( + CONF_ON_CLOSED, "add_on_state_callback", when="{parent}->is_fully_closed()" + ), +) + _VALVE_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -89,16 +89,8 @@ _VALVE_SCHEMA = ( cv.Optional(CONF_POSITION_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_OPEN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValveOpenTrigger), - } - ), - cv.Optional(CONF_ON_CLOSED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValveClosedTrigger), - } - ), + cv.Optional(CONF_ON_OPEN): automation.validate_automation({}), + cv.Optional(CONF_ON_CLOSED): automation.validate_automation({}), } ) ) @@ -134,12 +126,7 @@ def valve_schema( async def _setup_valve_core(var, config): setup_device_class(config) - for conf in config.get(CONF_ON_OPEN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_CLOSED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id_config := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id_config, var) diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h deleted file mode 100644 index 598bbc1678e..00000000000 --- a/esphome/components/valve/automation.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "valve.h" - -namespace esphome::valve { - -class ValveOpenTrigger final : public Trigger<> { - public: - ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { - a_valve->add_on_state_callback([this]() { - if (this->valve_->is_fully_open()) { - this->trigger(); - } - }); - } - - protected: - Valve *valve_; -}; - -class ValveClosedTrigger final : public Trigger<> { - public: - ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { - a_valve->add_on_state_callback([this]() { - if (this->valve_->is_fully_closed()) { - this->trigger(); - } - }); - } - - protected: - Valve *valve_; -}; - -} // namespace esphome::valve diff --git a/tests/components/valve/common-triggers.yaml b/tests/components/valve/common-triggers.yaml new file mode 100644 index 00000000000..452cc2eac89 --- /dev/null +++ b/tests/components/valve/common-triggers.yaml @@ -0,0 +1,9 @@ +valve: + - platform: template + name: Trigger Test Valve + id: trigger_test_valve + optimistic: true + on_open: + - logger.log: Valve fully open + on_closed: + - logger.log: Valve fully closed diff --git a/tests/components/valve/test-triggers.esp8266-ard.yaml b/tests/components/valve/test-triggers.esp8266-ard.yaml new file mode 100644 index 00000000000..63ca1042de9 --- /dev/null +++ b/tests/components/valve/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + valve: !include common.yaml + triggers: !include common-triggers.yaml From 8567e69d46fe043156a4f16385e6043da520d6f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:51:38 -1000 Subject: [PATCH 70/79] [core] Add status_momentary_warning/error overloads without a name (#20362) --- esphome/core/component.cpp | 28 ++++-- esphome/core/component.h | 14 +++ .../fixtures/status_momentary.yaml | 40 +++++++++ tests/integration/test_status_momentary.py | 87 +++++++++++++++++++ 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/status_momentary.yaml create mode 100644 tests/integration/test_status_momentary.py diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 0aa2dc21a24..edcd922aebd 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -292,14 +292,17 @@ void Component::status_set_warning(const LogString *message) { } void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const LogString *message) { - if (!this->set_status_flag_(STATUS_LED_ERROR)) - return; - ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), - message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); - if (message != nullptr) { + if (this->set_error_flag_(message) && message != nullptr) { store_component_error_message(this, message); } } +bool Component::set_error_flag_(const LogString *message) { + if (!this->set_status_flag_(STATUS_LED_ERROR)) + return false; + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); + return true; +} void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished @@ -329,6 +332,21 @@ void Component::status_momentary_error(const char *name, uint32_t length) { this->status_set_error(); this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } +void Component::status_momentary_warning(uint32_t length) { + this->status_momentary_warning(static_cast(nullptr), length); +} +void Component::status_momentary_warning(const LogString *message, uint32_t length) { + this->status_set_warning(message); + this->set_timeout(InternalSchedulerID::STATUS_WARNING, length, [this]() { this->status_clear_warning(); }); +} +void Component::status_momentary_error(uint32_t length) { + this->status_momentary_error(static_cast(nullptr), length); +} +void Component::status_momentary_error(const LogString *message, uint32_t length) { + // Not stored: a cleared momentary error must not show up later as a FAILED reason + this->set_error_flag_(message); + this->set_timeout(InternalSchedulerID::STATUS_ERROR, length, [this]() { this->status_clear_error(); }); +} void Component::dump_config() {} // Function implementation of LOG_UPDATE_INTERVAL macro to reduce code size diff --git a/esphome/core/component.h b/esphome/core/component.h index ecaf863ecfa..94ccec13f80 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -67,6 +67,8 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL; /// with component-level NUMERIC_ID values, even if the uint32_t values overlap. enum class InternalSchedulerID : uint32_t { POLLING_UPDATE = 0, // PollingComponent interval + STATUS_WARNING = 1, // status_momentary_warning() without a name + STATUS_ERROR = 2, // status_momentary_error() without a name }; // Forward declaration @@ -307,6 +309,11 @@ class Component { */ void status_momentary_warning(const char *name, uint32_t length = 5000); + /// Set warning status flag and clear it after `length` ms. A new call restarts the timeout. + /// `message` is logged in place of "unspecified" when the flag gets set. + void status_momentary_warning(uint32_t length = 5000); + void status_momentary_warning(const LogString *message, uint32_t length = 5000); + /** Set error status flag and automatically clear it after a timeout. * * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). @@ -316,6 +323,11 @@ class Component { */ void status_momentary_error(const char *name, uint32_t length = 5000); + /// Set error status flag and clear it after `length` ms. A new call restarts the timeout. + /// `message` is logged in place of "unspecified"; unlike status_set_error() it is not stored. + void status_momentary_error(uint32_t length = 5000); + void status_momentary_error(const LogString *message, uint32_t length = 5000); + bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; } /** Get the integration where this component was declared as a LogString for logging. @@ -356,6 +368,8 @@ class Component { /// Note: Callers often use the return value to decide whether to log a warning/error, /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); + /// Set the error flag and log it; true when it was not set before. + bool set_error_flag_(const LogString *message); /** Set an interval function with a const char* name. Empty name means no cancelling possible. * diff --git a/tests/integration/fixtures/status_momentary.yaml b/tests/integration/fixtures/status_momentary.yaml new file mode 100644 index 00000000000..424a73ce3b4 --- /dev/null +++ b/tests/integration/fixtures/status_momentary.yaml @@ -0,0 +1,40 @@ +esphome: + name: status-momentary-test + +host: +api: + actions: + - action: momentary_warning + variables: + length: int + then: + - lambda: "id(probe)->status_momentary_warning(length);" + - action: momentary_error + variables: + length: int + then: + - lambda: "id(probe)->status_momentary_error(length);" + - action: momentary_warning_message + then: + - lambda: 'id(probe)->status_momentary_warning(LOG_STR("probe warning reason"), 300);' + - action: momentary_error_message + then: + - lambda: 'id(probe)->status_momentary_error(LOG_STR("probe error reason"), 300);' + +logger: + +sensor: + - platform: template + id: probe + update_interval: 24h + lambda: return 1.0; + +binary_sensor: + - platform: template + name: Probe Warning + id: probe_warning + lambda: return id(probe)->status_has_warning(); + - platform: template + name: Probe Error + id: probe_error + lambda: return id(probe)->status_has_error(); diff --git a/tests/integration/test_status_momentary.py b/tests/integration/test_status_momentary.py new file mode 100644 index 00000000000..0bf3a820bed --- /dev/null +++ b/tests/integration/test_status_momentary.py @@ -0,0 +1,87 @@ +"""Integration test for status_momentary_warning/error without a name.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import BinarySensorState, EntityState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_status_momentary( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + loop = asyncio.get_running_loop() + warning_logged = loop.create_future() + error_logged = loop.create_future() + + def on_log_line(line: str) -> None: + if ( + "set Warning flag: probe warning reason" in line + and not warning_logged.done() + ): + warning_logged.set_result(True) + if "set Error flag: probe error reason" in line and not error_logged.done(): + error_logged.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + svc = {s.name: s for s in services} + keys = {e.object_id: e.key for e in entities} + warning_key = keys["probe_warning"] + error_key = keys["probe_error"] + + changes: asyncio.Queue[tuple[int, bool, float]] = asyncio.Queue() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState): + changes.put_nowait((state.key, state.state, loop.time())) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + assert initial_state_helper.initial_states[warning_key].state is False + assert initial_state_helper.initial_states[error_key].state is False + + async def next_change(key: int) -> tuple[bool, float]: + while True: + k, value, when = await asyncio.wait_for(changes.get(), timeout=5) + if k == key: + return value, when + + # The flag sets at once and clears after the length + for service, key in ( + ("momentary_warning", warning_key), + ("momentary_error", error_key), + ): + start = loop.time() + await client.execute_service(svc[service], {"length": 300}) + assert (await next_change(key))[0] is True + value, cleared = await next_change(key) + assert value is False + assert cleared - start >= 0.25 + + # A second call restarts the timeout instead of adding a second one + start = loop.time() + await client.execute_service(svc["momentary_warning"], {"length": 1000}) + assert (await next_change(warning_key))[0] is True + await asyncio.sleep(0.6) + await client.execute_service(svc["momentary_warning"], {"length": 1000}) + value, cleared = await next_change(warning_key) + assert value is False + assert cleared - start >= 1.4 + + # A message replaces "unspecified" in the log line + await client.execute_service(svc["momentary_warning_message"], {}) + await asyncio.wait_for(warning_logged, timeout=5) + await client.execute_service(svc["momentary_error_message"], {}) + await asyncio.wait_for(error_logged, timeout=5) From 640d677913de685169d0d4dc0def96d14dd35a3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:51:55 -1000 Subject: [PATCH 71/79] [st7735][st7789v][waveshare_epaper] Declare log tags with ESPHOME_LOG_TAG (#20295) --- esphome/components/st7735/st7735.cpp | 2 +- esphome/components/st7789v/st7789v.cpp | 2 +- esphome/components/waveshare_epaper/waveshare_213v3.cpp | 2 +- esphome/components/waveshare_epaper/waveshare_epaper.cpp | 2 +- script/ci-custom.py | 6 ------ 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/esphome/components/st7735/st7735.cpp b/esphome/components/st7735/st7735.cpp index 7eb70c5c235..94b94d17a98 100644 --- a/esphome/components/st7735/st7735.cpp +++ b/esphome/components/st7735/st7735.cpp @@ -218,7 +218,7 @@ static constexpr uint8_t PROGMEM 100 }; // 100 ms delay // clang-format on -static const char *const TAG = "st7735"; +ESPHOME_LOG_TAG(TAG, "st7735"); ST7735::ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors) diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index 2e07e24522c..343d196b507 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -4,7 +4,7 @@ namespace esphome::st7789v { -static const char *const TAG = "st7789v"; +ESPHOME_LOG_TAG(TAG, "st7789v"); #ifdef USE_ESP32 static constexpr size_t TEMP_BUFFER_SIZE = 1024; #else diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index cc9c34cb42f..5e19782f6d2 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_epaper { -static const char *const TAG = "waveshare_2.13v3"; +ESPHOME_LOG_TAG(TAG, "waveshare_2.13v3"); static const uint8_t PARTIAL_LUT[] = { 0x32, // cmd diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 93f23424c01..60d711d1cdf 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -7,7 +7,7 @@ namespace esphome::waveshare_epaper { -static const char *const TAG = "waveshare_epaper"; +ESPHOME_LOG_TAG(TAG, "waveshare_epaper"); static const uint8_t LUT_SIZE_WAVESHARE = 30; diff --git a/script/ci-custom.py b/script/ci-custom.py index 1b3abc40f2d..7839038a8c7 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1273,12 +1273,6 @@ def lint_no_std_nothrow(fname, match): r"\s*(?:=\s*\{?|[{(])\s*\"", prefilter="TAG", include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], - # Deprecated components, converted in a follow-up PR - exclude=[ - "esphome/components/st7735/*", - "esphome/components/st7789v/*", - "esphome/components/waveshare_epaper/*", - ], ) def lint_log_tag_macro(fname, match): return ( From 6b03d68fac862101393f3601b0182e43e7689ef2 Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 10 Oct 2026 09:53:38 +0800 Subject: [PATCH 72/79] [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) --- esphome/components/heatpumpir/heatpumpir.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index eb23eb8ed50..2376a67416f 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd = HDIR_SWING; } + // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, + // so low/medium/high have to start at FAN_1 there instead of FAN_2. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: - fan_speed_cmd = FAN_2; + fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; break; case climate::CLIMATE_FAN_MEDIUM: - fan_speed_cmd = FAN_3; + fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3; break; case climate::CLIMATE_FAN_HIGH: - fan_speed_cmd = FAN_4; + fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4; break; case climate::CLIMATE_FAN_AUTO: default: From 41cc96bbb8cd83368093d0af89499bf4ae95d071 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 15:55:09 -1000 Subject: [PATCH 73/79] [light] Keep the effect list in a flash table (#20204) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/codegen.py | 1 + esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 11 +---- esphome/components/api/api_pb2.cpp | 8 +-- esphome/components/api/api_pb2.h | 2 +- esphome/components/api/api_pb2_includes.h | 1 + esphome/components/light/__init__.py | 16 +++++- esphome/components/light/light_call.cpp | 2 +- esphome/components/light/light_effect.h | 2 + esphome/components/light/light_effect_names.h | 44 +++++++++++++++++ esphome/components/light/light_state.cpp | 12 ----- esphome/components/light/light_state.h | 11 +++-- esphome/core/hal.h | 8 +++ esphome/cpp_generator.py | 49 ++++++++++++++++--- .../components/api/bench_list_entities.cpp | 18 +++++-- .../light/test_default_setters.py | 26 +++++++++- tests/components/light/common.yaml | 3 +- tests/components/light/test_light_state.cpp | 29 ----------- tests/unit_tests/test_cpp_generator.py | 42 +++++++++++++++- 19 files changed, 207 insertions(+), 80 deletions(-) create mode 100644 esphome/components/light/light_effect_names.h diff --git a/esphome/codegen.py b/esphome/codegen.py index d57ec39aab7..4db01a21740 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -34,6 +34,7 @@ from esphome.cpp_generator import ( # noqa: F401 extern_progmem_array, get_variable, get_variable_with_full_id, + is_static_pointer, is_template, new_Pvariable, new_variable, diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 76b82810a4b..d4ae82fd9f1 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -760,7 +760,7 @@ message ListEntitiesLightResponse { bool legacy_supports_color_temperature = 8 [deprecated=true]; float min_mireds = 9; float max_mireds = 10; - repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"]; + repeated string effects = 11 [(container_pointer_no_template) = "light::LightEffectNames"]; bool disabled_by_default = 13; string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 15; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 940c8847edd..fd51aae1be8 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -644,16 +644,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c msg.min_mireds = traits.get_min_mireds(); msg.max_mireds = traits.get_max_mireds(); } - FixedVector effects_list; - if (light->supports_effects()) { - auto &light_effects = light->get_effects(); - effects_list.init(light_effects.size() + 1); - effects_list.push_back("None"); - for (auto *effect : light_effects) { - // c_str() is safe as effect names are null-terminated strings from codegen - effects_list.push_back(effect->get_name().c_str()); - } - } + light::LightEffectNames effects_list(light->get_effects()); msg.effects = &effects_list; return fill_and_encode_entity_info(light, msg, conn, remaining_size); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b59663aeb51..8847249b173 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -688,8 +688,8 @@ uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__rest if (uint32_t raw = float_to_raw(msg.max_mireds); raw != 0) [[likely]] { pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - for (const char *it : *msg.effects) { - pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it, strlen(it)); + for (const auto &it : *msg.effects) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, it); } pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.disabled_by_default); #ifdef USE_ENTITY_ICON @@ -713,8 +713,8 @@ uint32_t ListEntitiesLightResponse::calc_size_msg(const void *self) { size += ProtoSize::calc_float(1, msg.min_mireds); size += ProtoSize::calc_float(1, msg.max_mireds); if (!msg.effects->empty()) { - for (const char *it : *msg.effects) { - size += ProtoSize::calc_length_force(1, strlen(it)); + for (const auto &it : *msg.effects) { + size += ProtoSize::calc_length_force(1, it.size()); } } size += ProtoSize::calc_bool(1, msg.disabled_by_default); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421c3fccb38..17436d33aab 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1059,7 +1059,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { const light::ColorModeMask *supported_color_modes{}; float min_mireds{0.0f}; float max_mireds{0.0f}; - const FixedVector *effects{}; + const light::LightEffectNames *effects{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 833e6529e2a..e076f98fe3d 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -15,6 +15,7 @@ #endif #ifdef USE_LIGHT +#include "esphome/components/light/light_effect_names.h" #include "esphome/components/light/light_traits.h" #endif diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index b175620bbf4..c490300b58c 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -41,7 +41,7 @@ from esphome.core.entity_helpers import ( queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -71,6 +71,7 @@ from .types import ( # noqa: F401 ChannelColors, ColorMode, GammaTable, + LightEffect, LightOutput, LightState, LightStateRTCState, @@ -519,6 +520,17 @@ def validate_color_temperature_channels(value): return value +def _effects_table(effects: list[MockObj]) -> MockObj: + """Emit the flash table of a light's effect pointers; each must be a static address.""" + return cg.shared_progmem_array( + "light_effects", + LightEffect.operator("ptr"), + cg.ArrayInitializer(*effects), + share=False, + constexpr=False, + ) + + @setup_entity("light") async def setup_light_core_(light_var, config, output_var): # All 8 legacy restore_mode values, and the restore_state key, are just different @@ -586,7 +598,7 @@ async def setup_light_core_(light_var, config, output_var): EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) if effects: - cg.add(light_var.add_effects(effects)) + cg.add(light_var.add_effects(_effects_table(effects), len(effects))) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 5ad9215eedf..0970c513a29 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -147,7 +147,7 @@ void LightCall::perform() { // EFFECT StringRef effect_s; if (this->effect_ == 0u) { - effect_s = StringRef::from_lit("None"); + effect_s = EFFECT_NONE_REF; } else { effect_s = this->parent_->effects_[this->effect_ - 1]->get_name(); } diff --git a/esphome/components/light/light_effect.h b/esphome/components/light/light_effect.h index a89e3fec5a9..3fabcf5d51b 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -5,6 +5,8 @@ namespace esphome::light { +inline constexpr StringRef EFFECT_NONE_REF = StringRef::from_lit("None"); + class LightState; class LightEffect { diff --git a/esphome/components/light/light_effect_names.h b/esphome/components/light/light_effect_names.h new file mode 100644 index 00000000000..7ddf459b248 --- /dev/null +++ b/esphome/components/light/light_effect_names.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "light_effect.h" + +namespace esphome::light { + +/// Effect names as the native API lists them: "None" first, then each effect; empty without effects. +/// Reads the light's effect table directly, so listing entities allocates nothing. Holds a reference: +/// it only lives for one synchronous encode, and by value costs flash on every copy. +class LightEffectNames { + public: + explicit LightEffectNames(const ConstVector &effects) : effects_(effects) {} + + class Iterator { + public: + ESPHOME_ALWAYS_INLINE Iterator(const ConstVector &effects, size_t index) + : effects_(effects), index_(index) {} + ESPHOME_ALWAYS_INLINE StringRef operator*() const { + return this->index_ == 0 ? EFFECT_NONE_REF : this->effects_[this->index_ - 1]->get_name(); + } + ESPHOME_ALWAYS_INLINE Iterator &operator++() { + ++this->index_; + return *this; + } + ESPHOME_ALWAYS_INLINE bool operator!=(const Iterator &other) const { return this->index_ != other.index_; } + + protected: + const ConstVector &effects_; + size_t index_; + }; + + ESPHOME_ALWAYS_INLINE Iterator begin() const { return {this->effects_, 0}; } + ESPHOME_ALWAYS_INLINE Iterator end() const { + return {this->effects_, this->effects_.empty() ? 0 : this->effects_.size() + 1}; + } + ESPHOME_ALWAYS_INLINE bool empty() const { return this->effects_.empty(); } + + protected: + const ConstVector &effects_; +}; + +} // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9fe483a291c..885440187f9 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -9,8 +9,6 @@ #include "light_output.h" #include "transformers.h" -#include - namespace esphome::light { ESPHOME_LOG_TAG(TAG, "light"); @@ -196,8 +194,6 @@ void LightState::publish_state() { LightOutput *LightState::get_output() const { return this->output_; } -static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); - StringRef LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); @@ -218,11 +214,6 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::add_effects(const std::initializer_list &effects) { - // Called once from Python codegen during setup with all effects from YAML config - this->effects_ = effects; -} - void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -352,9 +343,6 @@ float LightState::gamma_uncorrect_lut(float value) const { #endif // USE_LIGHT_GAMMA_LUT void LightState::start_effect_(uint32_t effect_index) { - // An external add_effects() can exceed the codegen cap; ignore an index the uint16_t can't hold - if (effect_index > std::numeric_limits::max()) - return; this->stop_effect_(); if (effect_index == 0) return; diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index fcb2ca40c90..2a99943e1df 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -217,10 +217,13 @@ class LightState : public EntityBase, public Component { bool supports_effects() const { return !this->effects_.empty(); } /// Get all effects for this light state. - const FixedVector &get_effects() const { return this->effects_; } + const ConstVector &get_effects() const { return this->effects_; } - /// Add effects for this light state. - void add_effects(const std::initializer_list &effects); + /// Codegen only: point at the generated table of this light's effects, which must outlive it. + /// Codegen caps the count at 65535, so an effect index always fits the uint16_t active index. + void add_effects(LightEffect *const *effects, size_t count) { + this->effects_ = ConstVector(effects, count); + } /// Get the total number of effects available for this light. size_t get_effect_count() const { return this->effects_.size(); } @@ -354,7 +357,7 @@ class LightState : public EntityBase, public Component { /// The currently active transformer for this light (transition/flash). std::unique_ptr transformer_{nullptr}; /// List of effects for this light. - FixedVector effects_; + ConstVector effects_; /// Object used to store the persisted values of the light. ESPPreferenceObject rtc_; diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4c5a19c6d1b..dd3618f6237 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -4,9 +4,17 @@ #include #include "gpio.h" #include "esphome/core/defines.h" + #include "esphome/core/time_64.h" #include "esphome/core/time_conversion.h" +/// Address tables in ESP8266 flash must be constant-initialized: a runtime initializer would write to flash. +#if defined(USE_ESP8266) && defined(__GNUC__) && !defined(__clang__) +#define ESPHOME_FLASH_CONSTINIT constinit +#else +#define ESPHOME_FLASH_CONSTINIT +#endif + // Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), // inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem // helpers) live next to each platform component as components//hal.h diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index f5113d8098e..9c9fed66012 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -10,6 +10,7 @@ from esphome.core import ( ID, Define, EnumValue, + EsphomeError, HexInt, Lambda, Library, @@ -447,13 +448,16 @@ class LineComment(Statement): class ProgmemAssignmentExpression(AssignmentExpression): - __slots__ = () + __slots__ = ("constexpr",) - def __init__(self, type_, name, rhs): + def __init__(self, type_, name, rhs, constexpr: bool = True): super().__init__(type_, "", name, rhs) + self.constexpr = constexpr def __str__(self): - return f"static constexpr {self.type} {self.name}[] PROGMEM = {self.rhs}" + if self.constexpr: + return f"static constexpr {self.type} {self.name}[] PROGMEM = {self.rhs}" + return f"ESPHOME_FLASH_CONSTINIT static {self.type} const {self.name}[] PROGMEM = {self.rhs}" class StaticConstAssignmentExpression(AssignmentExpression): @@ -493,20 +497,34 @@ def extern_progmem_array( def shared_progmem_array( - name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True + name: str, + type_: "MockObjClass", + rhs: SafeExpType, + *, + share: bool = True, + constexpr: bool = True, ) -> "MockObj": """Emit a global PROGMEM array once per distinct type and contents; later calls reuse it. - The array is ``static constexpr``, so elements must be constant expressions and lambdas - must be captureless. Its name is made unique against every config id and variable. + The array is ``static constexpr`` by default, so elements must be constant expressions and + lambdas must be captureless. Its name is made unique against every config id and variable. ``share=False`` always emits a new array, e.g. for lambdas that may keep static state. + ``constexpr=False`` is for tables of generated object pointers: each top level variable + element must pass ``is_static_pointer`` or ``EsphomeError`` is raised. """ from esphome.config import iter_ids from esphome.config_validation import RESERVED_IDS arrays: dict[str, MockObj] = CORE.data.setdefault("shared_progmem_array", {}) rhs = safe_exp(rhs) - key = f"{type_} {rhs}" + if not constexpr and isinstance(rhs, ArrayInitializer): + for arg in rhs.args: + if isinstance(arg, MockObj) and not is_static_pointer(arg): + raise EsphomeError( + f"'{arg}' must be created with cg.new_Pvariable so its address is known " + "at compile time" + ) + key = f"{type_} {rhs} {constexpr}" if share and (array := arrays.get(key)) is not None: return array used = {str(i) for i, _ in iter_ids(CORE.config)} @@ -514,7 +532,7 @@ def shared_progmem_array( used |= set(RESERVED_IDS) | CORE.loaded_integrations id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) # Global, so any scope can use it; anything a lambda references is already declared. - CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs)) + CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs, constexpr)) array = MockObj(id_, ".") CORE.register_variable(id_, array) if share: @@ -703,6 +721,7 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": ) placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) + CORE.data.setdefault(_STATIC_POINTER_IDS, set()).add(id_.id) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) CORE.add_global(decl) @@ -712,6 +731,20 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": return obj +_STATIC_POINTER_IDS = "static_pointer_ids" + + +def is_static_pointer(obj: SafeExpType) -> bool: + """True if ``obj`` is a Pvariable whose object was placement constructed in static storage. + + Its pointer is then an address known at compile time and may appear in a ``constexpr=False`` + PROGMEM table; a pointer assigned in ``setup()`` may not. + """ + return isinstance(obj, MockObj) and str(obj.base) in CORE.data.get( + _STATIC_POINTER_IDS, () + ) + + def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": """Declare a new pointer variable in the code generation by calling it's constructor with the given arguments. diff --git a/tests/benchmarks/components/api/bench_list_entities.cpp b/tests/benchmarks/components/api/bench_list_entities.cpp index 4c445c2bb68..8a4cac1e2ef 100644 --- a/tests/benchmarks/components/api/bench_list_entities.cpp +++ b/tests/benchmarks/components/api/bench_list_entities.cpp @@ -3,6 +3,8 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" #include "esphome/components/light/color_mode.h" +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_effect_names.h" namespace esphome::api::benchmarks { @@ -150,7 +152,17 @@ BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse); // --- ListEntitiesLightResponse --- static light::ColorModeMask light_color_modes; -static FixedVector light_effects; + +class BenchEffect : public light::LightEffect { + public: + explicit BenchEffect(const char *name) : LightEffect(name) {} + void apply() override {} +}; +static BenchEffect rainbow_effect("Rainbow"); +static BenchEffect strobe_effect("Strobe"); +static light::LightEffect *const LIGHT_EFFECT_TABLE[] = {&rainbow_effect, &strobe_effect}; +static const ConstVector light_effect_list(LIGHT_EFFECT_TABLE, 2); +static const light::LightEffectNames light_effects(light_effect_list); static ListEntitiesLightResponse make_light_response() { // Initialize static data on first call @@ -158,10 +170,6 @@ static ListEntitiesLightResponse make_light_response() { if (!initialized) { light_color_modes.insert(light::ColorMode::RGB_WHITE); light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE); - light_effects.init(3); - light_effects.push_back("None"); - light_effects.push_back("Rainbow"); - light_effects.push_back("Strobe"); initialized = true; } diff --git a/tests/component_tests/light/test_default_setters.py b/tests/component_tests/light/test_default_setters.py index a4fc24a7cbf..cbbecb632f8 100644 --- a/tests/component_tests/light/test_default_setters.py +++ b/tests/component_tests/light/test_default_setters.py @@ -2,6 +2,14 @@ from collections.abc import Callable from pathlib import Path +import re + +import pytest + +import esphome.codegen as cg +from esphome.components import light +from esphome.components.light import types as light_types +from esphome.core import ID, EsphomeError def test_default_flash_length_and_empty_effects_are_not_emitted( @@ -16,4 +24,20 @@ def test_default_flash_length_and_empty_effects_are_not_emitted( assert "bare_light->set_flash_transition_length(" not in main_cpp assert "bare_light->add_effects(" not in main_cpp assert "fancy_light->set_flash_transition_length(500);" in main_cpp - assert "fancy_light->add_effects({" in main_cpp + call = re.search(r"fancy_light->add_effects\((\w+), (\d+)\);", main_cpp) + assert call is not None + # The effect pointers form a flash table; they are address constants, not constexpr + assert re.search( + rf"static light::LightEffect \* const {call.group(1)}\[\] PROGMEM = \{{[^}}]+\}};", + main_cpp, + ) + + +def test_effect_assigned_in_setup_is_rejected() -> None: + """A pointer assigned in setup() would make the flash table need dynamic init.""" + dynamic_effect = cg.Pvariable( + ID("dynamic_effect", is_declaration=True, type=light_types.LightEffect), + cg.RawExpression("make_effect()"), + ) + with pytest.raises(EsphomeError, match="dynamic_effect"): + light._effects_table([dynamic_effect]) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 64406afda86..c1e58c673f6 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -32,6 +32,7 @@ esphome: - lambda: |- const auto &effects = id(test_monochromatic_light).get_effects(); auto &same = id(test_monochromatic_light).get_effects(); + const auto copy = id(test_monochromatic_light).get_effects(); // the view is copyable uint32_t total = effects.size(); for (auto *effect : effects) { ESP_LOGD("test", "Effect %s", effect->get_name().c_str()); @@ -39,7 +40,7 @@ esphome: if (total > 0) { ESP_LOGD("test", "First %s", effects.at(0)->get_name().c_str()); // Raw pointer iterators on purpose: external code binds std::find's result to `const auto *` - auto *it = std::find(same.begin(), same.end(), effects[0]); + auto *it = std::find(same.begin(), same.end(), copy[0]); ESP_LOGD("test", "Index %d", (int) (it - same.begin())); } diff --git a/tests/components/light/test_light_state.cpp b/tests/components/light/test_light_state.cpp index 85bfbd09ef5..076438c7d2c 100644 --- a/tests/components/light/test_light_state.cpp +++ b/tests/components/light/test_light_state.cpp @@ -1,6 +1,5 @@ #include -#include "esphome/components/light/light_effect.h" #include "esphome/components/light/light_output.h" #include "esphome/components/light/light_state.h" @@ -18,36 +17,8 @@ class BrightnessOutput : public LightOutput { void write_state(LightState *state) override {} }; -class NoopEffect : public LightEffect { - public: - using LightEffect::LightEffect; - void apply() override {} -}; - -// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t. -class TestableLightState : public LightState { - public: - using LightState::LightState; - using LightState::start_effect_; -}; - } // namespace -// add_effects() is public, so an external component can exceed the codegen cap on effect count; -// an index the uint16_t can't hold must be ignored rather than wrap onto another effect. -TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) { - BrightnessOutput output; - TestableLightState state(&output); - NoopEffect effect("Noop"); - state.add_effects({&effect}); - - state.start_effect_(1); - ASSERT_EQ(state.get_current_effect_index(), 1u); - - state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect - EXPECT_EQ(state.get_current_effect_index(), 1u); -} - // get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals. TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) { static constexpr GammaTable TABLE{{}, 280}; diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index b6f8fd1ec10..6fdd197c952 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,7 +4,7 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct -from esphome.core import CORE, ID +from esphome.core import CORE, ID, EsphomeError class TestExpressions: @@ -829,6 +829,46 @@ class TestSharedProgmemArray: assert str(a) != str(b) assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2 + def test_is_static_pointer_only_for_placement_new(self) -> None: + CORE.config = {} + static = cg.new_Pvariable(ID("static_obj", is_declaration=True, type=ct.uint8)) + dynamic = cg.Pvariable( + ID("dynamic_obj", is_declaration=True, type=ct.uint8), + cg.RawExpression("nullptr"), + ) + assert cg.is_static_pointer(static) + assert not cg.is_static_pointer(dynamic) + + def test_constexpr_and_const_arrays_are_not_shared(self) -> None: + CORE.config = {} + a = cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False) + b = cg.shared_progmem_array("table", ct.uint8, [1]) + assert str(a) != str(b) + + def test_constexpr_false_rejects_dynamic_pointers(self) -> None: + CORE.config = {} + dynamic = cg.Pvariable( + ID("dynamic_obj", is_declaration=True, type=ct.uint8), + cg.RawExpression("nullptr"), + ) + with pytest.raises(EsphomeError, match="dynamic_obj"): + cg.shared_progmem_array( + "table", + ct.uint8.operator("ptr"), + cg.ArrayInitializer(dynamic), + constexpr=False, + ) + + def test_constexpr_false_emits_a_const_array(self) -> None: + CORE.config = {} + cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False) + (statement,) = ( + str(st) for st in CORE.global_statements if "PROGMEM" in str(st) + ) + assert statement.startswith( + "ESPHOME_FLASH_CONSTINIT static uint8_t const table[] PROGMEM = {1}" + ) + def test_same_contents_different_type_are_separate(self) -> None: CORE.config = {} a = cg.shared_progmem_array("table", ct.uint8, [1]) From 0ae24f75368ba90e2528b4e29d6a548644edcfae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:00:41 -1000 Subject: [PATCH 74/79] [core] Catch heap allocating helper calls with explicit template arguments (#20391) --- esphome/components/debug/debug_zephyr.cpp | 18 +++++++++--------- script/ci-custom.py | 3 ++- tests/script/test_ci_custom.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index cec2ab0be8b..f3a5f3d770d 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -1,5 +1,6 @@ #include "debug_component.h" #ifdef USE_ZEPHYR +#include #include #include "esphome/core/alloc_helpers.h" #include "esphome/core/log.h" @@ -400,18 +401,17 @@ size_t DebugComponent::get_device_info_(std::span #endif } #endif - auto uicr = [](volatile uint32_t *data, uint8_t size) { - std::string res; + // 13 words of 8 hex digits, separated by spaces + char uicr_buf[13 * 9]; + auto uicr = [&uicr_buf](volatile uint32_t *data, uint8_t size) { + size_t len = 0; for (size_t i = 0; i < size; i++) { - if (i > 0) { - res += ' '; - } - res += format_hex_pretty(data[i], '\0', false); + len = buf_append_printf(uicr_buf, sizeof(uicr_buf), len, i > 0 ? " %08" PRIX32 : "%08" PRIX32, data[i]); } - return res; + return uicr_buf; }; - ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13).c_str()); - ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12).c_str()); + ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13)); + ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12)); #ifdef ESPHOME_LOG_HAS_VERBOSE log_peripherals_info(); #endif diff --git a/script/ci-custom.py b/script/ci-custom.py index 7839038a8c7..8361387af01 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1065,7 +1065,8 @@ HEAP_ALLOCATING_HELPERS = { r"str_sprintf|" r"str_snprintf|" r"value_accuracy_to_string" - r")\s*\(" + CPP_RE_EOL, + # Explicit template arguments at any nesting depth, e.g. format_hex>( + r")\s*(?:<[^;{}()\n]*>\s*)?\(" + CPP_RE_EOL, include=cpp_include, exclude=[ # The definitions themselves diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 998650079e2..f303d19bb0d 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -444,6 +444,28 @@ def test_log_tag_as_string_ignores_logging(line: str) -> None: assert not ci_custom.lint_log_tag_as_string("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("content", "flagged"), + [ + ("auto s = format_hex_pretty(data, len);\n", True), + # Explicit template arguments must not hide the call + ("auto s = format_hex_pretty(v, '\\0', false);\n", True), + ("auto s = format_hex>(arr);\n", True), + ( + "auto s = format_hex, uint64_t>>(v);\n", + True, + ), + ("auto s = format_hex < 3;\n", False), + ("format_hex_pretty_to(buf, sizeof(buf), data, len);\n", False), + ], +) +def test_heap_allocating_helpers_detection(content: str, flagged: bool) -> None: + errs = ci_custom.lint_no_heap_allocating_helpers( + Path("esphome/components/x/x.cpp"), content + ) + assert bool(errs) is flagged + + # --- rule: every component __init__.py defines DOMAIN as its own name --- From f7b9646705170ecdffb429a550948f24835513a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:06:14 -1000 Subject: [PATCH 75/79] [improv_ble] Build the state triggers with build_callback_automation (#20444) --- esphome/components/improv_ble/__init__.py | 117 ++++++------------ esphome/components/improv_ble/automation.h | 86 ------------- .../improv_ble/test_improv_ble.py | 18 +-- .../improv_ble/test-triggers.esp32-idf.yaml | 21 ++++ 4 files changed, 68 insertions(+), 174 deletions(-) delete mode 100644 esphome/components/improv_ble/automation.h create mode 100644 tests/components/improv_ble/test-triggers.esp32-idf.yaml diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 06e73803f19..9dfc700f748 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -2,13 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_ON_START, - CONF_ON_STATE, - CONF_TRIGGER_ID, - PLATFORM_ESP32, -) +from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, PLATFORM_ESP32 from esphome.core import CORE from esphome.types import ConfigType @@ -59,20 +53,35 @@ State = improv_ns.enum("State") improv_ble_ns = cg.esphome_ns.namespace("improv_ble") ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) -ImprovBLEProvisionedTrigger = improv_ble_ns.class_( - "ImprovBLEProvisionedTrigger", automation.Trigger.template() -) -ImprovBLEProvisioningTrigger = improv_ble_ns.class_( - "ImprovBLEProvisioningTrigger", automation.Trigger.template() -) -ImprovBLEStartTrigger = improv_ble_ns.class_( - "ImprovBLEStartTrigger", automation.Trigger.template() -) -ImprovBLEStateTrigger = improv_ble_ns.class_( - "ImprovBLEStateTrigger", automation.Trigger.template() -) -ImprovBLEStoppedTrigger = improv_ble_ns.class_( - "ImprovBLEStoppedTrigger", automation.Trigger.template() + +_STATE_PARAMS = [(State, "state"), (Error, "error")] +_NOT_FAILED = "!{parent}->is_failed()" + + +def _state_automation( + conf_key: str, args: list, states: str | None +) -> automation.CallbackAutomation: + # Every automation hangs off the state callback and is silent while the component has failed. + return automation.CallbackAutomation( + conf_key, + "add_on_state_callback", + args, + params=_STATE_PARAMS, + forward=[name for _, name in args], + when=_NOT_FAILED if states is None else f"({states}) && {_NOT_FAILED}", + ) + + +_CALLBACK_AUTOMATIONS = ( + _state_automation(CONF_ON_PROVISIONED, [], "state == improv::STATE_PROVISIONED"), + _state_automation(CONF_ON_PROVISIONING, [], "state == improv::STATE_PROVISIONING"), + _state_automation( + CONF_ON_START, + [], + "state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION", + ), + _state_automation(CONF_ON_STATE, _STATE_PARAMS, None), + _state_automation(CONF_ON_STOP, [], "state == improv::STATE_STOPPED"), ) @@ -93,41 +102,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEProvisionedTrigger - ), - } - ), - cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEProvisioningTrigger - ), - } - ), - cv.Optional(CONF_ON_START): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStartTrigger - ), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStateTrigger - ), - } - ), - cv.Optional(CONF_ON_STOP): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStoppedTrigger - ), - } - ), + cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation({}), + cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation({}), + cv.Optional(CONF_ON_START): automation.validate_automation({}), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_STOP): automation.validate_automation({}), } ) .extend(improv_base.IMPROV_SCHEMA) @@ -164,28 +143,6 @@ async def to_code(config: ConfigType) -> None: status_indicator = await cg.get_variable(config[CONF_STATUS_INDICATOR]) cg.add(var.set_status_indicator(status_indicator)) - use_state_callback = False - for conf in config.get(CONF_ON_PROVISIONED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_PROVISIONING, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_START, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(State, "state"), (Error, "error")], conf - ) - use_state_callback = True - for conf in config.get(CONF_ON_STOP, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - if use_state_callback: + if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/improv_ble/automation.h b/esphome/components/improv_ble/automation.h deleted file mode 100644 index 223a1292384..00000000000 --- a/esphome/components/improv_ble/automation.h +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once -#ifdef USE_ESP32 -#ifdef USE_IMPROV_BLE_STATE_CALLBACK -#include "improv_ble_component.h" - -#include "esphome/core/automation.h" - -#include - -namespace esphome::improv_ble { - -class ImprovBLEProvisionedTrigger final : public Trigger<> { - public: - explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEProvisioningTrigger final : public Trigger<> { - public: - explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStartTrigger final : public Trigger<> { - public: - explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && - !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStateTrigger final : public Trigger { - public: - explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (!this->parent_->is_failed()) { - this->trigger(state, error); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStoppedTrigger final : public Trigger<> { - public: - explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { - parent->add_on_state_callback([this](improv::State state, improv::Error error) { - if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -} // namespace esphome::improv_ble - -#endif -#endif diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py index 02293bdb23b..d7df3869dc3 100644 --- a/tests/component_tests/improv_ble/test_improv_ble.py +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -42,18 +42,20 @@ def test_platform_without_ble_server_rejected( assert "only available on" in capsys.readouterr().out -def test_automations_emit_renamed_triggers( +def test_automations_register_state_callbacks( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("automations.yaml")) - for trigger in ( - "ImprovBLEProvisionedTrigger", - "ImprovBLEProvisioningTrigger", - "ImprovBLEStartTrigger", - "ImprovBLEStateTrigger", - "ImprovBLEStoppedTrigger", + assert "Trigger" not in main_cpp + for state in ( + "improv::STATE_PROVISIONED", + "improv::STATE_PROVISIONING", + "improv::STATE_AUTHORIZED", + "improv::STATE_STOPPED", ): - assert f"improv_ble::{trigger}" in main_cpp + assert f"state == {state}" in main_cpp + assert main_cpp.count("add_on_state_callback(") == 5 + assert "->is_failed()" in main_cpp assert "set_authorizer" in main_cpp assert "set_status_indicator" in main_cpp diff --git a/tests/components/improv_ble/test-triggers.esp32-idf.yaml b/tests/components/improv_ble/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..ceeb515ef35 --- /dev/null +++ b/tests/components/improv_ble/test-triggers.esp32-idf.yaml @@ -0,0 +1,21 @@ +wifi: + ssid: MySSID + password: password1 + +binary_sensor: + - platform: gpio + pin: 0 + id: improv_trigger_authorizer + +improv_ble: + authorizer: improv_trigger_authorizer + on_start: + - logger.log: Improv started + on_provisioning: + - logger.log: Provisioning + on_provisioned: + - logger.log: Provisioned + on_stop: + - logger.log: Improv stopped + on_state: + - lambda: ESP_LOGD("improv", "State %d error %d", (int) state, (int) error); From dc553aae55c191d1b82265bda2f8de9cb4641c32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:27:19 -1000 Subject: [PATCH 76/79] [core] Use status_momentary_warning/error without a name in components (#20363) --- esphome/components/cs5460a/cs5460a.cpp | 24 +++++++++---------- esphome/components/debug/debug_component.cpp | 2 +- .../demo/demo_alarm_control_panel.h | 4 ++-- esphome/components/es8388/es8388.cpp | 4 ++-- .../components/esphome/ota/ota_esphome.cpp | 2 +- .../components/espnow/espnow_component.cpp | 10 ++++---- .../http_request/http_request_arduino.cpp | 8 +++---- .../http_request/http_request_host.cpp | 6 ++--- .../http_request/http_request_idf.cpp | 16 ++++++------- .../microphone/i2s_audio_microphone.cpp | 4 ++-- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 4 ++-- .../micro_wake_word/micro_wake_word.cpp | 4 ++-- .../mixer/speaker/mixer_speaker.cpp | 2 +- esphome/components/mqtt/mqtt_button.cpp | 2 +- esphome/components/mqtt/mqtt_client.cpp | 6 ++--- esphome/components/mqtt/mqtt_fan.cpp | 10 ++++---- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- .../components/sound_level/sound_level.cpp | 4 ++-- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 2 +- 21 files changed, 60 insertions(+), 60 deletions(-) diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index efc0cc9fa1f..7c4e4847911 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -148,7 +148,7 @@ void CS5460AComponent::schedule_next_check_() { */ if (time_left > -15000) { time_left = 1000; - this->status_momentary_warning("warning", 1000); + this->status_momentary_warning(1000); } else { ESP_LOGCONFIG(TAG, "Device officially stuck, resetting"); this->cancel_timeout(STATUS_CHECK_TIMEOUT_ID); @@ -179,67 +179,67 @@ bool CS5460AComponent::check_status_() { if (status & (1 << 2)) { clear |= 1 << 2; ESP_LOGE(TAG, "Low supply detected"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 3)) { clear |= 1 << 3; ESP_LOGE(TAG, "Modulator oscillation on current channel"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 4)) { clear |= 1 << 4; ESP_LOGE(TAG, "Modulator oscillation on voltage channel"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 5)) { clear |= 1 << 5; ESP_LOGE(TAG, "Watch-dog timeout"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 11)) { clear |= 1 << 11; ESP_LOGE(TAG, "EOUT Energy Accumulation Register out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 12)) { clear |= 1 << 12; ESP_LOGE(TAG, "Energy out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 13)) { clear |= 1 << 13; ESP_LOGE(TAG, "RMS voltage out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 14)) { clear |= 1 << 14; ESP_LOGE(TAG, "RMS current out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 15)) { clear |= 1 << 15; ESP_LOGE(TAG, "Power calculation out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 16)) { clear |= 1 << 16; ESP_LOGE(TAG, "Voltage out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 17)) { clear |= 1 << 17; ESP_LOGE(TAG, "Current out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 19)) { diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 9c7622d1161..97f8550c5bc 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -58,7 +58,7 @@ void DebugComponent::loop() { if (new_free_heap < this->free_heap_ / 2) { this->free_heap_ = new_free_heap; ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); - this->status_momentary_warning("heap", 1000); + this->status_momentary_warning(1000); } #ifdef USE_SENSOR diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index e85d2a17ba6..79cfc8f8ca4 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -33,7 +33,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm()) { if (!code.has_value() || *code != "1234") { - this->status_momentary_error("invalid_code", 5000); + this->status_momentary_error(5000); return; } } @@ -42,7 +42,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { case ACP_STATE_DISARMED: if (this->get_requires_code()) { if (!code.has_value() || *code != "1234") { - this->status_momentary_error("invalid_code", 5000); + this->status_momentary_error(5000); return; } } diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 040fd7fe260..b57949ccd0a 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -231,7 +231,7 @@ bool ES8388::set_dac_output(DacOutputLine line) { optional ES8388::get_dac_power() { uint8_t dac_power; if (!this->read_byte(ES8388_DACPOWER, &dac_power)) { - this->status_momentary_warning("dacpower_read"); + this->status_momentary_warning(); return {}; } switch (dac_power) { @@ -274,7 +274,7 @@ bool ES8388::set_adc_input_mic(AdcInputMicLine line) { optional ES8388::get_mic_input() { uint8_t mic_input; if (!this->read_byte(ES8388_ADCCONTROL2, &mic_input)) { - this->status_momentary_warning("adccontrol2_read"); + this->status_momentary_warning(); return {}; } switch (mic_input) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 318e1e5282e..e6b19c42f0c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -635,7 +635,7 @@ error: this->cleanup_connection_(); - this->status_momentary_error("err", 5000); + this->status_momentary_error(5000); #ifdef USE_OTA_STATE_LISTENER this->notify_state_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 3fd468764af..4d888a9f373 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -406,7 +406,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl if (packet == nullptr) { this->send_packet_queue_.increment_dropped_count(); ESP_LOGE(TAG, "Failed to allocate send packet from pool"); - this->status_momentary_warning("send-packet-pool-full"); + this->status_momentary_warning(); return ESP_ERR_ESPNOW_NO_MEM; } // Load the packet data @@ -434,7 +434,7 @@ void ESPNowComponent::send_() { if (packet->callback_ != nullptr) { packet->callback_(err); } - this->status_momentary_warning("send-failed"); + this->status_momentary_warning(); this->send_packet_pool_.release(packet); this->current_send_packet_ = nullptr; // Reset current packet return; @@ -447,7 +447,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { } if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { - this->status_momentary_warning("peer-add-failed"); + this->status_momentary_warning(); return ESP_ERR_INVALID_MAC; } @@ -462,7 +462,7 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); ESP_LOGE(TAG, "Failed to add peer %s - %s", peer_buf, LOG_STR_ARG(espnow_error_to_str(err))); - this->status_momentary_warning("peer-add-failed"); + this->status_momentary_warning(); return err; } } @@ -492,7 +492,7 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); ESP_LOGE(TAG, "Failed to delete peer %s - %s", peer_buf, LOG_STR_ARG(espnow_error_to_str(err))); - this->status_momentary_warning("peer-del-failed"); + this->status_momentary_warning(); return err; } } diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index b7565e676d5..deb9c0fec25 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -28,7 +28,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -85,7 +85,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons if (!status) { ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); return nullptr; } @@ -143,13 +143,13 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); container->end(); return nullptr; } if (!is_success(container->status_code)) { ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index c32881dc1f6..0130445acdc 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -22,7 +22,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -101,7 +101,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c if (!result) { ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); return nullptr; } App.feed_wdt(); @@ -109,7 +109,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c container->status_code = response.status; if (!is_success(response.status)) { ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 1413b843b74..e429df9b971 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -54,7 +54,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -71,7 +71,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Unsupported method"); return nullptr; } @@ -110,7 +110,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch esp_http_client_handle_t client = esp_http_client_init(&config); if (client == nullptr) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; client could not be initialized"); return nullptr; } @@ -131,7 +131,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch esp_err_t err = esp_http_client_open(client, body_len); if (err != ESP_OK) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -154,7 +154,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } if (err != ESP_OK) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -179,7 +179,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch err = esp_http_client_set_redirection(client); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -192,7 +192,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch err = esp_http_client_open(client, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -220,7 +220,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); return container; } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 79367d67e08..1345f0187b3 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -397,7 +397,7 @@ void I2SAudioMicrophone::loop() { if (!this->start_driver_()) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver_fail", 1000); + this->status_momentary_error(1000); this->stop_driver_(); // Stop/frees whatever possibly started break; } @@ -408,7 +408,7 @@ void I2SAudioMicrophone::loop() { if (this->task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task_fail", 1000); + this->status_momentary_error(1000); this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index a57ef7f55c0..8d586ea4e01 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -140,7 +140,7 @@ void I2SAudioSpeakerBase::loop() { if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver-failure", 1000); + this->status_momentary_error(1000); break; } @@ -153,7 +153,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); + this->status_momentary_error(1000); this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 9ad19f66d5e..6d76e399868 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -471,14 +471,14 @@ void MicroWakeWord::loop() { // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, this->microphone_source_->get_audio_stream_info().get_sample_rate())) { - this->status_momentary_error("frontend_alloc", 1000); + this->status_momentary_error(1000); return; } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state - this->status_momentary_error("task_start", 1000); + this->status_momentary_error(1000); } } break; diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index dbe07991633..23867aef914 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -359,7 +359,7 @@ void MixerSpeaker::loop() { xEventGroupClearBits(this->event_group_, MIXER_TASK_COMMAND_START); } else { ESP_LOGE(TAG, "Failed to start; retrying in 1 second"); - this->status_momentary_error("failure", 1000); + this->status_momentary_error(1000); return; } } diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index 906913723cc..a04121e2a13 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -20,7 +20,7 @@ void MQTTButtonComponent::setup() { this->button_->press(); } else { ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index b3d8ab80b14..6e5fad3efc7 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -439,7 +439,7 @@ bool MQTTClientComponent::subscribe_(const char *topic, uint8_t qos) { } else { delay(5); ESP_LOGV(TAG, "Subscribe failed for topic='%s'. Will retry", topic); - this->status_momentary_warning("subscribe", 1000); + this->status_momentary_warning(1000); } return ret != 0; } @@ -499,7 +499,7 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { } else { delay(5); ESP_LOGV(TAG, "Unsubscribe failed for topic='%s'.", topic.c_str()); - this->status_momentary_warning("unsubscribe", 1000); + this->status_momentary_warning(1000); } auto it = subscriptions_.begin(); @@ -553,7 +553,7 @@ bool MQTTClientComponent::publish(const char *topic, const char *payload, size_t ESP_LOGVV(TAG, "Publish payload (len=%u): '%.*s'", payload_length, static_cast(payload_length), payload); } else { ESP_LOGV(TAG, "Publish failed for topic='%s' (len=%u). Will retry", topic, payload_length); - this->status_momentary_warning("publish", 1000); + this->status_momentary_warning(1000); } } return ret != 0; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index daf2a511dcc..7aa2f8e9d5a 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -46,7 +46,7 @@ void MQTTFanComponent::setup() { case PARSE_NONE: default: ESP_LOGW(TAG, "Unknown state payload %s", payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); break; } }); @@ -71,7 +71,7 @@ void MQTTFanComponent::setup() { break; case PARSE_NONE: ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str()); - this->status_momentary_warning("direction", 5000); + this->status_momentary_warning(5000); break; } }); @@ -95,7 +95,7 @@ void MQTTFanComponent::setup() { break; case PARSE_NONE: ESP_LOGW(TAG, "Unknown Oscillation Payload %s", payload.c_str()); - this->status_momentary_warning("oscillation", 5000); + this->status_momentary_warning(5000); break; } }); @@ -112,11 +112,11 @@ void MQTTFanComponent::setup() { this->state_->make_call().set_speed(speed_level).perform(); } else { ESP_LOGW(TAG, "Invalid speed level %d", speed_level); - this->status_momentary_warning("speed", 5000); + this->status_momentary_warning(5000); } } else { ESP_LOGW(TAG, "Invalid speed level %s (int expected)", payload.c_str()); - this->status_momentary_warning("speed", 5000); + this->status_momentary_warning(5000); } }); } diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 4f81045fdc3..8e9a6289852 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -25,7 +25,7 @@ void MQTTLockComponent::setup() { this->lock_->open(); } else { ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); this->lock_->add_on_state_callback( diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 17ba86a9187..44ce2497720 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -29,7 +29,7 @@ void MQTTSwitchComponent::setup() { case PARSE_NONE: default: ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); break; } }); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index eac45d30b71..69b2f64771b 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -20,7 +20,7 @@ void MQTTUpdateComponent::setup() { this->update_->perform(); } else { ESP_LOGW(TAG, "'%s': Received unknown update payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index c5eee3665c2..1e51e45b7b3 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -171,7 +171,7 @@ bool SoundLevelComponent::start_() { (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer == nullptr) { - this->status_momentary_error("ring_buffer", 15000); + this->status_momentary_error(15000); return false; } @@ -180,7 +180,7 @@ bool SoundLevelComponent::start_() { this->audio_source_ = audio::RingBufferAudioSource::create( temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); if (this->audio_source_ == nullptr) { - this->status_momentary_error("audio_source", 15000); + this->status_momentary_error(15000); return false; } this->ring_buffer_ = temp_ring_buffer; diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 68cb98b0534..2e3d1c85299 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -265,7 +265,7 @@ void VL53L0XSensor::setup() { void VL53L0XSensor::update() { if (this->initiated_read_ || this->waiting_for_interrupt_) { this->publish_state(NAN); - this->status_momentary_warning("update", 5000); + this->status_momentary_warning(5000); ESP_LOGW(TAG, "%s - update called before prior reading complete - initiated:%d waiting_for_interrupt:%d", this->name_.c_str(), this->initiated_read_, this->waiting_for_interrupt_); return; From ba7d3bd77057dd18560607dba5e4ae8c2cf0c8a2 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:37:46 +0200 Subject: [PATCH 77/79] [gpio][esp32][uart] Add option to hold gpio during (deep)sleep and reboot (3/3) (#18964) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../deep_sleep/deep_sleep_esp32.cpp | 2 +- esphome/components/esp32/gpio.cpp | 78 ++++++++++ esphome/components/esp32/gpio.h | 2 + esphome/components/esp32/gpio.py | 8 +- esphome/components/esp32/gpio_common.py | 26 ++++ esphome/components/esp32/gpio_esp32.py | 10 ++ esphome/components/esp32/gpio_esp32_c3.py | 10 +- esphome/components/esp32/gpio_esp32_c5.py | 7 + esphome/components/esp32/gpio_esp32_c6.py | 10 +- esphome/components/esp32/gpio_esp32_c61.py | 7 + esphome/components/esp32/gpio_esp32_h2.py | 10 +- esphome/components/esp32/gpio_esp32_h21.py | 7 + esphome/components/esp32/gpio_esp32_h4.py | 7 + esphome/components/esp32/gpio_esp32_p4.py | 10 +- esphome/components/esp32/gpio_esp32_s2.py | 6 + esphome/components/esp32/gpio_esp32_s3.py | 10 +- esphome/components/esp32/gpio_esp32_s31.py | 7 + .../uart/uart_component_esp_idf.cpp | 17 +++ .../components/uart/uart_component_esp_idf.h | 3 + tests/component_tests/esp32/test_esp32.py | 136 +++++++++++++++++- .../gpio/test_output_final_validate.py | 38 +++++ .../components/deep_sleep/test.esp32-idf.yaml | 26 +++- tests/components/gpio/test.esp32-c3-idf.yaml | 17 ++- tests/components/gpio/test.esp32-idf.yaml | 24 +++- tests/components/uart/test.esp32-idf.yaml | 7 +- 25 files changed, 442 insertions(+), 43 deletions(-) create mode 100644 esphome/components/esp32/gpio_common.py create mode 100644 tests/component_tests/gpio/test_output_final_validate.py diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index b71a4fa1383..7cf90bc33e3 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -111,7 +111,7 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); -#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP +#ifndef SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP #ifdef USE_GPIO_HOLD // Variants without per-pin deep sleep hold need the global enable gpio_deep_sleep_hold_en(); diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index d9c9b861858..59788e76dc7 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -50,17 +50,52 @@ struct ISRPinArg { gpio_num_t pin; gpio::Flags flags; bool inverted; +#ifdef USE_GPIO_HOLD + bool hold; +#endif #if defined(USE_ESP32_VARIANT_ESP32) bool use_rtc; int rtc_pin; #endif }; +#ifdef USE_GPIO_HOLD +// Re-latch a held pad onto the values just written to its registers. +static inline void refresh_hold(const ESP32InternalGPIOPin &pin) { + pin.set_hold(false); + pin.set_hold(true); +} + +static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) { + if (!arg->hold) + return; +#if defined(USE_ESP32_VARIANT_ESP32) + if (arg->use_rtc) { + rtcio_hal_hold_disable(arg->rtc_pin); + rtcio_hal_hold_enable(arg->rtc_pin); + return; + } +#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + // GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them + if (arg->pin < SOC_RTCIO_PIN_COUNT) { + rtcio_hal_hold_disable(arg->pin); + rtcio_hal_hold_enable(arg->pin); + return; + } +#endif + gpio_hal_hold_dis(&GPIO_HAL, arg->pin); + gpio_hal_hold_en(&GPIO_HAL, arg->pin); +} +#endif + ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->get_pin_num(); arg->flags = gpio::FLAG_NONE; arg->inverted = this->pin_flags_.inverted; +#ifdef USE_GPIO_HOLD + arg->hold = this->get_hold(); +#endif #if defined(USE_ESP32_VARIANT_ESP32) arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num()); if (arg->use_rtc) @@ -105,7 +140,23 @@ size_t ESP32InternalGPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%" PRIu32, static_cast(this->pin_)); } +#ifdef USE_GPIO_HOLD +void ESP32InternalGPIOPin::set_hold(bool hold) const { + if (!this->get_hold()) + return; + if (hold) { + gpio_hold_en(this->get_pin_num()); + } else { + gpio_hold_dis(this->get_pin_num()); + } +} +#endif + void ESP32InternalGPIOPin::setup() { +#ifdef USE_GPIO_HOLD + // Hold before gpio_config so the pad keeps its state while the registers change + this->set_hold(true); +#endif gpio_config_t conf{}; conf.pin_bit_mask = 1ULL << static_cast(this->pin_); conf.mode = flags_to_mode(this->flags_); @@ -116,6 +167,18 @@ void ESP32InternalGPIOPin::setup() { if (this->flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } +#ifdef USE_GPIO_HOLD + // gpio_hold_dis logs an error on input-only pads + if (GPIO_IS_VALID_OUTPUT_GPIO(this->get_pin_num())) { + if (!this->get_hold()) { + // Release a hold left behind by an earlier boot + gpio_hold_dis(this->get_pin_num()); + } else if (this->flags_ & gpio::FLAG_INPUT) { + // Inputs apply the config now so reads work; outputs wait for the first write + refresh_hold(*this); + } + } +#endif } void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { @@ -130,6 +193,9 @@ void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { pull_mode = GPIO_PULLDOWN_ONLY; } gpio_set_pull_mode(this->get_pin_num(), pull_mode); +#ifdef USE_GPIO_HOLD + refresh_hold(*this); +#endif } bool ESP32InternalGPIOPin::digital_read() { @@ -137,6 +203,9 @@ bool ESP32InternalGPIOPin::digital_read() { } void ESP32InternalGPIOPin::digital_write(bool value) { gpio_set_level(this->get_pin_num(), value != this->pin_flags_.inverted ? 1 : 0); +#ifdef USE_GPIO_HOLD + refresh_hold(*this); +#endif } void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->get_pin_num()); } @@ -144,6 +213,8 @@ void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->ge using namespace esp32; +// NOLINTBEGIN(clang-analyzer-core.FixedAddressDereference) -- some gpio_hal functions use MMIO at fixed addresses +// internally bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); return bool(gpio_hal_get_level(&GPIO_HAL, arg->pin)) != arg->inverted; @@ -152,6 +223,9 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { auto *arg = reinterpret_cast(this->arg_); gpio_hal_set_level(&GPIO_HAL, arg->pin, value != arg->inverted); +#ifdef USE_GPIO_HOLD + isr_refresh_hold(arg); +#endif } void IRAM_ATTR ISRInternalGPIOPin::clear_interrupt() { @@ -203,8 +277,12 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { gpio_hal_input_disable(&GPIO_HAL, arg->pin); } } +#ifdef USE_GPIO_HOLD + isr_refresh_hold(arg); +#endif arg->flags = flags; } +// NOLINTEND(clang-analyzer-core.FixedAddressDereference) } // namespace esphome diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index 954b213d7f8..6ac16731271 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -37,6 +37,8 @@ class ESP32InternalGPIOPin final : public GPIOPin { gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } #ifdef USE_GPIO_HOLD + /// Apply or release the pad hold. Does nothing unless the pin is configured with hold_state. + void set_hold(bool hold) const; bool is_held() const override { if (!this->get_hold()) return false; diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 321dd3d498f..7753574bc85 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -5,6 +5,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -248,6 +249,7 @@ ESP32_PIN_SCHEMA = cv.All( cv.float_with_unit("current", "mA", optional_unit=True), cv.enum(DRIVE_STRENGTHS), ), + cv.Optional(CONF_HOLD_STATE, default=False): cv.boolean, } ), validate_gpio_pin, @@ -266,7 +268,11 @@ async def esp32_pin_to_code(config): cg.add(var.set_inverted(True)) if CONF_DRIVE_STRENGTH in config: cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + flags = pins.gpio_flags_expr(config[CONF_MODE]) + if config[CONF_HOLD_STATE]: + flags = flags | cg.gpio_Flags.FLAG_HOLD + cg.add_define("USE_GPIO_HOLD") + cg.add(var.set_flags(flags)) return var diff --git a/esphome/components/esp32/gpio_common.py b/esphome/components/esp32/gpio_common.py new file mode 100644 index 00000000000..a12bebf6e92 --- /dev/null +++ b/esphome/components/esp32/gpio_common.py @@ -0,0 +1,26 @@ +"""Pin checks shared by the ESP32 variant validators.""" + +from logging import Logger +from typing import Any + +from esphome.components.const import CONF_HOLD_STATE +from esphome.const import CONF_NUMBER + + +def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> None: + if num in usb_jtag_pins: + logger.warning( + "GPIO%d is used by the USB-Serial-JTAG interface." + " Using this pin as GPIO will conflict with USB-Serial-JTAG.", + num, + ) + + +def check_usb_jtag_hold( + conf: dict[str, Any], usb_jtag_pins: set[int], logger: Logger +) -> None: + num = conf[CONF_NUMBER] + if conf.get(CONF_HOLD_STATE) and num in usb_jtag_pins: + logger.warning( + "GPIO%d cannot hold at low level during wakeup from deep sleep.", num + ) diff --git a/esphome/components/esp32/gpio_esp32.py b/esphome/components/esp32/gpio_esp32.py index fec257f90b8..11c62359c11 100644 --- a/esphome/components/esp32/gpio_esp32.py +++ b/esphome/components/esp32/gpio_esp32.py @@ -1,6 +1,7 @@ import logging from typing import Any +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_INPUT, @@ -53,6 +54,7 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: is_output = mode[CONF_OUTPUT] is_pullup = mode[CONF_PULLUP] is_pulldown = mode[CONF_PULLDOWN] + is_hold = value.get(CONF_HOLD_STATE) if is_input: # All ESP32 pins support input mode @@ -70,6 +72,14 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: raise cv.Invalid( f"GPIO{num} (34-39) does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN] ) + if is_hold and 34 <= num <= 39: + raise cv.Invalid( + f"GPIO{num} (34-39) is input-only and cannot be held.", + [CONF_HOLD_STATE], + ) + if is_hold and num == 20: + # Not in the digital hold mask and not an RTC pad, so the driver cannot hold it + raise cv.Invalid("GPIO20 has no hold function.", [CONF_HOLD_STATE]) check_strapping_pin(value, _ESP32_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c3.py b/esphome/components/esp32/gpio_esp32_c3.py index 6eb002f3f0a..c4624a93a4e 100644 --- a/esphome/components/esp32/gpio_esp32_c3.py +++ b/esphome/components/esp32/gpio_esp32_c3.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32C3_SPI_PSRAM_PINS = { 12: "SPIHD", 13: "SPIWP", @@ -28,12 +30,7 @@ def esp32_c3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-C3s and is already used by the SPI/PSRAM interface (function: {_ESP32C3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32C3_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32C3_USB_JTAG_PINS, _LOGGER) return value @@ -49,6 +46,7 @@ def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C3_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32C3_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index 639ed64c9ea..b3f4c319c61 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c5/include/hal/i2c_ll.h _ESP32C5_I2C_LP_PINS = {"SDA": 2, "SCL": 3} @@ -20,6 +22,8 @@ _ESP32C5_SPI_PSRAM_PINS = { _ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} +_ESP32C5_USB_JTAG_PINS = {13, 14} + _LOGGER = logging.getLogger(__name__) @@ -31,6 +35,7 @@ def esp32_c5_validate_gpio_pin(value: int) -> int: f"This pin cannot be used on ESP32-C5s and is already used by the SPI/PSRAM interface (function: {_ESP32C5_SPI_PSRAM_PINS[value]})" ) + check_usb_jtag_pin(value, _ESP32C5_USB_JTAG_PINS, _LOGGER) return value @@ -45,6 +50,8 @@ def esp32_c5_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C5_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index bd7bb9e2208..9eba375e26e 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c6/include/hal/i2c_ll.h _ESP32C6_I2C_LP_PINS = {"SDA": 6, "SCL": 7} @@ -32,12 +34,7 @@ def esp32_c6_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-C6s and is already used by the SPI/PSRAM interface (function: {_ESP32C6_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32C6_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32C6_USB_JTAG_PINS, _LOGGER) return value @@ -52,6 +49,7 @@ def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C6_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32C6_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py index 2f3abe6a0f9..3ae8f1c2921 100644 --- a/esphome/components/esp32/gpio_esp32_c61.py +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # GPIO14-17, GPIO19-21 are used for SPI flash/PSRAM _ESP32C61_SPI_PSRAM_PINS = { 14: "SPICS0", @@ -18,6 +20,8 @@ _ESP32C61_SPI_PSRAM_PINS = { _ESP32C61_STRAPPING_PINS = {8, 9} +_ESP32C61_USB_JTAG_PINS = {12, 13} + _LOGGER = logging.getLogger(__name__) @@ -29,6 +33,7 @@ def esp32_c61_validate_gpio_pin(value: int) -> int: f"This pin cannot be used on ESP32-C61s and is already used by the SPI/PSRAM interface (function: {_ESP32C61_SPI_PSRAM_PINS[value]})" ) + check_usb_jtag_pin(value, _ESP32C61_USB_JTAG_PINS, _LOGGER) return value @@ -43,5 +48,7 @@ def esp32_c61_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32-C61 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C61_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32C61_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index 9dd65376944..41520bb4b75 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32H2_SPI_FLASH_PINS = {6, 7, 15, 16, 17, 18, 19, 20, 21} _ESP32H2_USB_JTAG_PINS = {26, 27} @@ -24,12 +26,7 @@ def esp32_h2_validate_gpio_pin(value: int) -> int: "See https://docs.espressif.com/projects/esp-idf/en/latest/esp32h2/api-reference/peripherals/gpio.html", value, ) - if value in _ESP32H2_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32H2_USB_JTAG_PINS, _LOGGER) return value @@ -44,5 +41,6 @@ def esp32_h2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H2_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32H2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py index 5ab1b7c0740..a13fe3e4ccc 100644 --- a/esphome/components/esp32/gpio_esp32_h21.py +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -5,17 +5,22 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Partial set from the ESP-IDF / esptool boot-mode docs: # https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html # The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. _ESP32H21_STRAPPING_PINS: set[int] = {13, 14} +_ESP32H21_USB_JTAG_PINS = {17, 18} + _LOGGER = logging.getLogger(__name__) def esp32_h21_validate_gpio_pin(value: int) -> int: if value < 0 or value > 25: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + check_usb_jtag_pin(value, _ESP32H21_USB_JTAG_PINS, _LOGGER) return value @@ -30,5 +35,7 @@ def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H21_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py index 86a4d558589..0cbd83c50cb 100644 --- a/esphome/components/esp32/gpio_esp32_h4.py +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -5,17 +5,22 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Partial set from the ESP-IDF / esptool boot-mode docs: # https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html # The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. _ESP32H4_STRAPPING_PINS: set[int] = {13, 14} +_ESP32H4_USB_JTAG_PINS = {13, 14} + _LOGGER = logging.getLogger(__name__) def esp32_h4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 39: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + check_usb_jtag_pin(value, _ESP32H4_USB_JTAG_PINS, _LOGGER) return value @@ -30,5 +35,7 @@ def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H4_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 6e9227c5015..69fdf5dd22c 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://documentation.espressif.com/esp32-p4-chip-revision-v1.3_datasheet_en.pdf _ESP32P4_LP_PINS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} @@ -18,12 +20,7 @@ _LOGGER = logging.getLogger(__name__) def esp32_p4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") - if value in _ESP32P4_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32P4_USB_JTAG_PINS, _LOGGER) return value @@ -38,6 +35,7 @@ def esp32_p4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32P4_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s2.py b/esphome/components/esp32/gpio_esp32_s2.py index 4978f48a1c2..0de621789d5 100644 --- a/esphome/components/esp32/gpio_esp32_s2.py +++ b/esphome/components/esp32/gpio_esp32_s2.py @@ -1,6 +1,7 @@ import logging from typing import Any +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_INPUT, @@ -70,6 +71,11 @@ def esp32_s2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: raise cv.Invalid( f"GPIO{num} does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN] ) + if value.get(CONF_HOLD_STATE) and num == 46: + raise cv.Invalid( + f"GPIO{num} is input-only and cannot be held.", + [CONF_HOLD_STATE], + ) check_strapping_pin(value, _ESP32S2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index db8c5205336..d38c0a8011f 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -12,6 +12,8 @@ from esphome.const import ( from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin from esphome.types import ConfigType +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", 27: "SPIHD", @@ -53,12 +55,7 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: # These pins are not exposed in GPIO mux (reason unknown) # but they're missing from IO_MUX list in datasheet raise cv.Invalid(f"The pin GPIO{value} is not usable on ESP32-S3s.") - if value in _ESP32S3_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32S3_USB_JTAG_PINS, _LOGGER) return value @@ -73,6 +70,7 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32S3_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 7ccb7cdb903..a6a3b39193e 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Per the ESP32-S31 IDF DOCS and datasheet: # https://docs.espressif.com/projects/esp-idf/en/v6.1/esp32s31/api-reference/peripherals/gpio.html # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf @@ -16,6 +18,8 @@ _ESP32S31_STRAPPING_PINS: set[int] = {36, 37, 60, 61} # LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. _ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} +_ESP32S31_USB_JTAG_PINS = {33, 34} + _LOGGER = logging.getLogger(__name__) @@ -28,6 +32,7 @@ def esp32_s31_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." ) + check_usb_jtag_pin(value, _ESP32S31_USB_JTAG_PINS, _LOGGER) return value @@ -46,6 +51,8 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32S31_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 41fae283b4d..cccb7289fba 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -238,6 +238,10 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } +#ifdef USE_GPIO_HOLD + // Release held pins so the UART peripheral can drive them + this->set_pins_hold_(false); +#endif #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. @@ -514,7 +518,20 @@ void IDFUARTComponent::on_shutdown() { if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); } +#ifdef USE_GPIO_HOLD + // Hold the pins again so they keep their state through the reset + this->set_pins_hold_(true); +#endif } +#ifdef USE_GPIO_HOLD +void IDFUARTComponent::set_pins_hold_(bool hold) { + for (auto *pin : {this->tx_pin_, this->rx_pin_, this->flow_control_pin_}) { + if (pin != nullptr) + pin->set_hold(hold); + } +} +#endif + } // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 7c93b74fbdd..260117c4299 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -90,6 +90,9 @@ class IDFUARTComponent final : public UARTComponent, public Component { protected: void check_logger_conflict() override; +#ifdef USE_GPIO_HOLD + void set_pins_hold_(bool hold); +#endif uint32_t line_inversion_mask_(); // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. esp_err_t apply_line_settings_(); diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 04416d7f57a..4b7a0a6ab8e 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -10,6 +10,7 @@ from typing import Any import pytest +from esphome.components.const import CONF_HOLD_STATE from esphome.components.esp32 import ( _ESP_TLS_LINKING_COMPONENTS, DEFAULT_EXCLUDED_IDF_COMPONENTS, @@ -38,13 +39,30 @@ from esphome.components.esp32.const import ( KEY_NETWORK_SDKCONFIG, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, ) -from esphome.components.esp32.gpio import validate_gpio_pin +from esphome.components.esp32.gpio import validate_gpio_pin, validate_supports import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_INPUT, + CONF_MODE, CONF_NUMBER, + CONF_OPEN_DRAIN, + CONF_OUTPUT, + CONF_PULLDOWN, + CONF_PULLUP, PlatformFramework, Toolchain, ) @@ -57,7 +75,7 @@ def test_esp32_config( ) -> None: set_core_config(PlatformFramework.ESP32_IDF) - from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY + from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_FRIENDLY # Example ESP32 configuration config = { @@ -1722,9 +1740,8 @@ def test_esp32_s31_gpio_validation( caplog: pytest.LogCaptureFixture, ) -> None: """S31: GPIO26-28/30-32 are reserved for the SPI flash interface, GPIO29 - and GPIO41 do not exist, GPIO33 is a normal pin, and GPIO36 is a + and GPIO41 do not exist, GPIO35 is a normal pin, and GPIO36 is a strapping pin.""" - from esphome.components.esp32.const import VARIANT_ESP32S31 from esphome.components.esp32.gpio import validate_supports from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT @@ -1735,8 +1752,8 @@ def test_esp32_s31_gpio_validation( input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False} # Not reserved; a normal GPIO - pin = {CONF_NUMBER: 33, CONF_IGNORE_PIN_VALIDATION_ERROR: False} - assert validate_gpio_pin(pin)[CONF_NUMBER] == 33 + pin = {CONF_NUMBER: 35, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + assert validate_gpio_pin(pin)[CONF_NUMBER] == 35 # Reserved for the SPI flash interface, but can be bypassed with # ignore_pin_validation_error @@ -1764,6 +1781,113 @@ def test_esp32_s31_gpio_validation( assert "GPIO36 is a strapping pin" in caplog.text +_INPUT_ONLY_SETTINGS = ( + (CONF_OUTPUT, "does not support output pin mode"), + (CONF_PULLUP, "does not support pullups"), + (CONF_PULLDOWN, "does not support pulldowns"), + (CONF_HOLD_STATE, "is input-only and cannot be held"), +) + + +@pytest.mark.parametrize( + ("variant", "number", "setting", "error"), + [ + pytest.param( + variant, number, setting, error, id=f"{name}-gpio{number}-{setting}" + ) + for variant, name, numbers in ( + (VARIANT_ESP32, "esp32", range(34, 40)), + (VARIANT_ESP32S2, "s2", (46,)), + ) + for number in numbers + for setting, error in _INPUT_ONLY_SETTINGS + ] + + [ + pytest.param( + VARIANT_ESP32, + 20, + CONF_HOLD_STATE, + "GPIO20 has no hold function", + id="esp32-gpio20-hold_state", + ) + ], +) +def test_input_only_gpio_rejects_unsupported_modes( + set_core_config: SetCoreConfigCallable, + variant: str, + number: int, + setting: str, + error: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + mode = { + CONF_INPUT: True, + CONF_OUTPUT: False, + CONF_OPEN_DRAIN: False, + CONF_PULLUP: False, + CONF_PULLDOWN: False, + } + pin = {CONF_NUMBER: number, CONF_MODE: mode} + if setting == CONF_HOLD_STATE: + pin[setting] = True + else: + mode[setting] = True + + with pytest.raises(cv.Invalid, match=error): + validate_supports(pin) + + +@pytest.mark.parametrize( + ("variant", "number"), + [ + pytest.param(VARIANT_ESP32C3, 18, id="c3-18"), + pytest.param(VARIANT_ESP32C3, 19, id="c3-19"), + pytest.param(VARIANT_ESP32C5, 13, id="c5-13"), + pytest.param(VARIANT_ESP32C5, 14, id="c5-14"), + pytest.param(VARIANT_ESP32C6, 12, id="c6-12"), + pytest.param(VARIANT_ESP32C6, 13, id="c6-13"), + pytest.param(VARIANT_ESP32C61, 12, id="c61-12"), + pytest.param(VARIANT_ESP32C61, 13, id="c61-13"), + pytest.param(VARIANT_ESP32H2, 26, id="h2-26"), + pytest.param(VARIANT_ESP32H2, 27, id="h2-27"), + pytest.param(VARIANT_ESP32H4, 13, id="h4-13"), + pytest.param(VARIANT_ESP32H4, 14, id="h4-14"), + pytest.param(VARIANT_ESP32H21, 17, id="h21-17"), + pytest.param(VARIANT_ESP32H21, 18, id="h21-18"), + pytest.param(VARIANT_ESP32P4, 24, id="p4-24"), + pytest.param(VARIANT_ESP32P4, 25, id="p4-25"), + pytest.param(VARIANT_ESP32S3, 19, id="s3-19"), + pytest.param(VARIANT_ESP32S3, 20, id="s3-20"), + pytest.param(VARIANT_ESP32S31, 33, id="s31-33"), + pytest.param(VARIANT_ESP32S31, 34, id="s31-34"), + ], +) +def test_usb_jtag_gpio_hold_state_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + variant: str, + number: int, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + pin = { + CONF_NUMBER: number, + CONF_MODE: { + CONF_INPUT: True, + CONF_OUTPUT: False, + CONF_OPEN_DRAIN: False, + }, + CONF_HOLD_STATE: True, + } + + with caplog.at_level(logging.WARNING): + validate_supports(pin) + + assert ( + f"GPIO{number} cannot hold at low level during wakeup from deep sleep." + in caplog.text + ) + + _TLS_SERVER_OPTIONS = ( "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", diff --git a/tests/component_tests/gpio/test_output_final_validate.py b/tests/component_tests/gpio/test_output_final_validate.py new file mode 100644 index 00000000000..5d92ad44c6a --- /dev/null +++ b/tests/component_tests/gpio/test_output_final_validate.py @@ -0,0 +1,38 @@ +"""Tests for GPIO output final validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.const import CONF_HOLD_STATE +from esphome.components.gpio.output import FINAL_VALIDATE_SCHEMA +from esphome.config import Config +from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("power_supply_holds", [False, True]) +def test_output_hold_state_requires_power_supply_hold_state( + set_core_config: SetCoreConfigCallable, + power_supply_holds: bool, +) -> None: + supply_id = ID("supply", is_declaration=True, type="power_supply") + full_config = Config() + full_config["power_supply"] = [ + {CONF_ID: supply_id, CONF_PIN: {CONF_HOLD_STATE: power_supply_holds}} + ] + full_config.declare_ids.append((supply_id, ["power_supply", 0, CONF_ID])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + + config = { + CONF_PIN: {CONF_HOLD_STATE: True}, + CONF_POWER_SUPPLY: ID("supply", is_declaration=False, type="power_supply"), + } + if power_supply_holds: + FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises( + cv.Invalid, + match="The power supply pin must also set hold_state when an output it powers sets it", + ): + FINAL_VALIDATE_SCHEMA(config) diff --git a/tests/components/deep_sleep/test.esp32-idf.yaml b/tests/components/deep_sleep/test.esp32-idf.yaml index e45eb08349a..cc9bf5af166 100644 --- a/tests/components/deep_sleep/test.esp32-idf.yaml +++ b/tests/components/deep_sleep/test.esp32-idf.yaml @@ -1,5 +1,27 @@ substitutions: wakeup_pin: GPIO4 -<<: !include common.yaml -<<: !include common-esp32-all.yaml +packages: + deep_sleep: !include common.yaml + deep_sleep_esp32: !include common-esp32-all.yaml + +power_supply: + - id: power_supply_ds_esp32 + pin: + number: GPIO5 + hold_state: true + +output: + - platform: gpio + pin: + number: GPIO22 + hold_state: true + id: gpio_output_ds_esp32 + power_supply: power_supply_ds_esp32 + +switch: + - platform: gpio + pin: + number: GPIO23 + hold_state: true + id: gpio_switch_ds_esp32 diff --git a/tests/components/gpio/test.esp32-c3-idf.yaml b/tests/components/gpio/test.esp32-c3-idf.yaml index e9071b43560..42c207d627d 100644 --- a/tests/components/gpio/test.esp32-c3-idf.yaml +++ b/tests/components/gpio/test.esp32-c3-idf.yaml @@ -6,4 +6,19 @@ substitutions: switch_pin_3: GPIO6 switch_pin_4: GPIO7 -<<: !include common.yaml +packages: + gpio: !include common.yaml + +binary_sensor: + - platform: gpio + pin: + number: GPIO8 + hold_state: true + id: gpio_binary_sensor_esp32 + +output: + - platform: gpio + pin: + number: GPIO9 + hold_state: true + id: gpio_output_esp32 diff --git a/tests/components/gpio/test.esp32-idf.yaml b/tests/components/gpio/test.esp32-idf.yaml index 862aa533ead..12be2ef6fe1 100644 --- a/tests/components/gpio/test.esp32-idf.yaml +++ b/tests/components/gpio/test.esp32-idf.yaml @@ -6,4 +6,26 @@ substitutions: switch_pin_3: GPIO16 switch_pin_4: GPIO17 -<<: !include common.yaml +packages: + gpio: !include common.yaml + +binary_sensor: + - platform: gpio + pin: + number: GPIO18 + hold_state: true + id: gpio_binary_sensor_esp32 + +output: + - platform: gpio + pin: + number: GPIO19 + hold_state: true + id: gpio_output_esp32 + +switch: + - platform: gpio + pin: + number: GPIO21 + hold_state: true + id: gpio_switch_esp32 diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 9550de911b7..3bb87aa3cc7 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -14,7 +14,12 @@ esphome: uart: - id: uart_id - tx_pin: 17 + tx_pin: + number: 17 + mode: + pullup: true + output: true + hold_state: true rx_pin: 16 flow_control_pin: 4 baud_rate: 9600 From dd45f8a6f81a623d1b14200d784e419b7565f9c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:38:05 -1000 Subject: [PATCH 78/79] [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) --- esphome/components/usb_host/__init__.py | 53 ++++++++++---- tests/unit_tests/components/test_usb_host.py | 75 +++++++++++++++++++- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 94532958ed1..317cec8236b 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,5 @@ -from itertools import combinations +import itertools +import logging import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER @@ -22,6 +23,8 @@ import esphome.final_validate as fv from esphome.helpers import cpp_u16string_escape from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["bytebuffer"] CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["esp32"] @@ -71,15 +74,19 @@ def usb_device_schema( ) -def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: +def _clients_overlap(first: ConfigType, second: ConfigType) -> bool: # Two entries overlap when no field they both constrain tells them apart - for first, second in combinations(configs, 2): - for key, wildcard in _FILTER_WILDCARDS.items(): - a = first.get(key) - b = second.get(key) - if wildcard not in (a, b) and a != b: - break - else: + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + return False + return True + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + for first, second in itertools.combinations(configs, 2): + if _clients_overlap(first, second): raise cv.Invalid( f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" ) @@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: def _final_validate(config: ConfigType) -> ConfigType: - # Every USB client on the bus, whichever component configured it: any two could - # otherwise open the same device - clients = list(config.get(CONF_DEVICES) or ()) - clients.extend(fv.full_config.get().get("usb_uart") or ()) - validate_usb_clients(clients) + # Any two overlapping clients could otherwise open the same device + devices = config.get(CONF_DEVICES) or [] + uarts = fv.full_config.get().get("usb_uart") or [] + validate_usb_clients(devices) + validate_usb_clients(uarts) + # Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call + for device, uart in itertools.product(devices, uarts): + if not _clients_overlap(device, uart): + continue + # The device matches nothing the usb_uart entry does not already claim + redundant = all( + uart.get(key) in (wildcard, device.get(key)) + for key, wildcard in _FILTER_WILDCARDS.items() + ) + _LOGGER.warning( + "usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. " + "This will be an error in 2027.10.0", + device[CONF_ID], + uart[CONF_ID], + "remove it from usb_host devices" + if redundant + else "narrow its filter so it no longer matches that device", + ) return config diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 1a1df68f4de..8d572dba968 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -1,9 +1,12 @@ """Tests for usb_host device matching validation.""" +import logging + import pytest -from esphome.components.usb_host import validate_usb_clients +from esphome.components.usb_host import _final_validate, validate_usb_clients import esphome.config_validation as cv +import esphome.final_validate as fv from esphome.types import ConfigType @@ -109,3 +112,73 @@ def test_every_pair_is_compared_not_just_neighbours() -> None: ] with pytest.raises(cv.Invalid, match="a, c"): validate_usb_clients(configs) + + +def _run_final_validate( + devices: list[ConfigType], uarts: list[ConfigType] +) -> ConfigType: + config = {"id": "usb_host", "devices": devices} + token = fv.full_config.set({"usb_host": config, "usb_uart": uarts}) + try: + return _final_validate(config) + finally: + fv.full_config.reset(token) + + +def test_device_duplicating_usb_uart_warns(caplog: pytest.LogCaptureFixture) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "'device_0'" in caplog.text + assert "'uart_0'" in caplog.text + assert "remove it from usb_host devices" in caplog.text + + +def test_wildcard_device_covering_usb_uart_suggests_narrowing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "narrow its filter" in caplog.text + assert "remove it" not in caplog.text + + +def test_disjoint_device_and_usb_uart_do_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4002}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert caplog.text == "" + + +_DUPLICATE_CLIENTS = [ + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4001}, +] + + +@pytest.mark.parametrize( + ("devices", "uarts"), + [([*_DUPLICATE_CLIENTS], []), ([], [*_DUPLICATE_CLIENTS])], + ids=["devices", "uarts"], +) +def test_overlap_within_one_component_is_rejected( + devices: list[ConfigType], uarts: list[ConfigType] +) -> None: + with pytest.raises(cv.Invalid, match="a, b"): + _run_final_validate(devices, uarts) + + +def test_device_inside_broader_usb_uart_suggests_removing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "remove it from usb_host devices" in caplog.text From d3e7bb119c5b1826b8e2f0bb9d37835edee67d55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:39:21 -1000 Subject: [PATCH 79/79] [core] Deprecate the named status_momentary_warning/error overloads (#20369) --- esphome/core/component.h | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/esphome/core/component.h b/esphome/core/component.h index 94ccec13f80..0e95f8d4e90 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -300,13 +300,8 @@ class Component { this->status_clear_error_slow_path_(); } - /** Set warning status flag and automatically clear it after a timeout. - * - * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). - * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. - * This is NOT a message to display - use status_set_warning() with a message if logging is needed. - * @param length Duration in milliseconds before the warning is automatically cleared. - */ + // Remove before 2027.5.0 + ESPDEPRECATED("Use status_momentary_warning() without a name. Removed in 2027.5.0", "2026.11.0") void status_momentary_warning(const char *name, uint32_t length = 5000); /// Set warning status flag and clear it after `length` ms. A new call restarts the timeout. @@ -314,13 +309,8 @@ class Component { void status_momentary_warning(uint32_t length = 5000); void status_momentary_warning(const LogString *message, uint32_t length = 5000); - /** Set error status flag and automatically clear it after a timeout. - * - * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). - * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. - * This is NOT a message to display - use status_set_error() with a message if logging is needed. - * @param length Duration in milliseconds before the error is automatically cleared. - */ + // Remove before 2027.5.0 + ESPDEPRECATED("Use status_momentary_error() without a name. Removed in 2027.5.0", "2026.11.0") void status_momentary_error(const char *name, uint32_t length = 5000); /// Set error status flag and clear it after `length` ms. A new call restarts the timeout.