From 149f7870356fcf13426dfcdbda41d48dbf03a2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Fri, 23 May 2025 09:32:47 +0000 Subject: [PATCH 01/30] feat: `wifi.configure` now emits error after reconnecting to old AP (#8653) --- esphome/components/wifi/wifi_component.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index abedfab3a6..982007e47f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -483,14 +483,16 @@ template class WiFiConfigureAction : public Action, publi // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this]() { - this->connecting_ = false; + this->set_timeout("wifi-connect-timeout", 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(); - // Callback to notify the user that the connection failed - this->error_trigger_->trigger(); + // 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->connecting_ = false; + this->error_trigger_->trigger(); + }); }); } @@ -503,6 +505,7 @@ template class WiFiConfigureAction : public Action, publi 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->connecting_ = false; if (global_wifi_component->wifi_ssid() == this->new_sta_.get_ssid()) { // Callback to notify the user that the connection was successful From 19e2460af26b3664f787fe044dca0e6b168609c2 Mon Sep 17 00:00:00 2001 From: gotnone Date: Fri, 23 May 2025 04:34:10 -0500 Subject: [PATCH 02/30] [modbus_controller] Add assumed_state to switch (#8880) Co-authored-by: Stanley Pinchak Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/modbus_controller/switch/__init__.py | 8 ++++++-- .../components/modbus_controller/switch/modbus_switch.cpp | 4 ++++ .../components/modbus_controller/switch/modbus_switch.h | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 258d87fd25..e325e6198e 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID from .. import ( MODBUS_REGISTER_TYPE, @@ -36,6 +36,7 @@ CONFIG_SCHEMA = cv.All( .extend(ModbusItemBaseSchema) .extend( { + cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -62,7 +63,10 @@ async def to_code(config): paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) - cg.add(paren.add_sensor_item(var)) + assumed_state = config[CONF_ASSUMED_STATE] + cg.add(var.set_assumed_state(assumed_state)) + if not assumed_state: + cg.add(paren.add_sensor_item(var)) if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index b729e2659f..21c4c1718d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -19,6 +19,10 @@ void ModbusSwitch::setup() { } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } +void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + +bool ModbusSwitch::assumed_state() { return this->assumed_state_; } + void ModbusSwitch::parse_and_publish(const std::vector &data) { bool value = false; switch (this->register_type) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index fe4b7c1ad5..0098076ef4 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -29,6 +29,7 @@ class ModbusSwitch : public Component, public switch_::Switch, public SensorItem void setup() override; void write_state(bool state) override; void dump_config() override; + void set_assumed_state(bool assumed_state); void set_state(bool state) { this->state = state; } void parse_and_publish(const std::vector &data) override; void set_parent(ModbusController *parent) { this->parent_ = parent; } @@ -40,10 +41,12 @@ class ModbusSwitch : public Component, public switch_::Switch, public SensorItem void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: + bool assumed_state() override; ModbusController *parent_{nullptr}; bool use_write_multiple_{false}; optional publish_transform_func_{nullopt}; optional write_transform_func_{nullopt}; + bool assumed_state_{false}; }; } // namespace modbus_controller From 9dd404598464d103392c29d13e946e65fd2dedd3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 24 May 2025 14:54:06 +1200 Subject: [PATCH 03/30] [const] Move ``CONF_RESET`` to const.py (#8889) --- esphome/components/bl0942/sensor.py | 2 +- esphome/components/wl_134/text_sensor.py | 3 +-- esphome/const.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 550f534b74..f9fe7f5a5e 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, + CONF_RESET, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -27,7 +28,6 @@ from esphome.const import ( CONF_CURRENT_REFERENCE = "current_reference" CONF_ENERGY_REFERENCE = "energy_reference" CONF_POWER_REFERENCE = "power_reference" -CONF_RESET = "reset" CONF_VOLTAGE_REFERENCE = "voltage_reference" DEPENDENCIES = ["uart"] diff --git a/esphome/components/wl_134/text_sensor.py b/esphome/components/wl_134/text_sensor.py index d10627ab64..1a10396bc6 100644 --- a/esphome/components/wl_134/text_sensor.py +++ b/esphome/components/wl_134/text_sensor.py @@ -1,11 +1,10 @@ import esphome.codegen as cg from esphome.components import text_sensor, uart import esphome.config_validation as cv -from esphome.const import ICON_FINGERPRINT +from esphome.const import CONF_RESET, ICON_FINGERPRINT CODEOWNERS = ["@hobbypunk90"] DEPENDENCIES = ["uart"] -CONF_RESET = "reset" wl134_ns = cg.esphome_ns.namespace("wl_134") Wl134Component = wl134_ns.class_( diff --git a/esphome/const.py b/esphome/const.py index eab979e88d..199064dc3a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -735,6 +735,7 @@ CONF_REFRESH = "refresh" CONF_RELABEL = "relabel" CONF_REPEAT = "repeat" CONF_REPOSITORY = "repository" +CONF_RESET = "reset" CONF_RESET_DURATION = "reset_duration" CONF_RESET_PIN = "reset_pin" CONF_RESIZE = "resize" From d4c4b75eb332207c0fee0b15a916f099d57f32db Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 24 May 2025 09:15:24 -0500 Subject: [PATCH 04/30] [esp32] Fix building on IDF 4 (#8892) --- esphome/components/esp32/core.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index c90d68d00e..562bcba3c2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -15,8 +15,9 @@ #ifdef USE_ARDUINO #include #else +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include - +#endif void setup(); void loop(); #endif @@ -63,7 +64,13 @@ uint32_t arch_get_cpu_cycle_count() { return cpu_hal_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; #ifdef USE_ESP_IDF +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); +#else + rtc_cpu_freq_config_t config; + rtc_clk_cpu_freq_get_config(&config); + freq = config.freq_mhz * 1000000U; +#endif #elif defined(USE_ARDUINO) freq = ESP.getCpuFreqMHz() * 1000000; #endif From 4b5c3e7e2b17ec741f59d35eb714101f0b85962e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 25 May 2025 10:08:51 +0200 Subject: [PATCH 05/30] [bme68x_bsec2_i2c] Remove arduino dependency (#7815) --- esphome/components/bme68x_bsec2/__init__.py | 11 ++++++----- esphome/components/bme68x_bsec2/bme68x_bsec2.cpp | 1 + .../components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp | 1 + .../bme68x_bsec2_i2c/test.esp32-c3-idf.yaml | 5 +++++ tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml | 5 +++++ .../bme68x_bsec2_i2c/test.esp32-s2-idf.yaml | 5 +++++ .../bme68x_bsec2_i2c/test.esp32-s3-idf.yaml | 5 +++++ .../components/bme68x_bsec2_i2c/test.rp2040-ard.yaml | 5 +++++ 8 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml create mode 100644 tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml create mode 100644 tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml create mode 100644 tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml create mode 100644 tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index d6dbb52f18..f4235b31b4 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -16,7 +16,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] DOMAIN = "bme68x_bsec2" -BSEC2_LIBRARY_VERSION = "v1.8.2610" +BSEC2_LIBRARY_VERSION = "1.10.2610" CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" @@ -145,7 +145,6 @@ CONFIG_SCHEMA_BASE = ( ): cv.positive_time_period_minutes, }, ) - .add_extra(cv.only_with_arduino) .add_extra(validate_bme68x) .add_extra(download_bme68x_blob) ) @@ -179,11 +178,13 @@ async def to_code_base(config): bsec2_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) cg.add(var.set_bsec2_configuration(bsec2_arr, len(rhs))) - # Although this component does not use SPI, the BSEC2 library requires the SPI library - cg.add_library("SPI", None) + # Although this component does not use SPI, the BSEC2 Arduino library requires the SPI library + if core.CORE.using_arduino: + cg.add_library("SPI", None) cg.add_library( "BME68x Sensor library", - "1.1.40407", + "1.3.40408", + "https://github.com/boschsensortec/Bosch-BME68x-Library", ) cg.add_library( "BSEC2 Software Library", diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f83f20f1a5..07ad1fde90 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -1,4 +1,5 @@ #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp index 874c8bf388..50eaf33add 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp @@ -1,4 +1,5 @@ #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..84a9dd4bb4 --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO6 + sda_pin: GPIO7 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml b/tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml new file mode 100644 index 0000000000..ee2c29ca4e --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO5 + sda_pin: GPIO4 + +<<: !include common.yaml From 1e18d0b06c6ada7d7c3203a371e8202dfc65a271 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 26 May 2025 11:55:51 +1200 Subject: [PATCH 06/30] [i2s_audio] Add basic support for esp32-p4 (#8887) --- esphome/components/i2s_audio/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 0d413adb8a..ef95fd0b41 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -4,6 +4,7 @@ from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -74,6 +75,7 @@ I2S_PORTS = { VARIANT_ESP32S2: 1, VARIANT_ESP32S3: 2, VARIANT_ESP32C3: 1, + VARIANT_ESP32P4: 3, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") From ca0037d0760b3927c9a0399d042b430693d18d6a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 25 May 2025 21:33:41 -0400 Subject: [PATCH 07/30] [esp32, logger, core] Add initial c5 support (#8895) --- esphome/components/esp32/__init__.py | 2 + esphome/components/esp32/boards.py | 5 +++ esphome/components/esp32/const.py | 3 ++ esphome/components/esp32/gpio.py | 6 +++ esphome/components/esp32/gpio_esp32_c5.py | 45 +++++++++++++++++++++++ esphome/components/logger/__init__.py | 3 ++ esphome/core/defines.h | 3 +- 7 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 esphome/components/esp32/gpio_esp32_c5.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9c507d8a21..b211015865 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -57,6 +57,7 @@ from .const import ( # noqa VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -88,6 +89,7 @@ CPU_FREQUENCIES = { VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C2: get_cpu_frequencies(80, 120), VARIANT_ESP32C3: get_cpu_frequencies(80, 160), + VARIANT_ESP32C5: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 1a8f6d5332..e7cdac0d8e 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -2,6 +2,7 @@ from .const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -1593,6 +1594,10 @@ BOARDS = { "name": "Ai-Thinker ESP-C3-M1-I-Kit", "variant": VARIANT_ESP32C3, }, + "esp32-c5-devkitc-1": { + "name": "Espressif ESP32-C5-DevKitC-1", + "variant": VARIANT_ESP32C5, + }, "esp32-c6-devkitc-1": { "name": "Espressif ESP32-C6-DevKitC-1", "variant": VARIANT_ESP32C6, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 8ca3905be9..9bef18847f 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -17,6 +17,7 @@ VARIANT_ESP32S2 = "ESP32S2" VARIANT_ESP32S3 = "ESP32S3" VARIANT_ESP32C2 = "ESP32C2" VARIANT_ESP32C3 = "ESP32C3" +VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32H2 = "ESP32H2" VARIANT_ESP32P4 = "ESP32P4" @@ -26,6 +27,7 @@ VARIANTS = [ VARIANT_ESP32S3, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -37,6 +39,7 @@ VARIANT_FRIENDLY = { VARIANT_ESP32S3: "ESP32-S3", VARIANT_ESP32C2: "ESP32-C2", VARIANT_ESP32C3: "ESP32-C3", + VARIANT_ESP32C5: "ESP32-C5", VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32H2: "ESP32-H2", VARIANT_ESP32P4: "ESP32-P4", diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 85bae3f52a..c35e5c2215 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -27,6 +27,7 @@ from .const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -37,6 +38,7 @@ from .const import ( from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports from .gpio_esp32_c2 import esp32_c2_validate_gpio_pin, esp32_c2_validate_supports from .gpio_esp32_c3 import esp32_c3_validate_gpio_pin, esp32_c3_validate_supports +from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_supports from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports @@ -100,6 +102,10 @@ _esp32_validations = { pin_validation=esp32_c3_validate_gpio_pin, usage_validation=esp32_c3_validate_supports, ), + VARIANT_ESP32C5: ESP32ValidationFunctions( + pin_validation=esp32_c5_validate_gpio_pin, + usage_validation=esp32_c5_validate_supports, + ), VARIANT_ESP32C6: ESP32ValidationFunctions( pin_validation=esp32_c6_validate_gpio_pin, usage_validation=esp32_c6_validate_supports, diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py new file mode 100644 index 0000000000..ada426771c --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -0,0 +1,45 @@ +import logging + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +_ESP32C5_SPI_PSRAM_PINS = { + 16: "SPICS0", + 17: "SPIQ", + 18: "SPIWP", + 19: "VDD_SPI", + 20: "SPIHD", + 21: "SPICLK", + 22: "SPID", +} + +_ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_c5_validate_gpio_pin(value): + if value < 0 or value > 28: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-28)") + if value in _ESP32C5_SPI_PSRAM_PINS: + raise cv.Invalid( + f"This pin cannot be used on ESP32-C5s and is already used by the SPI/PSRAM interface (function: {_ESP32C5_SPI_PSRAM_PINS[value]})" + ) + + return value + + +def esp32_c5_validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 28: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-28)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 1a4d645c8a..462cae73b6 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -8,6 +8,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -89,6 +90,7 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C2: [UART0, UART1], + VARIANT_ESP32C5: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], @@ -207,6 +209,7 @@ CONFIG_SCHEMA = cv.All( esp32_s3_idf=USB_SERIAL_JTAG, esp32_c3_arduino=USB_CDC, esp32_c3_idf=USB_SERIAL_JTAG, + esp32_c5_idf=USB_SERIAL_JTAG, esp32_c6_arduino=USB_CDC, esp32_c6_idf=USB_SERIAL_JTAG, esp32_p4_idf=USB_SERIAL_JTAG, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 770e091205..455b404e32 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -160,7 +160,8 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #elif defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) + defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) #define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif From 5921a9cd684360f0e22d3539fbd93611b70227e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Mon, 26 May 2025 10:45:47 +0300 Subject: [PATCH 08/30] Resolve regex library warnings (#8890) --- esphome/cpp_generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e7d6195915..e2d067390d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -416,7 +416,9 @@ class LineComment(Statement): self.value = value def __str__(self): - parts = re.sub(r"\\\s*\n", r"\n", self.value, re.MULTILINE).split("\n") + parts = re.sub(r"\\\s*\n", r"\n", self.value, flags=re.MULTILINE).split( + "\n" + ) parts = [f"// {x}" for x in parts] return "\n".join(parts) From 430f63fcbb9e97681161709a51f5b02f353f73e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 08:13:45 +0000 Subject: [PATCH 09/30] Bump pyupgrade from 3.19.1 to 3.20.0 (#8891) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a76d5dd9b9..d55c00eea7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: - --branch=release - --branch=beta - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v3.20.0 hooks: - id: pyupgrade args: [--py310-plus] diff --git a/requirements_test.txt b/requirements_test.txt index b1f3355fbd..76be8aa0af 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==3.3.7 flake8==7.2.0 # also change in .pre-commit-config.yaml when updating ruff==0.11.11 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.19.1 # also change in .pre-commit-config.yaml when updating +pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests From af7b1a3a23460bde5e479a280f8c68ee58b13fcd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 27 May 2025 06:46:51 +1200 Subject: [PATCH 10/30] [api] Fix crash with gcc compiler on host (#8902) --- esphome/components/api/api_connection.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 27dd44ae86..b4646a2d7d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -4,11 +4,11 @@ #include #include #include "esphome/components/network/util.h" +#include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" -#include "esphome/core/application.h" #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -153,7 +153,11 @@ void APIConnection::loop() { } else { this->last_traffic_ = App.get_loop_component_start_time(); // read a packet - this->read_message(buffer.data_len, buffer.type, &buffer.container[buffer.data_offset]); + if (buffer.data_len > 0) { + this->read_message(buffer.data_len, buffer.type, &buffer.container[buffer.data_offset]); + } else { + this->read_message(0, buffer.type, nullptr); + } if (this->remove_) return; } From 73771d5c50467a771d76cca1efe9e489662b09f7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 27 May 2025 09:08:16 +1200 Subject: [PATCH 11/30] [web_server] Fix download list where external_components has a substitution value (#8911) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/dashboard/web_server.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index a297885782..529a0815b8 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -601,10 +601,12 @@ class DownloadListRequestHandler(BaseHandler): loop = asyncio.get_running_loop() try: downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid: + except vol.Invalid as exc: + _LOGGER.exception("Error while fetching downloads", exc_info=exc) self.send_error(404) return if downloads_json is None: + _LOGGER.error("Configuration %s not found", configuration) self.send_error(404) return self.set_status(200) @@ -618,14 +620,17 @@ class DownloadListRequestHandler(BaseHandler): if storage_json is None: return None - config = yaml_util.load_yaml(settings.rel_path(configuration)) + try: + config = yaml_util.load_yaml(settings.rel_path(configuration)) - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) + if const.CONF_EXTERNAL_COMPONENTS in config: + from esphome.components.external_components import ( + do_external_components_pass, + ) - do_external_components_pass(config) + do_external_components_pass(config) + except vol.Invalid: + _LOGGER.info("Could not parse `external_components`, skipping") from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS From 4ac433fddbeca8b001778398aa2dc4b9752de3fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 May 2025 21:31:32 -0500 Subject: [PATCH 12/30] Add integration tests for host (#8912) --- .coveragerc | 4 +- .github/workflows/ci.yml | 4 +- requirements_test.txt | 1 + script/integration_test | 10 + tests/integration/README.md | 80 ++++ tests/integration/__init__.py | 3 + tests/integration/conftest.py | 402 ++++++++++++++++++ tests/integration/const.py | 14 + .../integration/fixtures/host_mode_basic.yaml | 5 + .../fixtures/host_mode_noise_encryption.yaml | 7 + .../host_mode_noise_encryption_wrong_key.yaml | 7 + .../fixtures/host_mode_reconnect.yaml | 5 + .../fixtures/host_mode_with_sensor.yaml | 12 + tests/integration/test_host_mode_basic.py | 22 + .../test_host_mode_noise_encryption.py | 53 +++ tests/integration/test_host_mode_reconnect.py | 28 ++ tests/integration/test_host_mode_sensor.py | 49 +++ tests/integration/types.py | 46 ++ 18 files changed, 749 insertions(+), 3 deletions(-) create mode 100755 script/integration_test create mode 100644 tests/integration/README.md create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/const.py create mode 100644 tests/integration/fixtures/host_mode_basic.yaml create mode 100644 tests/integration/fixtures/host_mode_noise_encryption.yaml create mode 100644 tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml create mode 100644 tests/integration/fixtures/host_mode_reconnect.yaml create mode 100644 tests/integration/fixtures/host_mode_with_sensor.yaml create mode 100644 tests/integration/test_host_mode_basic.py create mode 100644 tests/integration/test_host_mode_noise_encryption.py create mode 100644 tests/integration/test_host_mode_reconnect.py create mode 100644 tests/integration/test_host_mode_sensor.py create mode 100644 tests/integration/types.py diff --git a/.coveragerc b/.coveragerc index 723242b288..12e48ec395 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,4 @@ [run] -omit = esphome/components/* +omit = + esphome/components/* + tests/integration/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35488d96b..377cd02c56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,12 +214,12 @@ jobs: if: matrix.os == 'windows-latest' run: | ./venv/Scripts/activate - pytest -vv --cov-report=xml --tb=native tests + pytest -vv --cov-report=xml --tb=native -n auto tests - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' run: | . venv/bin/activate - pytest -vv --cov-report=xml --tb=native tests + pytest -vv --cov-report=xml --tb=native -n auto tests - name: Upload coverage to Codecov uses: codecov/codecov-action@v5.4.3 with: diff --git a/requirements_test.txt b/requirements_test.txt index 76be8aa0af..8486a764f6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,5 +9,6 @@ pytest==8.3.5 pytest-cov==6.1.1 pytest-mock==3.14.0 pytest-asyncio==0.26.0 +pytest-xdist==3.6.1 asyncmock==0.4.2 hypothesis==6.92.1 diff --git a/script/integration_test b/script/integration_test new file mode 100755 index 0000000000..d637cdd298 --- /dev/null +++ b/script/integration_test @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "${script_dir}/.." + +set -x + +pytest -vvs --no-cov --tb=native -n 0 tests/integration/ diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000000..26bd5a00ee --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,80 @@ +# ESPHome Integration Tests + +This directory contains end-to-end integration tests for ESPHome, focusing on testing the complete flow from YAML configuration to running devices with API connections. + +## Structure + +- `conftest.py` - Common fixtures and utilities +- `const.py` - Constants used throughout the integration tests +- `types.py` - Type definitions for fixtures and functions +- `fixtures/` - YAML configuration files for tests +- `test_*.py` - Individual test files + +## How it works + +### Automatic YAML Loading + +The `yaml_config` fixture automatically loads YAML configurations based on the test name: +- It looks for a file named after the test function (e.g., `test_host_mode_basic` → `fixtures/host_mode_basic.yaml`) +- The fixture file must exist or the test will fail with a clear error message +- The fixture automatically injects a dynamic port number into the API configuration + +### Key Fixtures + +- `run_compiled` - Combines write, compile, and run operations into a single context manager +- `api_client_connected` - Creates an API client that automatically connects using ReconnectLogic +- `reserved_tcp_port` - Reserves a TCP port by holding the socket open until ESPHome needs it +- `unused_tcp_port` - Provides the reserved port number for each test + +### Writing Tests + +The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures: + +```python +@pytest.mark.asyncio +async def test_my_feature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Test your feature using the connected client + device_info = await client.device_info() + assert device_info is not None +``` + +### Creating YAML Fixtures + +Create a YAML file in the `fixtures/` directory with the same name as your test function (without the `test_` prefix): + +```yaml +# fixtures/my_feature.yaml +esphome: + name: my-test-device +host: +api: # Port will be automatically injected +logger: +# Add your components here +``` + +## Running Tests + +```bash +# Run all integration tests +script/integration_test + +# Run a specific test +pytest -vv tests/integration/test_host_mode_basic.py + +# Debug compilation errors or see ESPHome output +pytest -s tests/integration/test_host_mode_basic.py +``` + +## Implementation Details + +- Tests automatically wait for the API port to be available before connecting +- Process cleanup is handled automatically, with graceful shutdown using SIGINT +- Each test gets its own temporary directory and unique port +- Port allocation minimizes race conditions by holding the socket until just before ESPHome starts +- Output from ESPHome processes is displayed for debugging diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000..0cf87d2169 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,3 @@ +"""ESPHome integration tests.""" + +from __future__ import annotations diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000000..0ac5676667 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,402 @@ +"""Common fixtures for integration tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Generator +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from pathlib import Path +import platform +import signal +import socket +import tempfile + +from aioesphomeapi import APIClient, APIConnectionError, ReconnectLogic +import pytest +import pytest_asyncio + +# Skip all integration tests on Windows +if platform.system() == "Windows": + pytest.skip( + "Integration tests are not supported on Windows", allow_module_level=True + ) + +from .const import ( + API_CONNECTION_TIMEOUT, + DEFAULT_API_PORT, + LOCALHOST, + PORT_POLL_INTERVAL, + PORT_WAIT_TIMEOUT, + SIGINT_TIMEOUT, + SIGTERM_TIMEOUT, +) +from .types import ( + APIClientConnectedFactory, + APIClientFactory, + CompileFunction, + ConfigWriter, + RunCompiledFunction, + RunFunction, +) + + +@pytest.fixture +def integration_test_dir() -> Generator[Path]: + """Create a temporary directory for integration tests.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]: + """Reserve an unused TCP port by holding the socket open.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 0)) + port = s.getsockname()[1] + try: + yield port, s + finally: + s.close() + + +@pytest.fixture +def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int: + """Get the reserved TCP port number.""" + return reserved_tcp_port[0] + + +@pytest_asyncio.fixture +async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str: + """Load YAML configuration based on test name.""" + # Get the test function name + test_name: str = request.node.name + # Extract the base test name (remove test_ prefix and any parametrization) + base_name = test_name.replace("test_", "").partition("[")[0] + + # Load the fixture file + fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml" + if not fixture_path.exists(): + raise FileNotFoundError(f"Fixture file not found: {fixture_path}") + + loop = asyncio.get_running_loop() + content = await loop.run_in_executor(None, fixture_path.read_text) + + # Replace the port in the config if it contains api section + if "api:" in content: + # Add port configuration after api: + content = content.replace("api:", f"api:\n port: {unused_tcp_port}") + + return content + + +@pytest_asyncio.fixture +async def write_yaml_config( + integration_test_dir: Path, request: pytest.FixtureRequest +) -> AsyncGenerator[ConfigWriter]: + """Write YAML configuration to a file.""" + # Get the test name for default filename + test_name = request.node.name + base_name = test_name.replace("test_", "").split("[")[0] + + async def _write_config(content: str, filename: str | None = None) -> Path: + if filename is None: + filename = f"{base_name}.yaml" + config_path = integration_test_dir / filename + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, config_path.write_text, content) + return config_path + + yield _write_config + + +async def _run_esphome_command( + command: str, + config_path: Path, + cwd: Path, +) -> asyncio.subprocess.Process: + """Run an ESPHome command with the given arguments.""" + return await asyncio.create_subprocess_exec( + "esphome", + command, + str(config_path), + cwd=cwd, + stdout=None, # Inherit stdout + stderr=None, # Inherit stderr + stdin=asyncio.subprocess.DEVNULL, + # Start in a new process group to isolate signal handling + start_new_session=True, + ) + + +@pytest_asyncio.fixture +async def compile_esphome( + integration_test_dir: Path, +) -> AsyncGenerator[CompileFunction]: + """Compile an ESPHome configuration.""" + + async def _compile(config_path: Path) -> None: + proc = await _run_esphome_command("compile", config_path, integration_test_dir) + await proc.wait() + if proc.returncode != 0: + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) + + yield _compile + + +@pytest_asyncio.fixture +async def run_esphome_process( + integration_test_dir: Path, +) -> AsyncGenerator[RunFunction]: + """Run an ESPHome process and manage its lifecycle.""" + processes: list[asyncio.subprocess.Process] = [] + + async def _run(config_path: Path) -> asyncio.subprocess.Process: + process = await _run_esphome_command("run", config_path, integration_test_dir) + processes.append(process) + return process + + yield _run + + # Cleanup: terminate all "run" processes gracefully + for process in processes: + if process.returncode is None: + # Send SIGINT (Ctrl+C) for graceful shutdown of the running ESPHome instance + process.send_signal(signal.SIGINT) + try: + await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT) + except asyncio.TimeoutError: + # If SIGINT didn't work, try SIGTERM + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT) + except asyncio.TimeoutError: + # Last resort: SIGKILL + process.kill() + await process.wait() + + +@asynccontextmanager +async def create_api_client( + address: str = LOCALHOST, + port: int = DEFAULT_API_PORT, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", +) -> AsyncGenerator[APIClient]: + """Create an API client context manager.""" + client = APIClient( + address=address, + port=port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + ) + try: + yield client + finally: + await client.disconnect() + + +@pytest_asyncio.fixture +async def api_client_factory( + unused_tcp_port: int, +) -> AsyncGenerator[APIClientFactory]: + """Factory for creating API client context managers.""" + + def _create_client( + address: str = LOCALHOST, + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + ) -> AbstractAsyncContextManager[APIClient]: + return create_api_client( + address=address, + port=port if port is not None else unused_tcp_port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + ) + + yield _create_client + + +@asynccontextmanager +async def wait_and_connect_api_client( + address: str = LOCALHOST, + port: int = DEFAULT_API_PORT, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = API_CONNECTION_TIMEOUT, +) -> AsyncGenerator[APIClient]: + """Wait for API to be available and connect.""" + client = APIClient( + address=address, + port=port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + ) + + # Create a future to signal when connected + loop = asyncio.get_running_loop() + connected_future: asyncio.Future[None] = loop.create_future() + + async def on_connect() -> None: + """Called when successfully connected.""" + if not connected_future.done(): + connected_future.set_result(None) + + async def on_disconnect(expected_disconnect: bool) -> None: + """Called when disconnected.""" + if not connected_future.done() and not expected_disconnect: + connected_future.set_exception( + APIConnectionError("Disconnected before fully connected") + ) + + async def on_connect_error(err: Exception) -> None: + """Called when connection fails.""" + if not connected_future.done(): + connected_future.set_exception(err) + + # Create and start the reconnect logic + reconnect_logic = ReconnectLogic( + client=client, + on_connect=on_connect, + on_disconnect=on_disconnect, + zeroconf_instance=None, # Not using zeroconf for integration tests + name=f"{address}:{port}", + on_connect_error=on_connect_error, + ) + + try: + # Start the connection + await reconnect_logic.start() + + # Wait for connection with timeout + try: + await asyncio.wait_for(connected_future, timeout=timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"Failed to connect to API after {timeout} seconds") + + yield client + finally: + # Stop reconnect logic and disconnect + await reconnect_logic.stop() + await client.disconnect() + + +@pytest_asyncio.fixture +async def api_client_connected( + unused_tcp_port: int, +) -> AsyncGenerator[APIClientConnectedFactory]: + """Factory for creating connected API client context managers.""" + + def _connect_client( + address: str = LOCALHOST, + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = API_CONNECTION_TIMEOUT, + ) -> AbstractAsyncContextManager[APIClient]: + return wait_and_connect_api_client( + address=address, + port=port if port is not None else unused_tcp_port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + timeout=timeout, + ) + + yield _connect_client + + +async def wait_for_port_open( + host: str, port: int, timeout: float = PORT_WAIT_TIMEOUT +) -> None: + """Wait for a TCP port to be open and accepting connections.""" + loop = asyncio.get_running_loop() + start_time = loop.time() + + # Small yield to ensure the process has a chance to start + await asyncio.sleep(0) + + while loop.time() - start_time < timeout: + try: + # Try to connect to the port + _, writer = await asyncio.open_connection(host, port) + writer.close() + await writer.wait_closed() + return # Port is open + except (ConnectionRefusedError, OSError): + # Port not open yet, wait a bit and try again + await asyncio.sleep(PORT_POLL_INTERVAL) + + raise TimeoutError(f"Port {port} on {host} did not open within {timeout} seconds") + + +@asynccontextmanager +async def run_compiled_context( + yaml_content: str, + filename: str | None, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + run_esphome_process: RunFunction, + port: int, + port_socket: socket.socket | None = None, +) -> AsyncGenerator[asyncio.subprocess.Process]: + """Context manager to write, compile and run an ESPHome configuration.""" + # Write the YAML config + config_path = await write_yaml_config(yaml_content, filename) + + # Compile the configuration + await compile_esphome(config_path) + + # Close the port socket right before running to release the port + if port_socket is not None: + port_socket.close() + + # Run the ESPHome device + process = await run_esphome_process(config_path) + assert process.returncode is None, "Process died immediately" + + # Wait for the API server to start listening + await wait_for_port_open(LOCALHOST, port, timeout=PORT_WAIT_TIMEOUT) + + try: + yield process + finally: + # Process cleanup is handled by run_esphome_process fixture + pass + + +@pytest_asyncio.fixture +async def run_compiled( + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + run_esphome_process: RunFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> AsyncGenerator[RunCompiledFunction]: + """Write, compile and run an ESPHome configuration.""" + port, port_socket = reserved_tcp_port + + def _run_compiled( + yaml_content: str, filename: str | None = None + ) -> AbstractAsyncContextManager[asyncio.subprocess.Process]: + return run_compiled_context( + yaml_content, + filename, + write_yaml_config, + compile_esphome, + run_esphome_process, + port, + port_socket, + ) + + yield _run_compiled diff --git a/tests/integration/const.py b/tests/integration/const.py new file mode 100644 index 0000000000..db5e8f5ae1 --- /dev/null +++ b/tests/integration/const.py @@ -0,0 +1,14 @@ +"""Constants for integration tests.""" + +# Network constants +DEFAULT_API_PORT = 6053 +LOCALHOST = "localhost" + +# Timeout constants +API_CONNECTION_TIMEOUT = 30.0 # seconds +PORT_WAIT_TIMEOUT = 30.0 # seconds +PORT_POLL_INTERVAL = 0.1 # seconds + +# Process shutdown timeouts +SIGINT_TIMEOUT = 5.0 # seconds +SIGTERM_TIMEOUT = 2.0 # seconds diff --git a/tests/integration/fixtures/host_mode_basic.yaml b/tests/integration/fixtures/host_mode_basic.yaml new file mode 100644 index 0000000000..9bcda57f4f --- /dev/null +++ b/tests/integration/fixtures/host_mode_basic.yaml @@ -0,0 +1,5 @@ +esphome: + name: host-test +host: +api: +logger: diff --git a/tests/integration/fixtures/host_mode_noise_encryption.yaml b/tests/integration/fixtures/host_mode_noise_encryption.yaml new file mode 100644 index 0000000000..83605e28a3 --- /dev/null +++ b/tests/integration/fixtures/host_mode_noise_encryption.yaml @@ -0,0 +1,7 @@ +esphome: + name: host-noise-test +host: +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= +logger: diff --git a/tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml b/tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml new file mode 100644 index 0000000000..83605e28a3 --- /dev/null +++ b/tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml @@ -0,0 +1,7 @@ +esphome: + name: host-noise-test +host: +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= +logger: diff --git a/tests/integration/fixtures/host_mode_reconnect.yaml b/tests/integration/fixtures/host_mode_reconnect.yaml new file mode 100644 index 0000000000..f240e4b2fe --- /dev/null +++ b/tests/integration/fixtures/host_mode_reconnect.yaml @@ -0,0 +1,5 @@ +esphome: + name: host-reconnect-test +host: +api: +logger: diff --git a/tests/integration/fixtures/host_mode_with_sensor.yaml b/tests/integration/fixtures/host_mode_with_sensor.yaml new file mode 100644 index 0000000000..fecd0b435b --- /dev/null +++ b/tests/integration/fixtures/host_mode_with_sensor.yaml @@ -0,0 +1,12 @@ +esphome: + name: host-sensor-test +host: +api: +logger: +sensor: + - platform: template + name: Test Sensor + id: test_sensor + unit_of_measurement: °C + lambda: return 42.0; + update_interval: 0.1s diff --git a/tests/integration/test_host_mode_basic.py b/tests/integration/test_host_mode_basic.py new file mode 100644 index 0000000000..fd52979784 --- /dev/null +++ b/tests/integration/test_host_mode_basic.py @@ -0,0 +1,22 @@ +"""Basic integration test for Host mode.""" + +from __future__ import annotations + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_basic( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test basic Host mode functionality with API connection.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-test" diff --git a/tests/integration/test_host_mode_noise_encryption.py b/tests/integration/test_host_mode_noise_encryption.py new file mode 100644 index 0000000000..53873f2760 --- /dev/null +++ b/tests/integration/test_host_mode_noise_encryption.py @@ -0,0 +1,53 @@ +"""Integration test for Host mode with noise encryption.""" + +from __future__ import annotations + +from aioesphomeapi import InvalidEncryptionKeyAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The API key for noise encryption +NOISE_KEY = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" + + +@pytest.mark.asyncio +async def test_host_mode_noise_encryption( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test Host mode with noise encryption enabled.""" + # Write, compile and run the ESPHome device, then connect to API + # The API client should handle noise encryption automatically + async with ( + run_compiled(yaml_config), + api_client_connected(noise_psk=NOISE_KEY) as client, + ): + # If we can get device info, the encryption is working + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-noise-test" + + # List entities to ensure the encrypted connection is fully functional + entities = await client.list_entities_services() + assert entities is not None + + +@pytest.mark.asyncio +async def test_host_mode_noise_encryption_wrong_key( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that connection fails with wrong encryption key.""" + # Write, compile and run the ESPHome device + async with run_compiled(yaml_config): + # Try to connect with wrong key - should fail with InvalidEncryptionKeyAPIError + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk="wrong_key_that_should_not_work", + timeout=5, # Shorter timeout for expected failure + ) as client: + # This should not be reached + await client.device_info() diff --git a/tests/integration/test_host_mode_reconnect.py b/tests/integration/test_host_mode_reconnect.py new file mode 100644 index 0000000000..8f69193559 --- /dev/null +++ b/tests/integration/test_host_mode_reconnect.py @@ -0,0 +1,28 @@ +"""Integration test for Host mode reconnection.""" + +from __future__ import annotations + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_reconnect( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test reconnecting to a Host mode device.""" + # Write, compile and run the ESPHome device + async with run_compiled(yaml_config): + # First connection + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Reconnect with a new client + async with api_client_connected() as client2: + device_info2 = await client2.device_info() + assert device_info2 is not None + assert device_info2.name == device_info.name diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py new file mode 100644 index 0000000000..f0c938da1c --- /dev/null +++ b/tests/integration/test_host_mode_sensor.py @@ -0,0 +1,49 @@ +"""Integration test for Host mode with sensor.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_with_sensor( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test Host mode with a sensor component.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Subscribe to state changes + states: dict[int, EntityState] = {} + sensor_future: asyncio.Future[EntityState] = asyncio.Future() + + def on_state(state: EntityState) -> None: + states[state.key] = state + # If this is our sensor with value 42.0, resolve the future + if ( + hasattr(state, "state") + and state.state == 42.0 + and not sensor_future.done() + ): + sensor_future.set_result(state) + + client.subscribe_states(on_state) + + # Wait for sensor with specific value (42.0) with timeout + try: + test_sensor_state = await asyncio.wait_for(sensor_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail( + f"Sensor with value 42.0 not received within 5 seconds. " + f"Received states: {list(states.values())}" + ) + + # Verify the sensor state + assert test_sensor_state.state == 42.0 + assert len(states) > 0, "No states received" diff --git a/tests/integration/types.py b/tests/integration/types.py new file mode 100644 index 0000000000..ef1af2add8 --- /dev/null +++ b/tests/integration/types.py @@ -0,0 +1,46 @@ +"""Type definitions for integration tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager +from pathlib import Path +from typing import Protocol + +from aioesphomeapi import APIClient + +ConfigWriter = Callable[[str, str | None], Awaitable[Path]] +CompileFunction = Callable[[Path], Awaitable[None]] +RunFunction = Callable[[Path], Awaitable[asyncio.subprocess.Process]] +RunCompiledFunction = Callable[ + [str, str | None], AbstractAsyncContextManager[asyncio.subprocess.Process] +] +WaitFunction = Callable[[APIClient, float], Awaitable[bool]] + + +class APIClientFactory(Protocol): + """Protocol for API client factory.""" + + def __call__( # noqa: E704 + self, + address: str = "localhost", + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + ) -> AbstractAsyncContextManager[APIClient]: ... + + +class APIClientConnectedFactory(Protocol): + """Protocol for connected API client factory.""" + + def __call__( # noqa: E704 + self, + address: str = "localhost", + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = 30, + ) -> AbstractAsyncContextManager[APIClient]: ... From da4e710249c47b640b8bf8786ba9e8711cef2dd7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 21 May 2025 20:02:14 +1200 Subject: [PATCH 13/30] [core] Add some missing includes (#8864) --- esphome/core/helpers.h | 1 + esphome/core/string_ref.h | 1 + 2 files changed, 2 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 7866eaa9bd..4212aeca98 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index f3dc3a38b0..c4320107e3 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include From a5f85b4437fc8442a426b2d03d29a79b22eb5135 Mon Sep 17 00:00:00 2001 From: Cossid <83468485+Cossid@users.noreply.github.com> Date: Wed, 21 May 2025 20:26:19 -0500 Subject: [PATCH 14/30] [tuya_select] - Fix datapoint config error. (#8871) --- esphome/components/tuya/select/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/tuya/select/__init__.py b/esphome/components/tuya/select/__init__.py index e5b2e36ce7..9f2b6f1e12 100644 --- a/esphome/components/tuya/select/__init__.py +++ b/esphome/components/tuya/select/__init__.py @@ -54,8 +54,8 @@ async def to_code(config): cg.add(var.set_select_mappings(list(options_map.keys()))) parent = await cg.get_variable(config[CONF_TUYA_ID]) cg.add(var.set_tuya_parent(parent)) - if enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None) is not None: + if (enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None)) is not None: cg.add(var.set_select_id(enum_datapoint, False)) - if int_datapoint := config.get(CONF_INT_DATAPOINT, None) is not None: + if (int_datapoint := config.get(CONF_INT_DATAPOINT, None)) is not None: cg.add(var.set_select_id(int_datapoint, True)) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) From 5c54f75b7a5520e50460f2fdffead74c6dc9976a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 22 May 2025 13:40:53 +1200 Subject: [PATCH 15/30] [online_image] Allocate pngle manually to potentially use psram (#8354) Co-authored-by: Keith Burzinski --- esphome/components/online_image/__init__.py | 2 +- esphome/components/online_image/png_image.cpp | 22 ++++++++++++++++++- esphome/components/online_image/png_image.h | 8 ++++--- platformio.ini | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 6b69bc240b..55b9037176 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -75,7 +75,7 @@ class PNGFormat(Format): def actions(self): cg.add_define("USE_ONLINE_IMAGE_PNG_SUPPORT") - cg.add_library("pngle", "1.0.2") + cg.add_library("pngle", "1.1.0") IMAGE_FORMATS = { diff --git a/esphome/components/online_image/png_image.cpp b/esphome/components/online_image/png_image.cpp index fc5fb554bf..2038d09ed0 100644 --- a/esphome/components/online_image/png_image.cpp +++ b/esphome/components/online_image/png_image.cpp @@ -34,12 +34,32 @@ static void init_callback(pngle_t *pngle, uint32_t w, uint32_t h) { * @param h The height of the rectangle to draw. * @param rgba The color to paint the rectangle in. */ -static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint8_t rgba[4]) { +static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, const uint8_t rgba[4]) { PngDecoder *decoder = (PngDecoder *) pngle_get_user_data(pngle); Color color(rgba[0], rgba[1], rgba[2], rgba[3]); decoder->draw(x, y, w, h, color); } +PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) { + { + pngle_t *pngle = this->allocator_.allocate(1, PNGLE_T_SIZE); + if (!pngle) { + ESP_LOGE(TAG, "Failed to allocate memory for PNGLE engine!"); + return; + } + memset(pngle, 0, PNGLE_T_SIZE); + pngle_reset(pngle); + this->pngle_ = pngle; + } +} + +PngDecoder::~PngDecoder() { + if (this->pngle_) { + pngle_reset(this->pngle_); + this->allocator_.deallocate(this->pngle_, PNGLE_T_SIZE); + } +} + int PngDecoder::prepare(size_t download_size) { ImageDecoder::prepare(download_size); if (!this->pngle_) { diff --git a/esphome/components/online_image/png_image.h b/esphome/components/online_image/png_image.h index 39f445c588..46519f8ef4 100644 --- a/esphome/components/online_image/png_image.h +++ b/esphome/components/online_image/png_image.h @@ -1,7 +1,8 @@ #pragma once -#include "image_decoder.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "image_decoder.h" #ifdef USE_ONLINE_IMAGE_PNG_SUPPORT #include @@ -18,13 +19,14 @@ class PngDecoder : public ImageDecoder { * * @param display The image to decode the stream into. */ - PngDecoder(OnlineImage *image) : ImageDecoder(image), pngle_(pngle_new()) {} - ~PngDecoder() override { pngle_destroy(this->pngle_); } + PngDecoder(OnlineImage *image); + ~PngDecoder() override; int prepare(size_t download_size) override; int HOT decode(uint8_t *buffer, size_t size) override; protected: + RAMAllocator allocator_; pngle_t *pngle_; }; diff --git a/platformio.ini b/platformio.ini index 292188c6fa..23ed89c262 100644 --- a/platformio.ini +++ b/platformio.ini @@ -40,7 +40,7 @@ lib_deps = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - kikuchan98/pngle@1.0.2 ; online_image + kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image ; This is using the repository until a new release is published to PlatformIO From b1d5ad27f3e2f4abb1bfa6d0bf2d532131f480ff Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 22 May 2025 11:49:56 +1000 Subject: [PATCH 16/30] [lvgl] Improve error messages from text validation (#8872) --- esphome/components/lvgl/schemas.py | 54 +++++++++++++++++++----------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index d0dde01421..b51b6b8a85 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -36,29 +36,43 @@ from .types import ( # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} +TIME_TEXT_SCHEMA = cv.Schema( + { + cv.Required(CONF_TIME_FORMAT): cv.string, + cv.GenerateID(CONF_TIME): cv.templatable(cv.use_id(RealTimeClock)), + } +) + +PRINTF_TEXT_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_FORMAT): cv.string, + cv.Optional(CONF_ARGS, default=list): cv.ensure_list(cv.lambda_), + }, + ), + validate_printf, +) + + +def _validate_text(value): + """ + Do some sanity checking of the format to get better error messages + than using cv.Any + """ + if value is None: + raise cv.Invalid("No text specified") + if isinstance(value, dict): + if CONF_TIME_FORMAT in value: + return TIME_TEXT_SCHEMA(value) + return PRINTF_TEXT_SCHEMA(value) + + return cv.templatable(cv.string)(value) + + # A schema for text properties TEXT_SCHEMA = cv.Schema( { - cv.Optional(CONF_TEXT): cv.Any( - cv.All( - cv.Schema( - { - cv.Required(CONF_FORMAT): cv.string, - cv.Optional(CONF_ARGS, default=list): cv.ensure_list( - cv.lambda_ - ), - }, - ), - validate_printf, - ), - cv.Schema( - { - cv.Required(CONF_TIME_FORMAT): cv.string, - cv.GenerateID(CONF_TIME): cv.templatable(cv.use_id(RealTimeClock)), - } - ), - cv.templatable(cv.string), - ) + cv.Optional(CONF_TEXT): _validate_text, } ) From 90e3c5bba23a073c88c5353cd4a12105b7ab182b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 22 May 2025 14:19:16 -0500 Subject: [PATCH 17/30] [micro_wake_word] avoid duplicated detections from same event (#8877) --- esphome/components/micro_wake_word/streaming_model.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index ce3d8c2e4c..38b88557e6 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -147,7 +147,11 @@ bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCES this->recent_streaming_probabilities_[this->last_n_index_] = output->data.uint8[0]; // probability; this->unprocessed_probability_status_ = true; } - this->ignore_windows_ = std::min(this->ignore_windows_ + 1, 0); + if (this->recent_streaming_probabilities_[this->last_n_index_] < this->probability_cutoff_) { + // Only increment ignore windows if less than the probability cutoff; this forces the model to "cool-off" from a + // previous detection and calling ``reset_probabilities`` so it avoids duplicate detections + this->ignore_windows_ = std::min(this->ignore_windows_ + 1, 0); + } } return true; } From e4f3a952d512c2a4142f015ffe3c18f7de7f13f8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 22 May 2025 14:20:15 -0500 Subject: [PATCH 18/30] [speaker] ensure the pipeline returns an error state before returning its stopped (#8878) --- .../speaker/media_player/audio_pipeline.cpp | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 60f562cc2c..ac122b6e0c 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -174,6 +174,16 @@ AudioPipelineState AudioPipeline::process_state() { } } + if ((event_bits & EventGroupBits::READER_MESSAGE_ERROR)) { + xEventGroupClearBits(this->event_group_, EventGroupBits::READER_MESSAGE_ERROR); + return AudioPipelineState::ERROR_READING; + } + + if ((event_bits & EventGroupBits::DECODER_MESSAGE_ERROR)) { + xEventGroupClearBits(this->event_group_, EventGroupBits::DECODER_MESSAGE_ERROR); + return AudioPipelineState::ERROR_DECODING; + } + if ((event_bits & EventGroupBits::READER_MESSAGE_FINISHED) && (!(event_bits & EventGroupBits::READER_MESSAGE_LOADED_MEDIA_TYPE) && (event_bits & EventGroupBits::DECODER_MESSAGE_FINISHED))) { @@ -203,16 +213,6 @@ AudioPipelineState AudioPipeline::process_state() { return AudioPipelineState::STOPPED; } - if ((event_bits & EventGroupBits::READER_MESSAGE_ERROR)) { - xEventGroupClearBits(this->event_group_, EventGroupBits::READER_MESSAGE_ERROR); - return AudioPipelineState::ERROR_READING; - } - - if ((event_bits & EventGroupBits::DECODER_MESSAGE_ERROR)) { - xEventGroupClearBits(this->event_group_, EventGroupBits::DECODER_MESSAGE_ERROR); - return AudioPipelineState::ERROR_DECODING; - } - if (this->pause_state_) { return AudioPipelineState::PAUSED; } From ad20825f31c860351add683982fdfa1966aa8378 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 23 May 2025 11:33:38 +1200 Subject: [PATCH 19/30] [logger] Fix options in select (#8875) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/logger/__init__.py | 2 ++ esphome/components/logger/select/__init__.py | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 4698c1d9f1..4136480629 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( CONF_HARDWARE_UART, CONF_ID, CONF_LEVEL, + CONF_LOGGER, CONF_LOGS, CONF_ON_MESSAGE, CONF_TAG, @@ -247,6 +248,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): baud_rate = config[CONF_BAUD_RATE] level = config[CONF_LEVEL] + CORE.data.setdefault(CONF_LOGGER, {})[CONF_LEVEL] = level initial_level = LOG_LEVELS[config.get(CONF_INITIAL_LEVEL, level)] log = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index b1fc881537..2e83599eb4 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -5,7 +5,7 @@ from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_ from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented -from .. import CONF_LOGGER_ID, LOG_LEVEL_SEVERITY, Logger, logger_ns +from .. import CONF_LOGGER_ID, LOG_LEVELS, Logger, logger_ns CODEOWNERS = ["@clydebarrow"] @@ -21,9 +21,10 @@ CONFIG_SCHEMA = select.select_schema( async def to_code(config): - levels = LOG_LEVEL_SEVERITY - index = levels.index(CORE.config[CONF_LOGGER][CONF_LEVEL]) + parent = await cg.get_variable(config[CONF_LOGGER_ID]) + levels = list(LOG_LEVELS) + index = levels.index(CORE.data[CONF_LOGGER][CONF_LEVEL]) levels = levels[: index + 1] var = await select.new_select(config, options=levels) - await register_parented(var, config[CONF_LOGGER_ID]) + await register_parented(var, parent) await register_component(var, config) From e0e4ba9592db3eb810a300390b569f00e872db45 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 24 May 2025 09:15:24 -0500 Subject: [PATCH 20/30] [esp32] Fix building on IDF 4 (#8892) --- esphome/components/esp32/core.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index c90d68d00e..562bcba3c2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -15,8 +15,9 @@ #ifdef USE_ARDUINO #include #else +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include - +#endif void setup(); void loop(); #endif @@ -63,7 +64,13 @@ uint32_t arch_get_cpu_cycle_count() { return cpu_hal_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; #ifdef USE_ESP_IDF +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); +#else + rtc_cpu_freq_config_t config; + rtc_clk_cpu_freq_get_config(&config); + freq = config.freq_mhz * 1000000U; +#endif #elif defined(USE_ARDUINO) freq = ESP.getCpuFreqMHz() * 1000000; #endif From 6c08f5e343c658a7d8118b959f1a00718857ebc7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 27 May 2025 06:46:51 +1200 Subject: [PATCH 21/30] [api] Fix crash with gcc compiler on host (#8902) --- esphome/components/api/api_connection.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 27dd44ae86..b4646a2d7d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -4,11 +4,11 @@ #include #include #include "esphome/components/network/util.h" +#include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" -#include "esphome/core/application.h" #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -153,7 +153,11 @@ void APIConnection::loop() { } else { this->last_traffic_ = App.get_loop_component_start_time(); // read a packet - this->read_message(buffer.data_len, buffer.type, &buffer.container[buffer.data_offset]); + if (buffer.data_len > 0) { + this->read_message(buffer.data_len, buffer.type, &buffer.container[buffer.data_offset]); + } else { + this->read_message(0, buffer.type, nullptr); + } if (this->remove_) return; } From fdc6c4a21948c991e1f8a8aa720d9a200200cd45 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 27 May 2025 09:08:16 +1200 Subject: [PATCH 22/30] [web_server] Fix download list where external_components has a substitution value (#8911) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/dashboard/web_server.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 6196e01760..ddef2e7e2b 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -601,10 +601,12 @@ class DownloadListRequestHandler(BaseHandler): loop = asyncio.get_running_loop() try: downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid: + except vol.Invalid as exc: + _LOGGER.exception("Error while fetching downloads", exc_info=exc) self.send_error(404) return if downloads_json is None: + _LOGGER.error("Configuration %s not found", configuration) self.send_error(404) return self.set_status(200) @@ -618,14 +620,17 @@ class DownloadListRequestHandler(BaseHandler): if storage_json is None: return None - config = yaml_util.load_yaml(settings.rel_path(configuration)) + try: + config = yaml_util.load_yaml(settings.rel_path(configuration)) - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) + if const.CONF_EXTERNAL_COMPONENTS in config: + from esphome.components.external_components import ( + do_external_components_pass, + ) - do_external_components_pass(config) + do_external_components_pass(config) + except vol.Invalid: + _LOGGER.info("Could not parse `external_components`, skipping") from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS From 42390faf4a064a687e6227b64a15a9f957861840 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 27 May 2025 14:31:38 +1200 Subject: [PATCH 23/30] Bump version to 2025.5.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d22d87bf8e..e5c3c1802d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.5.0 +PROJECT_NUMBER = 2025.5.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 7a61c93428..2fc30beaaa 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1,6 +1,6 @@ """Constants used by esphome.""" -__version__ = "2025.5.0" +__version__ = "2025.5.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From caf9930ff9963b237ca3fc27b153e9836b5c6555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 May 2025 23:20:14 -0500 Subject: [PATCH 24/30] Fix flakey tests (#8914) --- tests/dashboard/test_web_server.py | 2 ++ tests/integration/conftest.py | 24 ++++++++++++++++++++++++ tests/integration/const.py | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 1ea9c73f32..cd02200d0b 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -53,6 +53,8 @@ async def dashboard() -> DashboardTestHelper: assert DASHBOARD.settings.on_ha_addon is True assert DASHBOARD.settings.using_auth is False task = asyncio.create_task(DASHBOARD.async_run()) + # Wait for initial device loading to complete + await DASHBOARD.entries.async_request_update_entries() client = AsyncHTTPClient() io_loop = IOLoop(make_current=False) yield DashboardTestHelper(io_loop, client, port) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0ac5676667..35586519e4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Generator from contextlib import AbstractAsyncContextManager, asynccontextmanager +import logging from pathlib import Path import platform import signal @@ -40,6 +41,29 @@ from .types import ( ) +@pytest.fixture(scope="module", autouse=True) +def enable_aioesphomeapi_debug_logging(): + """Enable debug logging for aioesphomeapi to help diagnose connection issues.""" + # Get the aioesphomeapi logger + logger = logging.getLogger("aioesphomeapi") + # Save the original level + original_level = logger.level + # Set to DEBUG level + logger.setLevel(logging.DEBUG) + # Also ensure we have a handler that outputs to console + if not logger.handlers: + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + yield + # Restore original level + logger.setLevel(original_level) + + @pytest.fixture def integration_test_dir() -> Generator[Path]: """Create a temporary directory for integration tests.""" diff --git a/tests/integration/const.py b/tests/integration/const.py index db5e8f5ae1..6876bbd443 100644 --- a/tests/integration/const.py +++ b/tests/integration/const.py @@ -2,7 +2,7 @@ # Network constants DEFAULT_API_PORT = 6053 -LOCALHOST = "localhost" +LOCALHOST = "127.0.0.1" # Timeout constants API_CONNECTION_TIMEOUT = 30.0 # seconds From 95a17387a8355917f1a20bc46ce9446bf755debe Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 27 May 2025 16:26:01 +1200 Subject: [PATCH 25/30] Bump actions/checkout from 4.1.7 to 4.2.2 (#8904) --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 26 +++++++++++++------------- .github/workflows/release.yml | 8 ++++---- .github/workflows/yaml-lint.yml | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 92d209cc34..f51bd84186 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 8a14dba5eb..5f524612ed 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 377cd02c56..22bdeca429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt') }}" >> $GITHUB_OUTPUT @@ -68,7 +68,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -89,7 +89,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -110,7 +110,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -131,7 +131,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -152,7 +152,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -202,7 +202,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -232,7 +232,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -300,7 +300,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -356,7 +356,7 @@ jobs: count: ${{ steps.list-components.outputs.count }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 with: # Fetch enough history so `git merge-base refs/remotes/origin/dev HEAD` works. fetch-depth: 500 @@ -406,7 +406,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -432,7 +432,7 @@ jobs: matrix: ${{ steps.split.outputs.components }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Split components into 20 groups id: split run: | @@ -462,7 +462,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a310b7f083..eae01fe0b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Download digests uses: actions/download-artifact@v4.3.0 diff --git a/.github/workflows/yaml-lint.yml b/.github/workflows/yaml-lint.yml index 1c0b5f58ad..ed9b4407a2 100644 --- a/.github/workflows/yaml-lint.yml +++ b/.github/workflows/yaml-lint.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Run yamllint uses: frenck/action-yamllint@v1.5.0 with: From 361de223707158e15e6b3303051171c58b044216 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 26 May 2025 22:16:27 -0700 Subject: [PATCH 26/30] [sx1509] add support for keys (#8413) Co-authored-by: Samuel Sieb Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/sx1509/__init__.py | 73 +++++++++++++++++++-------- esphome/components/sx1509/sx1509.cpp | 30 +++++++++-- esphome/components/sx1509/sx1509.h | 10 +++- tests/components/sx1509/common.yaml | 11 ++++ 4 files changed, 100 insertions(+), 24 deletions(-) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index b1702b5ade..f1b08a505a 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -1,6 +1,6 @@ -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import i2c, key_provider import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -8,13 +8,16 @@ from esphome.const import ( CONF_INVERTED, CONF_MODE, CONF_NUMBER, + CONF_ON_KEY, CONF_OPEN_DRAIN, CONF_OUTPUT, CONF_PULLDOWN, CONF_PULLUP, + CONF_TRIGGER_ID, ) CONF_KEYPAD = "keypad" +CONF_KEYS = "keys" CONF_KEY_ROWS = "key_rows" CONF_KEY_COLUMNS = "key_columns" CONF_SLEEP_TIME = "sleep_time" @@ -22,22 +25,47 @@ CONF_SCAN_TIME = "scan_time" CONF_DEBOUNCE_TIME = "debounce_time" CONF_SX1509_ID = "sx1509_id" +AUTO_LOAD = ["key_provider"] DEPENDENCIES = ["i2c"] MULTI_CONF = True sx1509_ns = cg.esphome_ns.namespace("sx1509") -SX1509Component = sx1509_ns.class_("SX1509Component", cg.Component, i2c.I2CDevice) +SX1509Component = sx1509_ns.class_( + "SX1509Component", cg.Component, i2c.I2CDevice, key_provider.KeyProvider +) SX1509GPIOPin = sx1509_ns.class_("SX1509GPIOPin", cg.GPIOPin) +SX1509KeyTrigger = sx1509_ns.class_( + "SX1509KeyTrigger", automation.Trigger.template(cg.uint8) +) -KEYPAD_SCHEMA = cv.Schema( - { - cv.Required(CONF_KEY_ROWS): cv.int_range(min=1, max=8), - cv.Required(CONF_KEY_COLUMNS): cv.int_range(min=1, max=8), - cv.Optional(CONF_SLEEP_TIME): cv.int_range(min=128, max=8192), - cv.Optional(CONF_SCAN_TIME): cv.int_range(min=1, max=128), - cv.Optional(CONF_DEBOUNCE_TIME): cv.int_range(min=1, max=64), - } + +def check_keys(config): + if CONF_KEYS in config: + if len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS]: + raise cv.Invalid( + "The number of key codes must equal the number of rows * columns" + ) + return config + + +KEYPAD_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_KEY_ROWS): cv.int_range(min=2, max=8), + cv.Required(CONF_KEY_COLUMNS): cv.int_range(min=1, max=8), + cv.Optional(CONF_SLEEP_TIME): cv.int_range(min=128, max=8192), + cv.Optional(CONF_SCAN_TIME): cv.int_range(min=1, max=128), + cv.Optional(CONF_DEBOUNCE_TIME): cv.int_range(min=1, max=64), + cv.Optional(CONF_KEYS): cv.string, + cv.Optional(CONF_ON_KEY): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SX1509KeyTrigger), + } + ), + } + ), + check_keys, ) CONFIG_SCHEMA = ( @@ -56,17 +84,22 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_KEYPAD in config: - keypad = config[CONF_KEYPAD] - cg.add(var.set_rows_cols(keypad[CONF_KEY_ROWS], keypad[CONF_KEY_COLUMNS])) + if conf := config.get(CONF_KEYPAD): + cg.add(var.set_rows_cols(conf[CONF_KEY_ROWS], conf[CONF_KEY_COLUMNS])) if ( - CONF_SLEEP_TIME in keypad - and CONF_SCAN_TIME in keypad - and CONF_DEBOUNCE_TIME in keypad + CONF_SLEEP_TIME in conf + and CONF_SCAN_TIME in conf + and CONF_DEBOUNCE_TIME in conf ): - cg.add(var.set_sleep_time(keypad[CONF_SLEEP_TIME])) - cg.add(var.set_scan_time(keypad[CONF_SCAN_TIME])) - cg.add(var.set_debounce_time(keypad[CONF_DEBOUNCE_TIME])) + cg.add(var.set_sleep_time(conf[CONF_SLEEP_TIME])) + cg.add(var.set_scan_time(conf[CONF_SCAN_TIME])) + cg.add(var.set_debounce_time(conf[CONF_DEBOUNCE_TIME])) + if keys := conf.get(CONF_KEYS): + cg.add(var.set_keys(keys)) + for tconf in conf.get(CONF_ON_KEY, []): + trigger = cg.new_Pvariable(tconf[CONF_TRIGGER_ID]) + cg.add(var.register_key_trigger(trigger)) + await automation.build_automation(trigger, [(cg.uint8, "x")], tconf) def validate_mode(value): diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 855a90bacd..a4808c86e2 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -48,6 +48,30 @@ void SX1509Component::loop() { uint16_t key_data = this->read_key_data(); for (auto *binary_sensor : this->keypad_binary_sensors_) binary_sensor->process(key_data); + if (this->keys_.empty()) + return; + if (key_data == 0) { + this->last_key_ = 0; + return; + } + int row, col; + for (row = 0; row < 7; row++) { + if (key_data & (1 << row)) + break; + } + for (col = 8; col < 15; col++) { + if (key_data & (1 << col)) + break; + } + col -= 8; + uint8_t key = this->keys_[row * this->cols_ + col]; + if (key == this->last_key_) + return; + this->last_key_ = key; + ESP_LOGV(TAG, "row %d, col %d, key '%c'", row, col, key); + for (auto &trigger : this->key_triggers_) + trigger->trigger(key); + this->send_key_(key); } } @@ -230,9 +254,9 @@ void SX1509Component::setup_keypad_() { scan_time_bits &= 0b111; // Scan time is bits 2:0 temp_byte = sleep_time_ | scan_time_bits; this->write_byte(REG_KEY_CONFIG_1, temp_byte); - rows_ = (rows_ - 1) & 0b111; // 0 = off, 0b001 = 2 rows, 0b111 = 8 rows, etc. - cols_ = (cols_ - 1) & 0b111; // 0b000 = 1 column, ob111 = 8 columns, etc. - this->write_byte(REG_KEY_CONFIG_2, (rows_ << 3) | cols_); + temp_byte = ((this->rows_ - 1) & 0b111) << 3; // 0 = off, 0b001 = 2 rows, 0b111 = 8 rows, etc. + temp_byte |= (this->cols_ - 1) & 0b111; // 0b000 = 1 column, ob111 = 8 columns, etc. + this->write_byte(REG_KEY_CONFIG_2, temp_byte); } uint16_t SX1509Component::read_key_data() { diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 9e4f31aab0..c0e86aa8a1 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/i2c/i2c.h" +#include "esphome/components/key_provider/key_provider.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "sx1509_gpio_pin.h" @@ -27,7 +28,9 @@ class SX1509Processor { virtual void process(uint16_t data){}; }; -class SX1509Component : public Component, public i2c::I2CDevice { +class SX1509KeyTrigger : public Trigger {}; + +class SX1509Component : public Component, public i2c::I2CDevice, public key_provider::KeyProvider { public: SX1509Component() = default; @@ -47,12 +50,14 @@ class SX1509Component : public Component, public i2c::I2CDevice { this->cols_ = cols; this->has_keypad_ = true; }; + void set_keys(std::string keys) { this->keys_ = std::move(keys); }; void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; void register_keypad_binary_sensor(SX1509Processor *binary_sensor) { this->keypad_binary_sensors_.push_back(binary_sensor); } + void register_key_trigger(SX1509KeyTrigger *trig) { this->key_triggers_.push_back(trig); }; void setup_led_driver(uint8_t pin); protected: @@ -65,10 +70,13 @@ class SX1509Component : public Component, public i2c::I2CDevice { bool has_keypad_ = false; uint8_t rows_ = 0; uint8_t cols_ = 0; + std::string keys_; uint16_t sleep_time_ = 128; uint8_t scan_time_ = 1; uint8_t debounce_time_ = 1; + uint8_t last_key_ = 0; std::vector keypad_binary_sensors_; + std::vector key_triggers_; uint32_t last_loop_timestamp_ = 0; const uint32_t min_loop_period_ = 15; // ms diff --git a/tests/components/sx1509/common.yaml b/tests/components/sx1509/common.yaml index a09d850649..a83217e579 100644 --- a/tests/components/sx1509/common.yaml +++ b/tests/components/sx1509/common.yaml @@ -6,6 +6,12 @@ i2c: sx1509: - id: sx1509_hub address: 0x3E + keypad: + key_rows: 2 + key_columns: 2 + keys: abcd + on_key: + - lambda: ESP_LOGD("test", "got key '%c'", x); binary_sensor: - platform: gpio @@ -13,6 +19,11 @@ binary_sensor: pin: sx1509: sx1509_hub number: 3 + - platform: sx1509 + sx1509_id: sx1509_hub + name: "keypadkey_0" + row: 0 + col: 0 switch: - platform: gpio From 321411e355791b285f40db6efa6e5bf278a1bdf9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 17:26:08 +1200 Subject: [PATCH 27/30] Bump ruamel-yaml from 0.18.10 to 0.18.11 (#8910) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index af98606369..87319dbba0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ esphome-dashboard==20250514.0 aioesphomeapi==31.1.0 zeroconf==0.147.0 puremagic==1.29 -ruamel.yaml==0.18.10 # dashboard_import +ruamel.yaml==0.18.11 # dashboard_import esphome-glyphsets==0.2.0 pillow==10.4.0 cairosvg==2.8.2 From 0c7589caeb56cd64e923c41b08462a2e1de77712 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 17:26:14 +1200 Subject: [PATCH 28/30] Bump pytest-mock from 3.14.0 to 3.14.1 (#8909) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8486a764f6..8bb213511b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ pre-commit # Unit tests pytest==8.3.5 pytest-cov==6.1.1 -pytest-mock==3.14.0 +pytest-mock==3.14.1 pytest-asyncio==0.26.0 pytest-xdist==3.6.1 asyncmock==0.4.2 From f2e4dc79074e5d6a0b66b1bd4409cb2ee7081d50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 06:54:33 +0000 Subject: [PATCH 29/30] Bump setuptools from 80.8.0 to 80.9.0 (#8915) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e783799e58..4ee2d3a390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==80.8.0", "wheel>=0.43,<0.46"] +requires = ["setuptools==80.9.0", "wheel>=0.43,<0.46"] build-backend = "setuptools.build_meta" [project] From 7d049a61bbfbc5fd6bf545070b274507fd504abc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 06:55:47 +0000 Subject: [PATCH 30/30] Bump pytest-xdist from 3.6.1 to 3.7.0 (#8916) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8bb213511b..ebbc933622 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,6 +9,6 @@ pytest==8.3.5 pytest-cov==6.1.1 pytest-mock==3.14.1 pytest-asyncio==0.26.0 -pytest-xdist==3.6.1 +pytest-xdist==3.7.0 asyncmock==0.4.2 hypothesis==6.92.1