From 83fe4b4ff334d04a9f0cc70e296e5b375cea352f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:36:06 -0500 Subject: [PATCH 01/42] Bump ruff from 0.12.9 to 0.12.10 (#10362) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .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 5540733131..8b76523b2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.9 + rev: v0.12.10 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index f0a16fd7f3..f55618c0f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.8 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.12.9 # also change in .pre-commit-config.yaml when updating +ruff==0.12.10 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit From 5cd9a86dcba809120cb3ccd62e6ea2af088c55ad Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 23 Aug 2025 08:20:16 +0200 Subject: [PATCH 02/42] [nrf52] update toolchain to v0.17.4, support mac (#10391) --- .clang-tidy.hash | 2 +- esphome/components/nrf52/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index a6de2366bc..07d9b3931e 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -0440e35cf89a49e8a35fd3690ed453a72b7b6f61b9d346ced6140e1c0d39dff6 +8158e5b8ed99b8c2d1d8953db0561393e07cc2555e2230ba685b591db9af156e diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 908a855f70..e0ccd60c1a 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -120,7 +120,7 @@ async def to_code(config: ConfigType) -> None: "platform_packages", [ "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip", - "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.16.1-1.zip", + "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip", ], ) diff --git a/platformio.ini b/platformio.ini index 47fc5205bc..7b9cbbc9b7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -222,7 +222,7 @@ platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tag framework = zephyr platform_packages = platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip - platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.16.1-1.zip + platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip build_flags = ${common.build_flags} -DUSE_ZEPHYR From 17f787fc36a04617f033efaddd704c7c79cb4e14 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 23 Aug 2025 14:17:42 +0200 Subject: [PATCH 03/42] [nrf52] fix build in dashboard (#10323) --- .clang-tidy.hash | 2 +- esphome/components/nrf52/__init__.py | 2 +- platformio.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 07d9b3931e..f61b79de4d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -8158e5b8ed99b8c2d1d8953db0561393e07cc2555e2230ba685b591db9af156e +4368db58e8f884aff245996b1e8b644cc0796c0bb2fa706d5740d40b823d3ac9 diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index e0ccd60c1a..e75bf8192c 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -119,7 +119,7 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option( "platform_packages", [ - "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip", + "platformio/framework-zephyr@https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip", "platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip", ], ) diff --git a/platformio.ini b/platformio.ini index 7b9cbbc9b7..d97607fac5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -221,7 +221,7 @@ extends = common platform = https://github.com/tomaszduda23/platform-nordicnrf52/archive/refs/tags/v10.3.0-1.zip framework = zephyr platform_packages = - platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-4.zip + platformio/framework-zephyr @ https://github.com/tomaszduda23/framework-sdk-nrf/archive/refs/tags/v2.6.1-7.zip platformio/toolchain-gccarmnoneeabi@https://github.com/tomaszduda23/toolchain-sdk-ng/archive/refs/tags/v0.17.4-0.zip build_flags = ${common.build_flags} From c096c6934d6e610c9192eaa09a23b01f41bbaba3 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sun, 24 Aug 2025 10:56:06 +0200 Subject: [PATCH 04/42] fix temperature config validation regex (#9575) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/config_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f811fbf7c2..866ed4f8aa 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1112,8 +1112,8 @@ voltage = float_with_unit("voltage", "(v|V|volt|Volts)?") distance = float_with_unit("distance", "(m)") framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)") angle = float_with_unit("angle", "(°|deg)", optional_unit=True) -_temperature_c = float_with_unit("temperature", "(°C|° C|°|C)?") -_temperature_k = float_with_unit("temperature", "(° K|° K|K)?") +_temperature_c = float_with_unit("temperature", "(°C|° C|C|°)?") +_temperature_k = float_with_unit("temperature", "(°K|° K|K)?") _temperature_f = float_with_unit("temperature", "(°F|° F|F)?") decibel = float_with_unit("decibel", "(dB|dBm|db|dbm)", optional_unit=True) pressure = float_with_unit("pressure", "(bar|Bar)", optional_unit=True) From 12ba4b142e8dd2950e9406de2f8d378631772688 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sun, 24 Aug 2025 11:03:14 +0200 Subject: [PATCH 05/42] Update Python to 3.11 in AI instructions (#10407) --- .ai/instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 6c002f9617..4d6cd71f85 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro ## 2. Core Technologies & Stack -* **Languages:** Python (>=3.10), C++ (gnu++20) +* **Languages:** Python (>=3.11), C++ (gnu++20) * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. From be9c20c35758c64eae2289a8e8be4d04f5472212 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:52:52 +1000 Subject: [PATCH 06/42] [mipi_spi] Add model (#10392) --- esphome/components/mipi_spi/models/jc.py | 229 +++++++++++++++++++++++ 1 file changed, 229 insertions(+) diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index f1f046a427..5dbf049ded 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -255,4 +255,233 @@ DriverChip( ), ) +DriverChip( + "JC3636W518V2", + height=360, + width=360, + offset_height=1, + draw_rounding=1, + cs_pin=10, + reset_pin=47, + invert_colors=True, + color_order=MODE_RGB, + bus_mode=TYPE_QUAD, + data_rate="40MHz", + initsequence=( + (0xF0, 0x28), + (0xF2, 0x28), + (0x73, 0xF0), + (0x7C, 0xD1), + (0x83, 0xE0), + (0x84, 0x61), + (0xF2, 0x82), + (0xF0, 0x00), + (0xF0, 0x01), + (0xF1, 0x01), + (0xB0, 0x56), + (0xB1, 0x4D), + (0xB2, 0x24), + (0xB4, 0x87), + (0xB5, 0x44), + (0xB6, 0x8B), + (0xB7, 0x40), + (0xB8, 0x86), + (0xBA, 0x00), + (0xBB, 0x08), + (0xBC, 0x08), + (0xBD, 0x00), + (0xC0, 0x80), + (0xC1, 0x10), + (0xC2, 0x37), + (0xC3, 0x80), + (0xC4, 0x10), + (0xC5, 0x37), + (0xC6, 0xA9), + (0xC7, 0x41), + (0xC8, 0x01), + (0xC9, 0xA9), + (0xCA, 0x41), + (0xCB, 0x01), + (0xD0, 0x91), + (0xD1, 0x68), + (0xD2, 0x68), + (0xF5, 0x00, 0xA5), + (0xDD, 0x4F), + (0xDE, 0x4F), + (0xF1, 0x10), + (0xF0, 0x00), + (0xF0, 0x02), + ( + 0xE0, + 0xF0, + 0x0A, + 0x10, + 0x09, + 0x09, + 0x36, + 0x35, + 0x33, + 0x4A, + 0x29, + 0x15, + 0x15, + 0x2E, + 0x34, + ), + ( + 0xE1, + 0xF0, + 0x0A, + 0x0F, + 0x08, + 0x08, + 0x05, + 0x34, + 0x33, + 0x4A, + 0x39, + 0x15, + 0x15, + 0x2D, + 0x33, + ), + (0xF0, 0x10), + (0xF3, 0x10), + (0xE0, 0x07), + (0xE1, 0x00), + (0xE2, 0x00), + (0xE3, 0x00), + (0xE4, 0xE0), + (0xE5, 0x06), + (0xE6, 0x21), + (0xE7, 0x01), + (0xE8, 0x05), + (0xE9, 0x02), + (0xEA, 0xDA), + (0xEB, 0x00), + (0xEC, 0x00), + (0xED, 0x0F), + (0xEE, 0x00), + (0xEF, 0x00), + (0xF8, 0x00), + (0xF9, 0x00), + (0xFA, 0x00), + (0xFB, 0x00), + (0xFC, 0x00), + (0xFD, 0x00), + (0xFE, 0x00), + (0xFF, 0x00), + (0x60, 0x40), + (0x61, 0x04), + (0x62, 0x00), + (0x63, 0x42), + (0x64, 0xD9), + (0x65, 0x00), + (0x66, 0x00), + (0x67, 0x00), + (0x68, 0x00), + (0x69, 0x00), + (0x6A, 0x00), + (0x6B, 0x00), + (0x70, 0x40), + (0x71, 0x03), + (0x72, 0x00), + (0x73, 0x42), + (0x74, 0xD8), + (0x75, 0x00), + (0x76, 0x00), + (0x77, 0x00), + (0x78, 0x00), + (0x79, 0x00), + (0x7A, 0x00), + (0x7B, 0x00), + (0x80, 0x48), + (0x81, 0x00), + (0x82, 0x06), + (0x83, 0x02), + (0x84, 0xD6), + (0x85, 0x04), + (0x86, 0x00), + (0x87, 0x00), + (0x88, 0x48), + (0x89, 0x00), + (0x8A, 0x08), + (0x8B, 0x02), + (0x8C, 0xD8), + (0x8D, 0x04), + (0x8E, 0x00), + (0x8F, 0x00), + (0x90, 0x48), + (0x91, 0x00), + (0x92, 0x0A), + (0x93, 0x02), + (0x94, 0xDA), + (0x95, 0x04), + (0x96, 0x00), + (0x97, 0x00), + (0x98, 0x48), + (0x99, 0x00), + (0x9A, 0x0C), + (0x9B, 0x02), + (0x9C, 0xDC), + (0x9D, 0x04), + (0x9E, 0x00), + (0x9F, 0x00), + (0xA0, 0x48), + (0xA1, 0x00), + (0xA2, 0x05), + (0xA3, 0x02), + (0xA4, 0xD5), + (0xA5, 0x04), + (0xA6, 0x00), + (0xA7, 0x00), + (0xA8, 0x48), + (0xA9, 0x00), + (0xAA, 0x07), + (0xAB, 0x02), + (0xAC, 0xD7), + (0xAD, 0x04), + (0xAE, 0x00), + (0xAF, 0x00), + (0xB0, 0x48), + (0xB1, 0x00), + (0xB2, 0x09), + (0xB3, 0x02), + (0xB4, 0xD9), + (0xB5, 0x04), + (0xB6, 0x00), + (0xB7, 0x00), + (0xB8, 0x48), + (0xB9, 0x00), + (0xBA, 0x0B), + (0xBB, 0x02), + (0xBC, 0xDB), + (0xBD, 0x04), + (0xBE, 0x00), + (0xBF, 0x00), + (0xC0, 0x10), + (0xC1, 0x47), + (0xC2, 0x56), + (0xC3, 0x65), + (0xC4, 0x74), + (0xC5, 0x88), + (0xC6, 0x99), + (0xC7, 0x01), + (0xC8, 0xBB), + (0xC9, 0xAA), + (0xD0, 0x10), + (0xD1, 0x47), + (0xD2, 0x56), + (0xD3, 0x65), + (0xD4, 0x74), + (0xD5, 0x88), + (0xD6, 0x99), + (0xD7, 0x01), + (0xD8, 0xBB), + (0xD9, 0xAA), + (0xF3, 0x01), + (0xF0, 0x00), + ), +) + models = {} From 9737b355791baf7348ae5eb5eaee85a88901fca3 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:55:44 +1000 Subject: [PATCH 07/42] [http_request] Fix for host after ArduinoJson library bump (#10348) --- esphome/components/http_request/http_request_host.cpp | 7 +++++-- esphome/components/http_request/http_request_host.h | 6 +----- esphome/components/http_request/httplib.h | 10 +++++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 192032c1ac..0b4c998a40 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -1,7 +1,10 @@ -#include "http_request_host.h" - #ifdef USE_HOST +#define USE_HTTP_REQUEST_HOST_H +#define CPPHTTPLIB_NO_EXCEPTIONS +#include "httplib.h" +#include "http_request_host.h" + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 49fd3b43fe..bbeed87f70 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -1,11 +1,7 @@ #pragma once -#include "http_request.h" - #ifdef USE_HOST - -#define CPPHTTPLIB_NO_EXCEPTIONS -#include "httplib.h" +#include "http_request.h" namespace esphome { namespace http_request { diff --git a/esphome/components/http_request/httplib.h b/esphome/components/http_request/httplib.h index a2f4436ec7..8b08699702 100644 --- a/esphome/components/http_request/httplib.h +++ b/esphome/components/http_request/httplib.h @@ -3,12 +3,10 @@ /** * NOTE: This is a copy of httplib.h from https://github.com/yhirose/cpp-httplib * - * It has been modified only to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, + * It has been modified to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, * it was considered preferable to use it with as few changes as possible, to facilitate future updates. */ -#include "esphome/core/defines.h" - // // httplib.h // @@ -17,6 +15,11 @@ // #ifdef USE_HOST +// Prevent this code being included in main.cpp +#ifdef USE_HTTP_REQUEST_HOST_H + +#include "esphome/core/defines.h" + #ifndef CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H @@ -9687,5 +9690,6 @@ inline SSL_CTX *Client::ssl_context() const { #endif #endif // CPPHTTPLIB_HTTPLIB_H +#endif // USE_HTTP_REQUEST_HOST_H #endif From ca19959d7c991eb1ab6357c1578effe12e4a29b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:11:54 +0200 Subject: [PATCH 08/42] [core] Improve error reporting for entity name conflicts with non-ASCII characters (#10329) --- esphome/core/entity_helpers.py | 13 +++++- tests/unit_tests/core/test_entity_helpers.py | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 1ccc3e2683..e1b2a8264b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -236,10 +236,21 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" + # Show both original names and their ASCII-only versions if they differ + sanitized_msg = "" + if entity_name != existing_name: + sanitized_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " - f"Each entity on a device must have a unique name within its platform." + "Each entity on a device must have a unique name within its platform." + f"{sanitized_msg}" ) # Store metadata about this entity diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index db99243a1a..9ba5367413 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -705,3 +705,48 @@ def test_empty_or_null_device_id_on_entity() -> None: config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} validated2 = validator(config2) assert validated2 == config2 + + +def test_entity_duplicate_validator_non_ascii_names() -> None: + """Test that non-ASCII names show helpful error messages.""" + # Create validator for binary_sensor platform + validator = entity_duplicate_validator("binary_sensor") + + # First Russian sensor should pass + config1 = {CONF_NAME: "Датчик открытия основного крана"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second Russian sensor with different text but same ASCII conversion should fail + config2 = {CONF_NAME: "Датчик закрытия основного крана"} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + re.DOTALL, + ), + ): + validator(config2) + + +def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: + """Test that identical names don't show the enhanced message.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second entity with exact same name should fail without enhanced message + config2 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found.*" + r"Each entity on a device must have a unique name within its platform\.$", + ): + validator(config2) From 88303f39fa8174b17c9854ef5b8adafbb1ccb0a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:16:12 +0200 Subject: [PATCH 09/42] [pvvx_mithermometer] Fix race condition with BLE authentication (#10327) --- .../display/pvvx_display.cpp | 37 +++++++++++++++++-- .../pvvx_mithermometer/display/pvvx_display.h | 2 + 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 4b6c11b332..b6916ad68f 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -46,10 +46,32 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t } this->connection_established_ = true; this->char_handle_ = chr->handle; -#ifdef USE_TIME - this->sync_time_(); -#endif - this->display(); + + // Attempt to write immediately + // For devices without security, this will work + // For devices with security that are already paired, this will work + // For devices that need pairing, the write will be retried after auth completes + this->sync_time_and_display_(); + break; + } + default: + break; + } +} + +void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr)) + return; + + if (param->ble_security.auth_cmpl.success) { + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + // Now that pairing is complete, perform the pending writes + this->sync_time_and_display_(); + } else { + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str()); + } break; } default: @@ -127,6 +149,13 @@ void PVVXDisplay::delayed_disconnect_() { this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } +void PVVXDisplay::sync_time_and_display_() { +#ifdef USE_TIME + this->sync_time_(); +#endif + this->display(); +} + #ifdef USE_TIME void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index 9739362024..c7fc523420 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -43,6 +43,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; /// Set validity period of the display information in seconds (1..65535) void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; } @@ -112,6 +113,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void setcfgbit_(uint8_t bit, bool value); void send_to_setup_char_(uint8_t *blk, size_t size); void delayed_disconnect_(); + void sync_time_and_display_(); #ifdef USE_TIME void sync_time_(); time::RealTimeClock *time_{nullptr}; From acfce581fa714eda03d4742927fc6d7d3c59275c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:17:26 +0200 Subject: [PATCH 10/42] [esp32_ble_client] Optimize BLE connection parameters for different connection types (#10356) --- .../esp32_ble_client/ble_client_base.cpp | 99 +++++++++---------- .../esp32_ble_client/ble_client_base.h | 6 +- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..b9b7c75e0a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome::esp32_ble_client { @@ -111,43 +112,19 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Set preferred connection parameters before connecting - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; + // Determine connection parameters based on connection type + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + // V3 without cache needs fast params for service discovery + this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); + } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + // V3 with cache can use medium params + this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } + // For V1/Legacy, don't set params - use ESP-IDF defaults - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - this->log_connection_params_(param_type); - } - - // Now open the connection + // Open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); - if (ret) { - this->log_gattc_warning_("esp_ble_gattc_open", ret); - this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); - } + this->handle_connection_result_(ret); } esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } @@ -231,6 +208,15 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::handle_connection_result_(esp_err_t ret) { + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + this->set_state(espbt::ClientState::IDLE); + } else { + this->set_state(espbt::ClientState::CONNECTING); + } +} + void BLEClientBase::log_error_(const char *message) { ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } @@ -243,17 +229,30 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } -void BLEClientBase::restore_medium_conn_params_() { - // Restore to medium connection parameters after initial connection phase - // This balances performance with bandwidth usage for normal operation +void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - this->log_connection_params_("medium"); - esp_ble_gap_update_conn_params(&conn_params); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_update_conn_params(&conn_params); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_update_conn_params", err); + } +} + +void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type) { + // Set preferred connection parameters before connecting + // These will be used when establishing the connection + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, latency, timeout); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_set_prefer_conn_params", err); + } } bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, @@ -308,12 +307,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - // Restore to medium connection parameters for cached connections too - this->restore_medium_conn_params_(); + // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + // For V3_WITHOUT_CACHE, we already set fast params before connecting + // No need to update them again here this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; @@ -395,12 +395,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - // For V3 connections, restore to medium connection parameters after service discovery + // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->restore_medium_conn_params_(); - } else { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 1850b2c5b3..acfad9e9b0 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,10 +133,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); - void restore_medium_conn_params_(); + void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + void handle_connection_result_(esp_err_t ret); // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From 4396bc0d1acbba2e6414c6f4ae16febe8b471307 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:37 +0200 Subject: [PATCH 11/42] [esp32_ble] Increase GATT connection retry count to use full timeout window (#10376) --- esphome/components/esp32_ble/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2edd69c6c0..cc06058b65 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -280,6 +280,10 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) # Set the maximum number of notification registrations # This controls how many BLE characteristics can have notifications enabled From 61a50238880937c0f7b13e18a6f06ad5aebcb38a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:52 +0200 Subject: [PATCH 12/42] [script] Fix parallel mode scripts with delays cancelling each other (#10324) --- esphome/core/base_automation.h | 13 +++- esphome/core/scheduler.cpp | 19 +++-- esphome/core/scheduler.h | 9 ++- .../fixtures/parallel_script_delays.yaml | 45 ++++++++++++ tests/integration/test_automations.py | 70 +++++++++++++++++++ 5 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 tests/integration/fixtures/parallel_script_delays.yaml diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 740e10700b..ba942e5e43 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -5,6 +5,8 @@ #include "esphome/core/hal.h" #include "esphome/core/defines.h" #include "esphome/core/preferences.h" +#include "esphome/core/scheduler.h" +#include "esphome/core/application.h" #include @@ -158,7 +160,16 @@ template class DelayAction : public Action, public Compon void play_complex(Ts... x) override { auto f = std::bind(&DelayAction::play_next_, this, x...); this->num_running_++; - this->set_timeout("delay", this->delay_.value(x...), f); + + // If num_running_ > 1, we have multiple instances running in parallel + // In single/restart/queued modes, only one instance runs at a time + // Parallel mode uses skip_cancel=true to allow multiple delays to coexist + // WARNING: This can accumulate delays if scripts are triggered faster than they complete! + // Users should set max_runs on parallel scripts to limit concurrent executions. + // Issue #10264: This is a workaround for parallel script delays interfering with each other. + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3ade260ac..a907b89b02 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -65,14 +65,17 @@ static void validate_static_string(const char *name) { // Common implementation for both timeout and interval void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, - const void *name_ptr, uint32_t delay, std::function func, bool is_retry) { + const void *name_ptr, uint32_t delay, std::function func, bool is_retry, + bool skip_cancel) { // Get the name as const char* const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if name is not empty - LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + LockGuard guard{this->lock_}; + this->cancel_item_locked_(component, name_cstr, type); + } return; } @@ -97,7 +100,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } this->defer_queue_.push_back(std::move(item)); return; } @@ -150,9 +155,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // If name is provided, do atomic cancel-and-add + // If name is provided, do atomic cancel-and-add (unless skip_cancel is true) // Cancel existing items - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c73bd55d5d..f469a60d5c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -21,8 +21,13 @@ struct RetryArgs; void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members + // Allow retry_handler to access protected members for internal retry mechanism friend void ::esphome::retry_handler(const std::shared_ptr &args); + // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. + // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. + // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays + // to accumulate and overload the scheduler if misused. + template friend class DelayAction; public: // Public API - accepts std::string for backward compatibility @@ -184,7 +189,7 @@ class Scheduler { // Common implementation for both timeout and interval void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, - uint32_t delay, std::function func, bool is_retry = false); + uint32_t delay, std::function func, bool is_retry = false, bool skip_cancel = false); // Common implementation for retry void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml new file mode 100644 index 0000000000..6887045913 --- /dev/null +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -0,0 +1,45 @@ +esphome: + name: test-parallel-delays + +host: + +logger: + level: DEBUG + +api: + actions: + - action: test_parallel_delays + then: + # Start three parallel script instances with small delays between starts + - globals.set: + id: instance_counter + value: '1' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '2' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '3' + - script.execute: parallel_delay_script + +globals: + - id: instance_counter + type: int + initial_value: '0' + +script: + - id: parallel_delay_script + mode: parallel + then: + - lambda: !lambda |- + int instance = id(instance_counter); + ESP_LOGI("TEST", "Parallel script instance %d started", instance); + - delay: 1s + - lambda: !lambda |- + static int completed_counter = 0; + completed_counter++; + ESP_LOGI("TEST", "Parallel script instance %d completed after delay", completed_counter); diff --git a/tests/integration/test_automations.py b/tests/integration/test_automations.py index bd2082e86b..83268c1eea 100644 --- a/tests/integration/test_automations.py +++ b/tests/integration/test_automations.py @@ -89,3 +89,73 @@ async def test_delay_action_cancellation( assert 0.4 < time_from_second_start < 0.6, ( f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s" ) + + +@pytest.mark.asyncio +async def test_parallel_script_delays( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that parallel scripts with delays don't interfere with each other.""" + loop = asyncio.get_running_loop() + + # Track script executions + script_starts: list[float] = [] + script_ends: list[float] = [] + + # Patterns to match + start_pattern = re.compile(r"Parallel script instance \d+ started") + end_pattern = re.compile(r"Parallel script instance \d+ completed after delay") + + # Future to track when all scripts have completed + all_scripts_completed = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for parallel script messages.""" + current_time = loop.time() + + if start_pattern.search(line): + script_starts.append(current_time) + + if end_pattern.search(line): + script_ends.append(current_time) + # Check if we have all 3 completions + if len(script_ends) == 3 and not all_scripts_completed.done(): + all_scripts_completed.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + entities, services = await client.list_entities_services() + + # Find our test service + test_service = next( + (s for s in services if s.name == "test_parallel_delays"), None + ) + assert test_service is not None, "test_parallel_delays service not found" + + # Execute the test - this will start 3 parallel scripts with 1 second delays + client.execute_service(test_service, {}) + + # Wait for all scripts to complete (should take ~1 second, not 3) + await asyncio.wait_for(all_scripts_completed, timeout=2.0) + + # Verify we had 3 starts and 3 ends + assert len(script_starts) == 3, ( + f"Expected 3 script starts, got {len(script_starts)}" + ) + assert len(script_ends) == 3, f"Expected 3 script ends, got {len(script_ends)}" + + # Verify they ran in parallel - all should complete within ~1.5 seconds + first_start = min(script_starts) + last_end = max(script_ends) + total_time = last_end - first_start + + # If running in parallel, total time should be close to 1 second + # If they were interfering (running sequentially), it would take 3+ seconds + assert total_time < 1.5, ( + f"Parallel scripts took {total_time:.2f}s total, should be ~1s if running in parallel" + ) From b41a61c76e9fec271f8211be5c5cfdd3276728c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:23 +0200 Subject: [PATCH 13/42] [deep_sleep] Fix ESP32-C6 compilation error with gpio_deep_sleep_hold_en() (#10345) --- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 9 +++++++++ tests/components/deep_sleep/test.esp32-c6-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s2-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s3-idf.yaml | 5 +++++ 4 files changed, 24 insertions(+) create mode 100644 tests/components/deep_sleep/test.esp32-c6-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s2-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s3-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c5c1fe0835..e9d0a4981f 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -1,4 +1,5 @@ #ifdef USE_ESP32 +#include "soc/soc_caps.h" #include "driver/gpio.h" #include "deep_sleep_component.h" #include "esphome/core/log.h" @@ -83,7 +84,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; @@ -120,7 +125,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/tests/components/deep_sleep/test.esp32-c6-idf.yaml b/tests/components/deep_sleep/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c6-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s2-idf.yaml b/tests/components/deep_sleep/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s3-idf.yaml b/tests/components/deep_sleep/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml From 8fe582309e18b019136c1f5d2347935d2fe53de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:54 +0200 Subject: [PATCH 14/42] [esp32_ble_client] Reduce log level for harmless BLE timeout race conditions (#10339) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../esp32_ble_client/ble_client_base.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index b9b7c75e0a..351658279f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -136,7 +136,8 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - this->log_warning_("Disconnect before connected, disconnect scheduled."); + ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, + this->address_str_.c_str()); this->want_disconnect_ = true; return; } @@ -284,11 +285,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; + + // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an + // error, if the error occurred at the BTA/GATT layer. This can result in the event + // arriving after we've already transitioned to IDLE state. + if (this->state_ == espbt::ClientState::IDLE) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, + this->address_str_.c_str(), param->open.status); + break; + } + if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); + ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); From a58c3950bc7f055eb91f61655087fc6eb5f19ad0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:52:37 +1000 Subject: [PATCH 15/42] [lvgl] Fix meter rotation (#10342) --- esphome/components/lvgl/widgets/canvas.py | 10 +++++----- esphome/components/lvgl/widgets/meter.py | 9 +++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4fd81b6e4a..217e8935f1 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -24,7 +24,7 @@ from ..defines import ( literal, ) from ..lv_validation import ( - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_image, @@ -395,15 +395,15 @@ ARC_PROPS = { DRAW_OPA_SCHEMA.extend( { cv.Required(CONF_RADIUS): pixels, - cv.Required(CONF_START_ANGLE): lv_angle, - cv.Required(CONF_END_ANGLE): lv_angle, + cv.Required(CONF_START_ANGLE): lv_angle_degrees, + cv.Required(CONF_END_ANGLE): lv_angle_degrees, } ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) - start_angle = await lv_angle.process(config[CONF_START_ANGLE]) - end_angle = await lv_angle.process(config[CONF_END_ANGLE]) + start_angle = await lv_angle_degrees.process(config[CONF_START_ANGLE]) + end_angle = await lv_angle_degrees.process(config[CONF_END_ANGLE]) async def do_draw_arc(w: Widget, x, y, dsc_addr): lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index acec986f99..aefda0e71a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_VALUE, CONF_WIDTH, ) -from esphome.cpp_generator import IntLiteral from ..automation import action_to_code from ..defines import ( @@ -32,7 +31,7 @@ from ..helpers import add_lv_use, lvgl_components_required from ..lv_validation import ( get_end_value, get_start_value, - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_float, @@ -163,7 +162,7 @@ SCALE_SCHEMA = cv.Schema( cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), - cv.Optional(CONF_ROTATION): lv_angle, + cv.Optional(CONF_ROTATION): lv_angle_degrees, cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), } ) @@ -188,9 +187,7 @@ class MeterType(WidgetType): for scale_conf in config.get(CONF_SCALES, ()): rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 if CONF_ROTATION in scale_conf: - rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) - if isinstance(rotation, IntLiteral): - rotation = int(str(rotation)) // 10 + rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION]) with LocalVariable( "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) ) as meter_var: From 07bca6103f24bf0e9b3715db21638f345bced996 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:57:09 +0200 Subject: [PATCH 16/42] [esp32_ble_tracker] Fix on_scan_end trigger compilation without USE_ESP32_BLE_DEVICE (#10399) --- esphome/components/esp32_ble_tracker/automation.h | 5 ++++- .../esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index c0e6eee138..784f2eaaa2 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -80,14 +80,17 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, ESPBTUUID uuid_; }; +#endif // USE_ESP32_BLE_DEVICE + class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } +#ifdef USE_ESP32_BLE_DEVICE bool parse_device(const ESPBTDevice &device) override { return false; } +#endif void on_scan_end() override { this->trigger(); } }; -#endif // USE_ESP32_BLE_DEVICE template class ESP32BLEStartScanAction : public Action { public: diff --git a/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml new file mode 100644 index 0000000000..4e9849a540 --- /dev/null +++ b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml @@ -0,0 +1,3 @@ +esp32_ble_tracker: + on_scan_end: + - logger.log: "Scan ended!" From 9f0257528794ac60d3d9f3eb353f83b69845e30f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:58:46 +0200 Subject: [PATCH 17/42] [test] Add integration test for light effect memory corruption fix (#10417) --- tests/integration/fixtures/light_calls.yaml | 32 ++++++++++++++- tests/integration/test_light_calls.py | 45 +++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/tests/integration/fixtures/light_calls.yaml b/tests/integration/fixtures/light_calls.yaml index d692a11765..2b7650526f 100644 --- a/tests/integration/fixtures/light_calls.yaml +++ b/tests/integration/fixtures/light_calls.yaml @@ -56,10 +56,29 @@ light: warm_white_color_temperature: 2000 K constant_brightness: true effects: + # Use default parameters: - random: - name: "Random Effect" + # Customize parameters - use longer names to potentially trigger buffer issues + - random: + name: "My Very Slow Random Effect With Long Name" + transition_length: 30ms + update_interval: 30ms + - random: + name: "My Fast Random Effect That Changes Quickly" + transition_length: 4ms + update_interval: 5ms + - random: + name: "Random Effect With Medium Length Name Here" transition_length: 100ms update_interval: 200ms + - random: + name: "Another Random Effect With Different Parameters" + transition_length: 2ms + update_interval: 3ms + - random: + name: "Yet Another Random Effect To Test Memory" + transition_length: 15ms + update_interval: 20ms - strobe: name: "Strobe Effect" - pulse: @@ -73,6 +92,17 @@ light: red: test_red green: test_green blue: test_blue + effects: + # Same random effects to test for cross-contamination + - random: + - random: + name: "RGB Slow Random" + transition_length: 20ms + update_interval: 25ms + - random: + name: "RGB Fast Random" + transition_length: 2ms + update_interval: 3ms - platform: binary name: "Test Binary Light" diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 1a0a9e553f..af90ddbe86 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -108,14 +108,51 @@ async def test_light_calls( # Wait for flash to end state = await wait_for_state_change(rgbcw_light.key) - # Test 13: effect only + # Test 13: effect only - test all random effects # First ensure light is on client.light_command(key=rgbcw_light.key, state=True) state = await wait_for_state_change(rgbcw_light.key) - # Now set effect - client.light_command(key=rgbcw_light.key, effect="Random Effect") + + # Test 13a: Default random effect (no name, gets default name "Random") + client.light_command(key=rgbcw_light.key, effect="Random") state = await wait_for_state_change(rgbcw_light.key) - assert state.effect == "Random Effect" + assert state.effect == "Random" + + # Test 13b: Slow random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Very Slow Random Effect With Long Name" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Very Slow Random Effect With Long Name" + + # Test 13c: Fast random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Fast Random Effect That Changes Quickly" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Fast Random Effect That Changes Quickly" + + # Test 13d: Random effect with medium length name + client.light_command( + key=rgbcw_light.key, effect="Random Effect With Medium Length Name Here" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Random Effect With Medium Length Name Here" + + # Test 13e: Another random effect + client.light_command( + key=rgbcw_light.key, + effect="Another Random Effect With Different Parameters", + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Another Random Effect With Different Parameters" + + # Test 13f: Yet another random effect + client.light_command( + key=rgbcw_light.key, effect="Yet Another Random Effect To Test Memory" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Yet Another Random Effect To Test Memory" # Test 14: stop effect client.light_command(key=rgbcw_light.key, effect="None") From 456c31262d558c74100ca2481c70bb7f2e64b220 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 07:04:32 +1000 Subject: [PATCH 18/42] [web_server] Use oi.esphome.io for css and js assets (#10296) --- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/web_server/web_server.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 695757e137..be193bbab8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -52,9 +52,9 @@ def default_url(config: ConfigType) -> ConfigType: config = config.copy() if config[CONF_VERSION] == 1: if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "https://esphome.io/_static/webserver-v1.min.css" + config[CONF_CSS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.css" if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://esphome.io/_static/webserver-v1.min.js" + config[CONF_JS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.js" if config[CONF_VERSION] == 2: if CONF_CSS_URL not in config: config[CONF_CSS_URL] = "" diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6bece732fc..e42c35b32d 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -173,14 +173,14 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #if USE_WEBSERVER_VERSION == 1 /** Set the URL to the CSS that's sent to each client. Defaults to - * https://esphome.io/_static/webserver-v1.min.css + * https://oi.esphome.io/v1/webserver-v1.min.css * * @param css_url The url to the web server stylesheet. */ void set_css_url(const char *css_url); /** Set the URL to the script that's embedded in the index page. Defaults to - * https://esphome.io/_static/webserver-v1.min.js + * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ From ecfeb8e4d320f1f63e222caa36e593dcfe5b4c0e Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 25 Aug 2025 01:51:28 +0200 Subject: [PATCH 19/42] improve AI instructions (#10416) --- .ai/instructions.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 4d6cd71f85..1cd77b9136 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -38,7 +38,7 @@ This document provides essential context for AI models interacting with this pro 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. * **Platform Support:** - 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (S2, S3, C3, etc.) and both IDF and Arduino frameworks. + 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3). 2. **ESP8266** (`components/esp8266/`): Espressif ESP8266. Arduino framework only, with memory constraints. 3. **RP2040** (`components/rp2040/`): Raspberry Pi Pico/RP2040. Arduino framework with PIO (Programmable I/O) support. 4. **LibreTiny** (`components/libretiny/`): Realtek and Beken chips. Supports multiple chip families and auto-generated components. @@ -60,7 +60,7 @@ This document provides essential context for AI models interacting with this pro ├── __init__.py # Component configuration schema and code generation ├── [component].h # C++ header file (if needed) ├── [component].cpp # C++ implementation (if needed) - └── [platform]/ # Platform-specific implementations + └── [platform]/ # Platform-specific implementations ├── __init__.py # Platform-specific configuration ├── [platform].h # Platform C++ header └── [platform].cpp # Platform C++ implementation @@ -150,7 +150,8 @@ This document provides essential context for AI models interacting with this pro * **Configuration Validation:** * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. - * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `cv.only_with_arduino`. + * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`. + * **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`, `cv.only_with_esp_idf`. * **Schema Extensions:** ```python CONFIG_SCHEMA = cv.Schema({ ... }) From 6004367ee29942a74164db5f1d7487d5d6198271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Aug 2025 02:07:04 +0200 Subject: [PATCH 20/42] [esp32_ble_client] Add missing ESP_GATTC_UNREG_FOR_NOTIFY_EVT logging (#10347) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 351658279f..4805855960 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -495,6 +495,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->log_gattc_event_("UNREG_FOR_NOTIFY"); + break; + } + default: // ideally would check all other events for matching conn_id ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_.c_str(), event); From d2752b38c963f42336e0b48e104c4613e890fc99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Aug 2025 02:22:16 +0200 Subject: [PATCH 21/42] [core] Fix preference storage to account for device_id (#10333) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/climate/climate.cpp | 2 +- esphome/components/cover/cover.cpp | 2 +- .../components/duty_time/duty_time_sensor.cpp | 2 +- esphome/components/fan/fan.cpp | 3 +- esphome/components/haier/haier_base.cpp | 2 +- esphome/components/haier/hon_climate.cpp | 2 +- .../integration/integration_sensor.cpp | 2 +- esphome/components/ld2450/ld2450.cpp | 2 +- esphome/components/light/light_state.cpp | 4 +- esphome/components/lvgl/number/lvgl_number.h | 2 +- esphome/components/lvgl/select/lvgl_select.h | 2 +- esphome/components/number/automation.cpp | 2 +- .../opentherm/number/opentherm_number.cpp | 2 +- .../rotary_encoder/rotary_encoder.cpp | 2 +- esphome/components/sensor/automation.h | 2 +- .../media_player/speaker_media_player.cpp | 2 +- esphome/components/sprinkler/sprinkler.cpp | 2 +- esphome/components/switch/switch.cpp | 2 +- .../template_alarm_control_panel.cpp | 2 +- .../template/datetime/template_date.cpp | 2 +- .../template/datetime/template_datetime.cpp | 4 +- .../template/datetime/template_time.cpp | 2 +- .../template/number/template_number.cpp | 2 +- .../template/select/template_select.cpp | 2 +- .../template/text/template_text.cpp | 2 +- .../total_daily_energy/total_daily_energy.cpp | 2 +- .../components/tuya/number/tuya_number.cpp | 2 +- esphome/components/valve/valve.cpp | 2 +- esphome/core/entity_base.h | 29 +++ .../fixtures/multi_device_preferences.yaml | 165 ++++++++++++++++++ .../test_multi_device_preferences.py | 144 +++++++++++++++ 31 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 tests/integration/fixtures/multi_device_preferences.yaml create mode 100644 tests/integration/test_multi_device_preferences.py diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index edebc0de69..be56310b35 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -327,7 +327,7 @@ void Climate::add_on_control_callback(std::function &&callb static const uint32_t RESTORE_STATE_VERSION = 0x848EA6ADUL; optional Climate::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash() ^ + this->rtc_ = global_preferences->make_preference(this->get_preference_hash() ^ RESTORE_STATE_VERSION); ClimateDeviceRestoreState recovered{}; if (!this->rtc_.load(&recovered)) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 68dfab111b..700bceec01 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -194,7 +194,7 @@ void Cover::publish_state(bool save) { } } optional Cover::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); CoverRestoreState recovered{}; if (!this->rtc_.load(&recovered)) return {}; diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index c7319f7c33..f77f1fcf53 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -41,7 +41,7 @@ void DutyTimeSensor::setup() { uint32_t seconds = 0; if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); this->pref_.load(&seconds); } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 82fc5319e0..26065ed644 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -148,7 +148,8 @@ void Fan::publish_state() { constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABA; optional Fan::restore_state_() { FanRestoreState recovered{}; - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash() ^ RESTORE_STATE_VERSION); + this->rtc_ = + global_preferences->make_preference(this->get_preference_hash() ^ RESTORE_STATE_VERSION); bool restored = this->rtc_.load(&recovered); switch (this->restore_mode_) { diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 4f933b08e3..55a2454fca 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -351,7 +351,7 @@ ClimateTraits HaierClimateBase::traits() { return traits_; } void HaierClimateBase::initialization() { constexpr uint32_t restore_settings_version = 0xA77D21EF; this->base_rtc_ = - global_preferences->make_preference(this->get_object_id_hash() ^ restore_settings_version); + global_preferences->make_preference(this->get_preference_hash() ^ restore_settings_version); HaierBaseSettings recovered; if (!this->base_rtc_.load(&recovered)) { recovered = {false, true}; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index fd2d6a5800..9614bb1e47 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -516,7 +516,7 @@ void HonClimate::initialization() { HaierClimateBase::initialization(); constexpr uint32_t restore_settings_version = 0x57EB59DDUL; this->hon_rtc_ = - global_preferences->make_preference(this->get_object_id_hash() ^ restore_settings_version); + global_preferences->make_preference(this->get_preference_hash() ^ restore_settings_version); HonSettings recovered; if (this->hon_rtc_.load(&recovered)) { this->settings_ = recovered; diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index c09778e79e..80c718dc8d 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "integration"; void IntegrationSensor::setup() { if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); float preference_value = 0; this->pref_.load(&preference_value); this->result_ = preference_value; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b123d541d9..f30752e5a2 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui void LD2450Component::setup() { #ifdef USE_NUMBER if (this->presence_timeout_number_ != nullptr) { - this->pref_ = global_preferences->make_preference(this->presence_timeout_number_->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->presence_timeout_number_->get_preference_hash()); this->set_presence_timeout(); } #endif diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9e42b2f1e2..f18d5ba1de 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -41,7 +41,7 @@ void LightState::setup() { case LIGHT_RESTORE_DEFAULT_ON: case LIGHT_RESTORE_INVERTED_DEFAULT_OFF: case LIGHT_RESTORE_INVERTED_DEFAULT_ON: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); // Attempt to load from preferences, else fall back to default values if (!this->rtc_.load(&recovered)) { recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON || @@ -54,7 +54,7 @@ void LightState::setup() { break; case LIGHT_RESTORE_AND_OFF: case LIGHT_RESTORE_AND_ON: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); this->rtc_.load(&recovered); recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON); break; diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 277494673b..7bc44c9e20 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -21,7 +21,7 @@ class LVGLNumber : public number::Number, public Component { void setup() override { float value = this->value_lambda_(); if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&value)) { this->control_lambda_(value); } diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index 5b43209a5f..a0e60295a6 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -20,7 +20,7 @@ class LVGLSelect : public select::Select, public Component { this->set_options_(); if (this->restore_) { size_t index; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&index)) this->widget_->set_selected_index(index, LV_ANIM_OFF); } diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index cadc6f54f6..bfc59d0465 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -15,7 +15,7 @@ void ValueRangeTrigger::setup() { float local_min = this->min_.value(0.0); float local_max = this->max_.value(0.0); convert hash = {.from = (local_max - local_min)}; - uint32_t myhash = hash.to ^ this->parent_->get_object_id_hash(); + uint32_t myhash = hash.to ^ this->parent_->get_preference_hash(); this->rtc_ = global_preferences->make_preference(myhash); bool initial_state; if (this->rtc_.load(&initial_state)) { diff --git a/esphome/components/opentherm/number/opentherm_number.cpp b/esphome/components/opentherm/number/opentherm_number.cpp index c5d094aa36..f0c69144c8 100644 --- a/esphome/components/opentherm/number/opentherm_number.cpp +++ b/esphome/components/opentherm/number/opentherm_number.cpp @@ -17,7 +17,7 @@ void OpenthermNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 20ea8d0293..26e20664f2 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -132,7 +132,7 @@ void RotaryEncoderSensor::setup() { int32_t initial_value = 0; switch (this->restore_mode_) { case ROTARY_ENCODER_RESTORE_DEFAULT_ZERO: - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->rtc_.load(&initial_value)) { initial_value = 0; } diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 8cd0adbeb2..4f34c35023 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -40,7 +40,7 @@ class ValueRangeTrigger : public Trigger, public Component { template void set_max(V max) { this->max_ = max; } void setup() override { - this->rtc_ = global_preferences->make_preference(this->parent_->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->parent_->get_preference_hash()); bool initial_state; if (this->rtc_.load(&initial_state)) { this->previous_in_range_ = initial_state; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 2c30f17c78..b45a78010a 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -55,7 +55,7 @@ void SpeakerMediaPlayer::setup() { this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand)); - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); VolumeRestoreState volume_restore_state; if (this->pref_.load(&volume_restore_state)) { diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index e191498857..7676e17468 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -81,7 +81,7 @@ void SprinklerControllerNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 13c12c1213..49acd274b2 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -32,7 +32,7 @@ optional Switch::get_initial_state() { if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK)) return {}; - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); bool initial_state; if (!this->rtc_.load(&initial_state)) return {}; diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index 11a148830d..eac0629480 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -86,7 +86,7 @@ void TemplateAlarmControlPanel::setup() { break; case ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED: { uint8_t value; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (this->pref_.load(&value)) { this->current_state_ = static_cast(value); } else { diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index 01e15e532e..2fa8016802 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -20,7 +20,7 @@ void TemplateDate::setup() { } else { datetime::DateEntityRestoreState temp; this->pref_ = - global_preferences->make_preference(194434030U ^ this->get_object_id_hash()); + global_preferences->make_preference(194434030U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 3ab74e197f..a4a4e47d65 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -19,8 +19,8 @@ void TemplateDateTime::setup() { state = this->initial_value_; } else { datetime::DateTimeEntityRestoreState temp; - this->pref_ = global_preferences->make_preference(194434090U ^ - this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference( + 194434090U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index 0e4d734d16..349700f187 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -20,7 +20,7 @@ void TemplateTime::setup() { } else { datetime::TimeEntityRestoreState temp; this->pref_ = - global_preferences->make_preference(194434060U ^ this->get_object_id_hash()); + global_preferences->make_preference(194434060U ^ this->get_preference_hash()); if (this->pref_.load(&temp)) { temp.apply(this); return; diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index aaf5b27a71..187f426273 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -14,7 +14,7 @@ void TemplateNumber::setup() { if (!this->restore_value_) { value = this->initial_value_; } else { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&value)) { if (!std::isnan(this->initial_value_)) { value = this->initial_value_; diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 6ec29c8ef0..95b0ee0d2b 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -16,7 +16,7 @@ void TemplateSelect::setup() { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); } else { size_t index; - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); if (!this->pref_.load(&index)) { value = this->initial_option_; ESP_LOGD(TAG, "State from initial (could not load stored index): %s", value.c_str()); diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index f5df7287c5..d8e840ba7e 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -15,7 +15,7 @@ void TemplateText::setup() { if (!this->pref_) { ESP_LOGD(TAG, "State from initial: %s", value.c_str()); } else { - uint32_t key = this->get_object_id_hash(); + uint32_t key = this->get_preference_hash(); key += this->traits.get_min_length() << 2; key += this->traits.get_max_length() << 4; key += fnv1_hash(this->traits.get_pattern()) << 6; diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 7c316c495d..818696f99b 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -10,7 +10,7 @@ void TotalDailyEnergy::setup() { float initial_value = 0; if (this->restore_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); this->pref_.load(&initial_value); } this->publish_state_and_save(initial_value); diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index 68a7f8f2a7..44b22167de 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -8,7 +8,7 @@ static const char *const TAG = "tuya.number"; void TuyaNumber::setup() { if (this->restore_value_) { - this->pref_ = global_preferences->make_preference(this->get_object_id_hash()); + this->pref_ = global_preferences->make_preference(this->get_preference_hash()); } this->parent_->register_listener(this->number_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index d1ec17945a..0ee710fc02 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -155,7 +155,7 @@ void Valve::publish_state(bool save) { } } optional Valve::restore_state_() { - this->rtc_ = global_preferences->make_preference(this->get_object_id_hash()); + this->rtc_ = global_preferences->make_preference(this->get_preference_hash()); ValveRestoreState recovered{}; if (!this->rtc_.load(&recovered)) return {}; diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 68163ce8c3..8a65a9627a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -85,6 +85,35 @@ class EntityBase { // Set has_state - for components that need to manually set this void set_has_state(bool state) { this->flags_.has_state = state; } + /** + * @brief Get a unique hash for storing preferences/settings for this entity. + * + * This method returns a hash that uniquely identifies the entity for the purpose of + * storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(), + * this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness + * across multiple devices that may have entities with the same object_id. + * + * Use this method when storing or retrieving preferences/settings that should be unique + * per device-entity pair. Use get_object_id_hash() when you need a hash that identifies + * the entity regardless of the device it belongs to. + * + * For backward compatibility, if device_id is 0 (the main device), the hash is unchanged + * from previous versions, so existing single-device configurations will continue to work. + * + * @return uint32_t The unique hash for preferences, including device_id if available. + */ + uint32_t get_preference_hash() { +#ifdef USE_DEVICES + // Combine object_id_hash with device_id to ensure uniqueness across devices + // Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash + // This ensures backward compatibility for existing single-device configurations + return this->get_object_id_hash() ^ this->get_device_id(); +#else + // Without devices, just use object_id_hash as before + return this->get_object_id_hash(); +#endif + } + protected: friend class api::APIConnection; diff --git a/tests/integration/fixtures/multi_device_preferences.yaml b/tests/integration/fixtures/multi_device_preferences.yaml new file mode 100644 index 0000000000..634d7157b2 --- /dev/null +++ b/tests/integration/fixtures/multi_device_preferences.yaml @@ -0,0 +1,165 @@ +esphome: + name: multi-device-preferences-test + # Define multiple devices for testing preference storage + devices: + - id: device_a + name: Device A + - id: device_b + name: Device B + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +# Test entities with restore modes to verify preference storage + +# Switches with same name on different devices - test restore mode +switch: + - platform: template + name: Light + id: light_device_a + device_id: device_a + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Device A Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Device A Light turned OFF"); + + - platform: template + name: Light + id: light_device_b + device_id: device_b + restore_mode: RESTORE_DEFAULT_ON # Different default to test uniqueness + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Device B Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Device B Light turned OFF"); + + - platform: template + name: Light + id: light_main + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - lambda: |- + ESP_LOGI("test", "Main Light turned ON"); + turn_off_action: + - lambda: |- + ESP_LOGI("test", "Main Light turned OFF"); + +# Numbers with restore to test preference storage +number: + - platform: template + name: Setpoint + id: setpoint_device_a + device_id: device_a + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 20.0 + set_action: + - lambda: |- + ESP_LOGI("test", "Device A Setpoint set to %.1f", x); + id(setpoint_device_a).state = x; + + - platform: template + name: Setpoint + id: setpoint_device_b + device_id: device_b + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 25.0 # Different initial to test uniqueness + set_action: + - lambda: |- + ESP_LOGI("test", "Device B Setpoint set to %.1f", x); + id(setpoint_device_b).state = x; + + - platform: template + name: Setpoint + id: setpoint_main + min_value: 10.0 + max_value: 30.0 + step: 0.5 + restore_value: true + initial_value: 22.0 + set_action: + - lambda: |- + ESP_LOGI("test", "Main Setpoint set to %.1f", x); + id(setpoint_main).state = x; + +# Selects with restore to test preference storage +select: + - platform: template + name: Mode + id: mode_device_a + device_id: device_a + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Auto" + set_action: + - lambda: |- + ESP_LOGI("test", "Device A Mode set to %s", x.c_str()); + id(mode_device_a).state = x; + + - platform: template + name: Mode + id: mode_device_b + device_id: device_b + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Manual" # Different initial to test uniqueness + set_action: + - lambda: |- + ESP_LOGI("test", "Device B Mode set to %s", x.c_str()); + id(mode_device_b).state = x; + + - platform: template + name: Mode + id: mode_main + options: + - "Auto" + - "Manual" + - "Off" + restore_value: true + initial_option: "Off" + set_action: + - lambda: |- + ESP_LOGI("test", "Main Mode set to %s", x.c_str()); + id(mode_main).state = x; + +# Button to trigger preference logging test +button: + - platform: template + name: Test Preferences + on_press: + - lambda: |- + ESP_LOGI("test", "Testing preference storage uniqueness:"); + ESP_LOGI("test", "Device A Light state: %s", id(light_device_a).state ? "ON" : "OFF"); + ESP_LOGI("test", "Device B Light state: %s", id(light_device_b).state ? "ON" : "OFF"); + ESP_LOGI("test", "Main Light state: %s", id(light_main).state ? "ON" : "OFF"); + ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state); + ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state); + ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state); + ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str()); + ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str()); + ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str()); + // Log preference hashes for entities that actually store preferences + ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash()); + ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash()); + ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash()); + ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash()); diff --git a/tests/integration/test_multi_device_preferences.py b/tests/integration/test_multi_device_preferences.py new file mode 100644 index 0000000000..625f83f16e --- /dev/null +++ b/tests/integration/test_multi_device_preferences.py @@ -0,0 +1,144 @@ +"""Test multi-device preference storage functionality.""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import ButtonInfo, NumberInfo, SelectInfo, SwitchInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_multi_device_preferences( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that entities with same names on different devices have unique preference storage.""" + loop = asyncio.get_running_loop() + log_lines: list[str] = [] + preferences_logged = loop.create_future() + + # Patterns to match preference hash logs + switch_hash_pattern_device = re.compile(r"Device ([AB]) Switch Pref Hash: (\d+)") + switch_hash_pattern_main = re.compile(r"Main Switch Pref Hash: (\d+)") + number_hash_pattern_device = re.compile(r"Device ([AB]) Number Pref Hash: (\d+)") + number_hash_pattern_main = re.compile(r"Main Number Pref Hash: (\d+)") + switch_hashes: dict[str, int] = {} + number_hashes: dict[str, int] = {} + + def check_output(line: str) -> None: + """Check log output for preference hash information.""" + log_lines.append(line) + + # Look for device switch preference hash logs + match = switch_hash_pattern_device.search(line) + if match: + device = match.group(1) + hash_value = int(match.group(2)) + switch_hashes[device] = hash_value + + # Look for main switch preference hash + match = switch_hash_pattern_main.search(line) + if match: + hash_value = int(match.group(1)) + switch_hashes["Main"] = hash_value + + # Look for device number preference hash logs + match = number_hash_pattern_device.search(line) + if match: + device = match.group(1) + hash_value = int(match.group(2)) + number_hashes[device] = hash_value + + # Look for main number preference hash + match = number_hash_pattern_main.search(line) + if match: + hash_value = int(match.group(1)) + number_hashes["Main"] = hash_value + + # If we have all hashes, complete the future + if ( + len(switch_hashes) == 3 + and len(number_hashes) == 3 + and not preferences_logged.done() + ): + preferences_logged.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entity list + entities, _ = await client.list_entities_services() + + # Verify we have the expected entities with duplicate names on different devices + + # Check switches (3 with name "Light") + switches = [ + e for e in entities if isinstance(e, SwitchInfo) and e.name == "Light" + ] + assert len(switches) == 3, f"Expected 3 'Light' switches, got {len(switches)}" + + # Check numbers (3 with name "Setpoint") + numbers = [ + e for e in entities if isinstance(e, NumberInfo) and e.name == "Setpoint" + ] + assert len(numbers) == 3, f"Expected 3 'Setpoint' numbers, got {len(numbers)}" + + # Check selects (3 with name "Mode") + selects = [ + e for e in entities if isinstance(e, SelectInfo) and e.name == "Mode" + ] + assert len(selects) == 3, f"Expected 3 'Mode' selects, got {len(selects)}" + + # Find the test button entity to trigger preference logging + buttons = [e for e in entities if isinstance(e, ButtonInfo)] + test_button = next((b for b in buttons if b.name == "Test Preferences"), None) + assert test_button is not None, "Test Preferences button not found" + + # Press the button to trigger logging + client.button_command(test_button.key) + + # Wait for preference hashes to be logged + try: + await asyncio.wait_for(preferences_logged, timeout=5.0) + except TimeoutError: + pytest.fail("Preference hashes not logged within timeout") + + # Verify all switch preference hashes are unique + assert len(switch_hashes) == 3, ( + f"Expected 3 devices with switches, got {switch_hashes}" + ) + switch_hash_values = list(switch_hashes.values()) + assert len(switch_hash_values) == len(set(switch_hash_values)), ( + f"Switch preference hashes are not unique: {switch_hashes}" + ) + + # Verify all number preference hashes are unique + assert len(number_hashes) == 3, ( + f"Expected 3 devices with numbers, got {number_hashes}" + ) + number_hash_values = list(number_hashes.values()) + assert len(number_hash_values) == len(set(number_hash_values)), ( + f"Number preference hashes are not unique: {number_hashes}" + ) + + # Verify Device A and Device B have different hashes (they have device_id set) + assert switch_hashes["A"] != switch_hashes["B"], ( + f"Device A and B switches should have different hashes: A={switch_hashes['A']}, B={switch_hashes['B']}" + ) + assert number_hashes["A"] != number_hashes["B"], ( + f"Device A and B numbers should have different hashes: A={number_hashes['A']}, B={number_hashes['B']}" + ) + + # Verify Main device hash is different from both A and B + assert switch_hashes["Main"] != switch_hashes["A"], ( + f"Main and Device A switches should have different hashes: Main={switch_hashes['Main']}, A={switch_hashes['A']}" + ) + assert switch_hashes["Main"] != switch_hashes["B"], ( + f"Main and Device B switches should have different hashes: Main={switch_hashes['Main']}, B={switch_hashes['B']}" + ) From 6da8ec8d555972ec6a3829dccfad96adaea75f32 Mon Sep 17 00:00:00 2001 From: Jonathan Rascher Date: Sun, 24 Aug 2025 22:40:19 -0500 Subject: [PATCH 22/42] Merge commit from fork Ensures auth check doesn't pass erroneously when the client-supplied digest is shorter than the correct digest, but happens to match a prefix of the correct value (e.g., same username + certain substrings of the password). --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 3226444b1b..55b07c0f5e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -253,7 +253,7 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw esp_crypto_base64_encode(reinterpret_cast(digest.get()), n, &out, reinterpret_cast(user_info.c_str()), user_info.size()); - return strncmp(digest.get(), auth_str + auth_prefix_len, auth.value().size() - auth_prefix_len) == 0; + return strcmp(digest.get(), auth_str + auth_prefix_len) == 0; } void AsyncWebServerRequest::requestAuthentication(const char *realm) const { From da16887915e9e44c5e534ee03ad99e3efed3749e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:26:53 -0500 Subject: [PATCH 23/42] [api] Add zero-copy StringRef methods for compilation_time and effect_name (#10257) --- esphome/components/api/api_connection.cpp | 8 ++------ esphome/components/light/light_state.cpp | 14 ++++++++++++-- esphome/components/light/light_state.h | 3 +++ esphome/core/application.h | 3 +++ 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cdeabb5cac..a5bde1d2ec 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -455,9 +455,7 @@ uint16_t APIConnection::try_send_light_state(EntityBase *entity, APIConnection * resp.cold_white = values.get_cold_white(); resp.warm_white = values.get_warm_white(); if (light->supports_effects()) { - // get_effect_name() returns temporary std::string - must store it - std::string effect_name = light->get_effect_name(); - resp.set_effect(StringRef(effect_name)); + resp.set_effect(light->get_effect_name_ref()); } return fill_and_encode_entity_state(light, resp, LightStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1415,9 +1413,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); - // get_compilation_time() returns temporary std::string - must store it - std::string compilation_time = App.get_compilation_time(); - resp.set_compilation_time(StringRef(compilation_time)); + resp.set_compilation_time(App.get_compilation_time_ref()); // Compile-time StringRef constants for manufacturers #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 5b57707d6b..9e42b2f1e2 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -140,12 +140,22 @@ float LightState::get_setup_priority() const { return setup_priority::HARDWARE - void LightState::publish_state() { this->remote_values_callback_.call(); } LightOutput *LightState::get_output() const { return this->output_; } + +static constexpr const char *EFFECT_NONE = "None"; +static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); + std::string LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); - } else { - return "None"; } + return EFFECT_NONE; +} + +StringRef LightState::get_effect_name_ref() { + if (this->active_effect_index_ > 0) { + return StringRef(this->effects_[this->active_effect_index_ - 1]->get_name()); + } + return EFFECT_NONE_REF; } void LightState::add_new_remote_values_callback(std::function &&send_callback) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 72cb99223e..94b81dee61 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -4,6 +4,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/optional.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "light_call.h" #include "light_color_values.h" #include "light_effect.h" @@ -116,6 +117,8 @@ class LightState : public EntityBase, public Component { /// Return the name of the current effect, or if no effect is active "None". std::string get_effect_name(); + /// Return the name of the current effect as StringRef (for API usage) + StringRef get_effect_name_ref(); /** * This lets front-end components subscribe to light change events. This callback is called once diff --git a/esphome/core/application.h b/esphome/core/application.h index 4120afff53..9cb2a4c638 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" +#include "esphome/core/string_ref.h" #ifdef USE_DEVICES #include "esphome/core/device.h" @@ -248,6 +249,8 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } std::string get_compilation_time() const { return this->compilation_time_; } + /// Get the compilation time as StringRef (for API usage) + StringRef get_compilation_time_ref() const { return StringRef(this->compilation_time_); } /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } From d054709c2df078efaef84dbdb235cbd0ab51aae6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Aug 2025 21:38:32 -0500 Subject: [PATCH 24/42] [esp32_ble_client] Add log helper functions to reduce flash usage by 120 bytes (#10243) --- .../esp32_ble_client/ble_client_base.cpp | 42 ++++++++++--------- .../esp32_ble_client/ble_client_base.h | 4 ++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 2d84436d84..e23be2e0c1 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -159,8 +159,7 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGW(TAG, "[%d] [%s] Disconnecting before connected, disconnect scheduled.", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Disconnect before connected, disconnect scheduled."); this->want_disconnect_ = true; return; } @@ -172,13 +171,11 @@ void BLEClientBase::unconditional_disconnect() { ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_.c_str(), this->conn_id_); if (this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGE(TAG, "[%d] [%s] Tried to disconnect while already disconnecting.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("Already disconnecting"); return; } if (this->conn_id_ == UNSET_CONN_ID) { - ESP_LOGE(TAG, "[%d] [%s] No connection ID set, cannot disconnect.", this->connection_index_, - this->address_str_.c_str()); + this->log_error_("conn id unset, cannot disconnect"); return; } auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); @@ -234,6 +231,18 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::log_error_(const char *message) { + ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + +void BLEClientBase::log_error_(const char *message, int code) { + ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_str_.c_str(), message, code); +} + +void BLEClientBase::log_warning_(const char *message) { + ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); +} + void BLEClientBase::restore_medium_conn_params_() { // Restore to medium connection parameters after initial connection phase // This balances performance with bandwidth usage for normal operation @@ -264,8 +273,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->app_id); this->gattc_if_ = esp_gattc_if; } else { - ESP_LOGE(TAG, "[%d] [%s] gattc app registration failed id=%d code=%d", this->connection_index_, - this->address_str_.c_str(), param->reg.app_id, param->reg.status); + this->log_error_("gattc app registration failed status", param->reg.status); this->status_ = param->reg.status; this->mark_failed(); } @@ -281,8 +289,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - ESP_LOGE(TAG, "[%d] [%s] Got ESP_GATTC_OPEN_EVT while in %s state, status=%d", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); + this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -307,7 +314,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->state_ = espbt::ClientState::ESTABLISHED; break; } - ESP_LOGD(TAG, "[%d] [%s] Searching for services", this->connection_index_, this->address_str_.c_str()); + this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; } @@ -332,8 +339,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // Check if we were disconnected while waiting for service discovery if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_ == espbt::ClientState::CONNECTED) { - ESP_LOGW(TAG, "[%d] [%s] Disconnected by remote during service discovery", this->connection_index_, - this->address_str_.c_str()); + this->log_warning_("Remote closed during discovery"); } else { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_.c_str(), param->disconnect.reason); @@ -506,16 +512,14 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ return; esp_bd_addr_t bd_addr; memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, sizeof(esp_bd_addr_t)); - ESP_LOGI(TAG, "[%d] [%s] auth complete. remote BD_ADDR: %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_.c_str(), format_hex(bd_addr, 6).c_str()); if (!param->ble_security.auth_cmpl.success) { - ESP_LOGE(TAG, "[%d] [%s] auth fail reason = 0x%x", this->connection_index_, this->address_str_.c_str(), - param->ble_security.auth_cmpl.fail_reason); + this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { this->paired_ = true; - ESP_LOGD(TAG, "[%d] [%s] auth success. address type = %d auth mode = %d", this->connection_index_, - this->address_str_.c_str(), param->ble_security.auth_cmpl.addr_type, - param->ble_security.auth_cmpl.auth_mode); + ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_.c_str(), + param->ble_security.auth_cmpl.addr_type, param->ble_security.auth_cmpl.auth_mode); } break; diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 2d00688dbd..1850b2c5b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -137,6 +137,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + // Compact error logging helpers to reduce flash usage + void log_error_(const char *message); + void log_error_(const char *message, int code); + void log_warning_(const char *message); }; } // namespace esphome::esp32_ble_client From 0de725942817e6a9fd6a31d199f449fee6d83a0c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 20 Aug 2025 23:47:20 +1200 Subject: [PATCH 25/42] [api] Add ``USE_API_HOMEASSISTANT_SERVICES`` if using ``tag_scanned`` action (#10316) --- esphome/components/api/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ba5f994e9a..2672ea1edb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -321,6 +321,7 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, ) async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) cg.add(var.set_service("esphome.tag_scanned")) From 50408d9abb3a59109e045e33692ba8dc6dd9fb0c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 05:55:44 +1000 Subject: [PATCH 26/42] [http_request] Fix for host after ArduinoJson library bump (#10348) --- esphome/components/http_request/http_request_host.cpp | 7 +++++-- esphome/components/http_request/http_request_host.h | 6 +----- esphome/components/http_request/httplib.h | 10 +++++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 192032c1ac..0b4c998a40 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -1,7 +1,10 @@ -#include "http_request_host.h" - #ifdef USE_HOST +#define USE_HTTP_REQUEST_HOST_H +#define CPPHTTPLIB_NO_EXCEPTIONS +#include "httplib.h" +#include "http_request_host.h" + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 49fd3b43fe..bbeed87f70 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -1,11 +1,7 @@ #pragma once -#include "http_request.h" - #ifdef USE_HOST - -#define CPPHTTPLIB_NO_EXCEPTIONS -#include "httplib.h" +#include "http_request.h" namespace esphome { namespace http_request { diff --git a/esphome/components/http_request/httplib.h b/esphome/components/http_request/httplib.h index a2f4436ec7..8b08699702 100644 --- a/esphome/components/http_request/httplib.h +++ b/esphome/components/http_request/httplib.h @@ -3,12 +3,10 @@ /** * NOTE: This is a copy of httplib.h from https://github.com/yhirose/cpp-httplib * - * It has been modified only to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, + * It has been modified to add ifdefs for USE_HOST. While it contains many functions unused in ESPHome, * it was considered preferable to use it with as few changes as possible, to facilitate future updates. */ -#include "esphome/core/defines.h" - // // httplib.h // @@ -17,6 +15,11 @@ // #ifdef USE_HOST +// Prevent this code being included in main.cpp +#ifdef USE_HTTP_REQUEST_HOST_H + +#include "esphome/core/defines.h" + #ifndef CPPHTTPLIB_HTTPLIB_H #define CPPHTTPLIB_HTTPLIB_H @@ -9687,5 +9690,6 @@ inline SSL_CTX *Client::ssl_context() const { #endif #endif // CPPHTTPLIB_HTTPLIB_H +#endif // USE_HTTP_REQUEST_HOST_H #endif From 03836ee2d238403f23fae53249c18ac4a651ef65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:11:54 +0200 Subject: [PATCH 27/42] [core] Improve error reporting for entity name conflicts with non-ASCII characters (#10329) --- esphome/core/entity_helpers.py | 13 +++++- tests/unit_tests/core/test_entity_helpers.py | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 1ccc3e2683..e1b2a8264b 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -236,10 +236,21 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy if existing_component != "unknown": conflict_msg += f" from component '{existing_component}'" + # Show both original names and their ASCII-only versions if they differ + sanitized_msg = "" + if entity_name != existing_name: + sanitized_msg = ( + f"\n Original names: '{entity_name}' and '{existing_name}'" + f"\n Both convert to ASCII ID: '{name_key}'" + "\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')" + "\n to distinguish them" + ) + raise cv.Invalid( f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " f"{conflict_msg}. " - f"Each entity on a device must have a unique name within its platform." + "Each entity on a device must have a unique name within its platform." + f"{sanitized_msg}" ) # Store metadata about this entity diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index db99243a1a..9ba5367413 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -705,3 +705,48 @@ def test_empty_or_null_device_id_on_entity() -> None: config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: None} validated2 = validator(config2) assert validated2 == config2 + + +def test_entity_duplicate_validator_non_ascii_names() -> None: + """Test that non-ASCII names show helpful error messages.""" + # Create validator for binary_sensor platform + validator = entity_duplicate_validator("binary_sensor") + + # First Russian sensor should pass + config1 = {CONF_NAME: "Датчик открытия основного крана"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second Russian sensor with different text but same ASCII conversion should fail + config2 = {CONF_NAME: "Датчик закрытия основного крана"} + with pytest.raises( + Invalid, + match=re.compile( + r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*" + r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*" + r"Both convert to ASCII ID: '_______________________________'.*" + r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)", + re.DOTALL, + ), + ): + validator(config2) + + +def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: + """Test that identical names don't show the enhanced message.""" + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + + # Second entity with exact same name should fail without enhanced message + config2 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found.*" + r"Each entity on a device must have a unique name within its platform\.$", + ): + validator(config2) From 07715dd50f65eaa292d5da18df4c717cd1087794 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:16:12 +0200 Subject: [PATCH 28/42] [pvvx_mithermometer] Fix race condition with BLE authentication (#10327) --- .../display/pvvx_display.cpp | 37 +++++++++++++++++-- .../pvvx_mithermometer/display/pvvx_display.h | 2 + 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 4b6c11b332..b6916ad68f 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -46,10 +46,32 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t } this->connection_established_ = true; this->char_handle_ = chr->handle; -#ifdef USE_TIME - this->sync_time_(); -#endif - this->display(); + + // Attempt to write immediately + // For devices without security, this will work + // For devices with security that are already paired, this will work + // For devices that need pairing, the write will be retried after auth completes + this->sync_time_and_display_(); + break; + } + default: + break; + } +} + +void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr)) + return; + + if (param->ble_security.auth_cmpl.success) { + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + // Now that pairing is complete, perform the pending writes + this->sync_time_and_display_(); + } else { + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str()); + } break; } default: @@ -127,6 +149,13 @@ void PVVXDisplay::delayed_disconnect_() { this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } +void PVVXDisplay::sync_time_and_display_() { +#ifdef USE_TIME + this->sync_time_(); +#endif + this->display(); +} + #ifdef USE_TIME void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index 9739362024..c7fc523420 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -43,6 +43,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; /// Set validity period of the display information in seconds (1..65535) void set_validity_period(uint16_t validity_period) { this->validity_period_ = validity_period; } @@ -112,6 +113,7 @@ class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { void setcfgbit_(uint8_t bit, bool value); void send_to_setup_char_(uint8_t *blk, size_t size); void delayed_disconnect_(); + void sync_time_and_display_(); #ifdef USE_TIME void sync_time_(); time::RealTimeClock *time_{nullptr}; From 959ffde60e4c0596dcf1fa2d99c99ec5ae4f90c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:17:26 +0200 Subject: [PATCH 29/42] [esp32_ble_client] Optimize BLE connection parameters for different connection types (#10356) --- .../esp32_ble_client/ble_client_base.cpp | 99 +++++++++---------- .../esp32_ble_client/ble_client_base.h | 6 +- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e23be2e0c1..b9b7c75e0a 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace esphome::esp32_ble_client { @@ -111,43 +112,19 @@ void BLEClientBase::connect() { this->remote_addr_type_); this->paired_ = false; - // Set preferred connection parameters before connecting - // Use FAST for all V3 connections (better latency and reliability) - // Use MEDIUM for V1/legacy connections (balanced performance) - uint16_t min_interval, max_interval, timeout; - const char *param_type; - - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - min_interval = FAST_MIN_CONN_INTERVAL; - max_interval = FAST_MAX_CONN_INTERVAL; - timeout = FAST_CONN_TIMEOUT; - param_type = "fast"; - } else { - min_interval = MEDIUM_MIN_CONN_INTERVAL; - max_interval = MEDIUM_MAX_CONN_INTERVAL; - timeout = MEDIUM_CONN_TIMEOUT; - param_type = "medium"; + // Determine connection parameters based on connection type + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + // V3 without cache needs fast params for service discovery + this->set_conn_params_(FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT, "fast"); + } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { + // V3 with cache can use medium params + this->set_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } + // For V1/Legacy, don't set params - use ESP-IDF defaults - auto param_ret = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, - 0, // latency: 0 - timeout); - if (param_ret != ESP_OK) { - ESP_LOGW(TAG, "[%d] [%s] esp_ble_gap_set_prefer_conn_params failed: %d", this->connection_index_, - this->address_str_.c_str(), param_ret); - } else { - this->log_connection_params_(param_type); - } - - // Now open the connection + // Open the connection auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); - if (ret) { - this->log_gattc_warning_("esp_ble_gattc_open", ret); - this->set_state(espbt::ClientState::IDLE); - } else { - this->set_state(espbt::ClientState::CONNECTING); - } + this->handle_connection_result_(ret); } esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } @@ -231,6 +208,15 @@ void BLEClientBase::log_connection_params_(const char *param_type) { ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); } +void BLEClientBase::handle_connection_result_(esp_err_t ret) { + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + this->set_state(espbt::ClientState::IDLE); + } else { + this->set_state(espbt::ClientState::CONNECTING); + } +} + void BLEClientBase::log_error_(const char *message) { ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } @@ -243,17 +229,30 @@ void BLEClientBase::log_warning_(const char *message) { ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); } -void BLEClientBase::restore_medium_conn_params_() { - // Restore to medium connection parameters after initial connection phase - // This balances performance with bandwidth usage for normal operation +void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { esp_ble_conn_update_params_t conn_params = {{0}}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); - conn_params.min_int = MEDIUM_MIN_CONN_INTERVAL; - conn_params.max_int = MEDIUM_MAX_CONN_INTERVAL; - conn_params.latency = 0; - conn_params.timeout = MEDIUM_CONN_TIMEOUT; - this->log_connection_params_("medium"); - esp_ble_gap_update_conn_params(&conn_params); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_update_conn_params(&conn_params); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_update_conn_params", err); + } +} + +void BLEClientBase::set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type) { + // Set preferred connection parameters before connecting + // These will be used when establishing the connection + this->log_connection_params_(param_type); + esp_err_t err = esp_ble_gap_set_prefer_conn_params(this->remote_bda_, min_interval, max_interval, latency, timeout); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_set_prefer_conn_params", err); + } } bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, @@ -308,12 +307,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->set_state(espbt::ClientState::CONNECTED); ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - // Restore to medium connection parameters for cached connections too - this->restore_medium_conn_params_(); + // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. this->state_ = espbt::ClientState::ESTABLISHED; break; } + // For V3_WITHOUT_CACHE, we already set fast params before connecting + // No need to update them again here this->log_event_("Searching for services"); esp_ble_gattc_search_service(esp_gattc_if, param->cfg_mtu.conn_id, nullptr); break; @@ -395,12 +395,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->search_cmpl.conn_id) return false; this->log_gattc_event_("SEARCH_CMPL"); - // For V3 connections, restore to medium connection parameters after service discovery + // For V3_WITHOUT_CACHE, switch back to medium connection parameters after service discovery // This balances performance with bandwidth usage after the critical discovery phase - if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE || - this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->restore_medium_conn_params_(); - } else { + if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 1850b2c5b3..acfad9e9b0 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -133,10 +133,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_event_(const char *name); void log_gattc_event_(const char *name); - void restore_medium_conn_params_(); + void update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + void set_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); void log_gattc_warning_(const char *operation, esp_gatt_status_t status); void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); + void handle_connection_result_(esp_err_t ret); // Compact error logging helpers to reduce flash usage void log_error_(const char *message); void log_error_(const char *message, int code); From fcc3c8e1b6a21669a34e525e469766e080520898 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:37 +0200 Subject: [PATCH 30/42] [esp32_ble] Increase GATT connection retry count to use full timeout window (#10376) --- esphome/components/esp32_ble/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 2edd69c6c0..cc06058b65 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -280,6 +280,10 @@ async def to_code(config): add_idf_sdkconfig_option( "CONFIG_BT_BLE_ESTAB_LINK_CONN_TOUT", timeout_seconds ) + # Increase GATT client connection retry count for problematic devices + # Default in ESP-IDF is 3, we increase to 10 for better reliability with + # low-power/timing-sensitive devices + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CONNECT_RETRY_COUNT", 10) # Set the maximum number of notification registrations # This controls how many BLE characteristics can have notifications enabled From d560831d796239f6356815cbecbf6f7ccd3a9c67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:49:52 +0200 Subject: [PATCH 31/42] [script] Fix parallel mode scripts with delays cancelling each other (#10324) --- esphome/core/base_automation.h | 13 +++- esphome/core/scheduler.cpp | 19 +++-- esphome/core/scheduler.h | 9 ++- .../fixtures/parallel_script_delays.yaml | 45 ++++++++++++ tests/integration/test_automations.py | 70 +++++++++++++++++++ 5 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 tests/integration/fixtures/parallel_script_delays.yaml diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 740e10700b..ba942e5e43 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -5,6 +5,8 @@ #include "esphome/core/hal.h" #include "esphome/core/defines.h" #include "esphome/core/preferences.h" +#include "esphome/core/scheduler.h" +#include "esphome/core/application.h" #include @@ -158,7 +160,16 @@ template class DelayAction : public Action, public Compon void play_complex(Ts... x) override { auto f = std::bind(&DelayAction::play_next_, this, x...); this->num_running_++; - this->set_timeout("delay", this->delay_.value(x...), f); + + // If num_running_ > 1, we have multiple instances running in parallel + // In single/restart/queued modes, only one instance runs at a time + // Parallel mode uses skip_cancel=true to allow multiple delays to coexist + // WARNING: This can accumulate delays if scripts are triggered faster than they complete! + // Users should set max_runs on parallel scripts to limit concurrent executions. + // Issue #10264: This is a workaround for parallel script delays interfering with each other. + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3ade260ac..a907b89b02 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -65,14 +65,17 @@ static void validate_static_string(const char *name) { // Common implementation for both timeout and interval void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, - const void *name_ptr, uint32_t delay, std::function func, bool is_retry) { + const void *name_ptr, uint32_t delay, std::function func, bool is_retry, + bool skip_cancel) { // Get the name as const char* const char *name_cstr = this->get_name_cstr_(is_static_string, name_ptr); if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if name is not empty - LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + LockGuard guard{this->lock_}; + this->cancel_item_locked_(component, name_cstr, type); + } return; } @@ -97,7 +100,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type if (delay == 0 && type == SchedulerItem::TIMEOUT) { // Put in defer queue for guaranteed FIFO execution LockGuard guard{this->lock_}; - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } this->defer_queue_.push_back(std::move(item)); return; } @@ -150,9 +155,11 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // If name is provided, do atomic cancel-and-add + // If name is provided, do atomic cancel-and-add (unless skip_cancel is true) // Cancel existing items - this->cancel_item_locked_(component, name_cstr, type); + if (!skip_cancel) { + this->cancel_item_locked_(component, name_cstr, type); + } // Add new item directly to to_add_ // since we have the lock held this->to_add_.push_back(std::move(item)); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index c73bd55d5d..f469a60d5c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -21,8 +21,13 @@ struct RetryArgs; void retry_handler(const std::shared_ptr &args); class Scheduler { - // Allow retry_handler to access protected members + // Allow retry_handler to access protected members for internal retry mechanism friend void ::esphome::retry_handler(const std::shared_ptr &args); + // Allow DelayAction to call set_timer_common_ with skip_cancel=true for parallel script delays. + // This is needed to fix issue #10264 where parallel scripts with delays interfere with each other. + // We use friend instead of a public API because skip_cancel is dangerous - it can cause delays + // to accumulate and overload the scheduler if misused. + template friend class DelayAction; public: // Public API - accepts std::string for backward compatibility @@ -184,7 +189,7 @@ class Scheduler { // Common implementation for both timeout and interval void set_timer_common_(Component *component, SchedulerItem::Type type, bool is_static_string, const void *name_ptr, - uint32_t delay, std::function func, bool is_retry = false); + uint32_t delay, std::function func, bool is_retry = false, bool skip_cancel = false); // Common implementation for retry void set_retry_common_(Component *component, bool is_static_string, const void *name_ptr, uint32_t initial_wait_time, diff --git a/tests/integration/fixtures/parallel_script_delays.yaml b/tests/integration/fixtures/parallel_script_delays.yaml new file mode 100644 index 0000000000..6887045913 --- /dev/null +++ b/tests/integration/fixtures/parallel_script_delays.yaml @@ -0,0 +1,45 @@ +esphome: + name: test-parallel-delays + +host: + +logger: + level: DEBUG + +api: + actions: + - action: test_parallel_delays + then: + # Start three parallel script instances with small delays between starts + - globals.set: + id: instance_counter + value: '1' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '2' + - script.execute: parallel_delay_script + - delay: 10ms + - globals.set: + id: instance_counter + value: '3' + - script.execute: parallel_delay_script + +globals: + - id: instance_counter + type: int + initial_value: '0' + +script: + - id: parallel_delay_script + mode: parallel + then: + - lambda: !lambda |- + int instance = id(instance_counter); + ESP_LOGI("TEST", "Parallel script instance %d started", instance); + - delay: 1s + - lambda: !lambda |- + static int completed_counter = 0; + completed_counter++; + ESP_LOGI("TEST", "Parallel script instance %d completed after delay", completed_counter); diff --git a/tests/integration/test_automations.py b/tests/integration/test_automations.py index bd2082e86b..83268c1eea 100644 --- a/tests/integration/test_automations.py +++ b/tests/integration/test_automations.py @@ -89,3 +89,73 @@ async def test_delay_action_cancellation( assert 0.4 < time_from_second_start < 0.6, ( f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s" ) + + +@pytest.mark.asyncio +async def test_parallel_script_delays( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that parallel scripts with delays don't interfere with each other.""" + loop = asyncio.get_running_loop() + + # Track script executions + script_starts: list[float] = [] + script_ends: list[float] = [] + + # Patterns to match + start_pattern = re.compile(r"Parallel script instance \d+ started") + end_pattern = re.compile(r"Parallel script instance \d+ completed after delay") + + # Future to track when all scripts have completed + all_scripts_completed = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for parallel script messages.""" + current_time = loop.time() + + if start_pattern.search(line): + script_starts.append(current_time) + + if end_pattern.search(line): + script_ends.append(current_time) + # Check if we have all 3 completions + if len(script_ends) == 3 and not all_scripts_completed.done(): + all_scripts_completed.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + entities, services = await client.list_entities_services() + + # Find our test service + test_service = next( + (s for s in services if s.name == "test_parallel_delays"), None + ) + assert test_service is not None, "test_parallel_delays service not found" + + # Execute the test - this will start 3 parallel scripts with 1 second delays + client.execute_service(test_service, {}) + + # Wait for all scripts to complete (should take ~1 second, not 3) + await asyncio.wait_for(all_scripts_completed, timeout=2.0) + + # Verify we had 3 starts and 3 ends + assert len(script_starts) == 3, ( + f"Expected 3 script starts, got {len(script_starts)}" + ) + assert len(script_ends) == 3, f"Expected 3 script ends, got {len(script_ends)}" + + # Verify they ran in parallel - all should complete within ~1.5 seconds + first_start = min(script_starts) + last_end = max(script_ends) + total_time = last_end - first_start + + # If running in parallel, total time should be close to 1 second + # If they were interfering (running sequentially), it would take 3+ seconds + assert total_time < 1.5, ( + f"Parallel scripts took {total_time:.2f}s total, should be ~1s if running in parallel" + ) From 684384892a779bc47b600200f1d9826806cf67e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:23 +0200 Subject: [PATCH 32/42] [deep_sleep] Fix ESP32-C6 compilation error with gpio_deep_sleep_hold_en() (#10345) --- esphome/components/deep_sleep/deep_sleep_esp32.cpp | 9 +++++++++ tests/components/deep_sleep/test.esp32-c6-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s2-idf.yaml | 5 +++++ tests/components/deep_sleep/test.esp32-s3-idf.yaml | 5 +++++ 4 files changed, 24 insertions(+) create mode 100644 tests/components/deep_sleep/test.esp32-c6-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s2-idf.yaml create mode 100644 tests/components/deep_sleep/test.esp32-s3-idf.yaml diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c5c1fe0835..e9d0a4981f 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -1,4 +1,5 @@ #ifdef USE_ESP32 +#include "soc/soc_caps.h" #include "driver/gpio.h" #include "deep_sleep_component.h" #include "esphome/core/log.h" @@ -83,7 +84,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; @@ -120,7 +125,11 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); +#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP + // Some ESP32 variants support holding a single GPIO during deep sleep without this function + // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep gpio_deep_sleep_hold_en(); +#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/tests/components/deep_sleep/test.esp32-c6-idf.yaml b/tests/components/deep_sleep/test.esp32-c6-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c6-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s2-idf.yaml b/tests/components/deep_sleep/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml diff --git a/tests/components/deep_sleep/test.esp32-s3-idf.yaml b/tests/components/deep_sleep/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..10c17af0f5 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32.yaml From 8517c2e90371eff9748c406a1c8ed855259f9a29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:51:54 +0200 Subject: [PATCH 33/42] [esp32_ble_client] Reduce log level for harmless BLE timeout race conditions (#10339) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../esp32_ble_client/ble_client_base.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index b9b7c75e0a..351658279f 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -136,7 +136,8 @@ void BLEClientBase::disconnect() { return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { - this->log_warning_("Disconnect before connected, disconnect scheduled."); + ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, + this->address_str_.c_str()); this->want_disconnect_ = true; return; } @@ -284,11 +285,22 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_event_("OPEN"); // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; + + // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an + // error, if the error occurred at the BTA/GATT layer. This can result in the event + // arriving after we've already transitioned to IDLE state. + if (this->state_ == espbt::ClientState::IDLE) { + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, + this->address_str_.c_str(), param->open.status); + break; + } + if (this->state_ != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. - this->log_error_("ESP_GATTC_OPEN_EVT wrong state status", param->open.status); + ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, + this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); From d9dcfe66ec67ff78332c030cd25feace3b7fb275 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:52:37 +1000 Subject: [PATCH 34/42] [lvgl] Fix meter rotation (#10342) --- esphome/components/lvgl/widgets/canvas.py | 10 +++++----- esphome/components/lvgl/widgets/meter.py | 9 +++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 4fd81b6e4a..217e8935f1 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -24,7 +24,7 @@ from ..defines import ( literal, ) from ..lv_validation import ( - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_image, @@ -395,15 +395,15 @@ ARC_PROPS = { DRAW_OPA_SCHEMA.extend( { cv.Required(CONF_RADIUS): pixels, - cv.Required(CONF_START_ANGLE): lv_angle, - cv.Required(CONF_END_ANGLE): lv_angle, + cv.Required(CONF_START_ANGLE): lv_angle_degrees, + cv.Required(CONF_END_ANGLE): lv_angle_degrees, } ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) - start_angle = await lv_angle.process(config[CONF_START_ANGLE]) - end_angle = await lv_angle.process(config[CONF_END_ANGLE]) + start_angle = await lv_angle_degrees.process(config[CONF_START_ANGLE]) + end_angle = await lv_angle_degrees.process(config[CONF_END_ANGLE]) async def do_draw_arc(w: Widget, x, y, dsc_addr): lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index acec986f99..aefda0e71a 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_VALUE, CONF_WIDTH, ) -from esphome.cpp_generator import IntLiteral from ..automation import action_to_code from ..defines import ( @@ -32,7 +31,7 @@ from ..helpers import add_lv_use, lvgl_components_required from ..lv_validation import ( get_end_value, get_start_value, - lv_angle, + lv_angle_degrees, lv_bool, lv_color, lv_float, @@ -163,7 +162,7 @@ SCALE_SCHEMA = cv.Schema( cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), - cv.Optional(CONF_ROTATION): lv_angle, + cv.Optional(CONF_ROTATION): lv_angle_degrees, cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), } ) @@ -188,9 +187,7 @@ class MeterType(WidgetType): for scale_conf in config.get(CONF_SCALES, ()): rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 if CONF_ROTATION in scale_conf: - rotation = await lv_angle.process(scale_conf[CONF_ROTATION]) - if isinstance(rotation, IntLiteral): - rotation = int(str(rotation)) // 10 + rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION]) with LocalVariable( "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) ) as meter_var: From c542db8bfe894c12aeaafba3fc09598e4e1efc74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:57:09 +0200 Subject: [PATCH 35/42] [esp32_ble_tracker] Fix on_scan_end trigger compilation without USE_ESP32_BLE_DEVICE (#10399) --- esphome/components/esp32_ble_tracker/automation.h | 5 ++++- .../esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index c0e6eee138..784f2eaaa2 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -80,14 +80,17 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, ESPBTUUID uuid_; }; +#endif // USE_ESP32_BLE_DEVICE + class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } +#ifdef USE_ESP32_BLE_DEVICE bool parse_device(const ESPBTDevice &device) override { return false; } +#endif void on_scan_end() override { this->trigger(); } }; -#endif // USE_ESP32_BLE_DEVICE template class ESP32BLEStartScanAction : public Action { public: diff --git a/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml new file mode 100644 index 0000000000..4e9849a540 --- /dev/null +++ b/tests/components/esp32_ble_tracker/test-on-scan-end.esp32-idf.yaml @@ -0,0 +1,3 @@ +esp32_ble_tracker: + on_scan_end: + - logger.log: "Scan ended!" From aebd21958acd7e5a9dd24d75117974c809da1864 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Aug 2025 22:58:46 +0200 Subject: [PATCH 36/42] [test] Add integration test for light effect memory corruption fix (#10417) --- tests/integration/fixtures/light_calls.yaml | 32 ++++++++++++++- tests/integration/test_light_calls.py | 45 +++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/tests/integration/fixtures/light_calls.yaml b/tests/integration/fixtures/light_calls.yaml index d692a11765..2b7650526f 100644 --- a/tests/integration/fixtures/light_calls.yaml +++ b/tests/integration/fixtures/light_calls.yaml @@ -56,10 +56,29 @@ light: warm_white_color_temperature: 2000 K constant_brightness: true effects: + # Use default parameters: - random: - name: "Random Effect" + # Customize parameters - use longer names to potentially trigger buffer issues + - random: + name: "My Very Slow Random Effect With Long Name" + transition_length: 30ms + update_interval: 30ms + - random: + name: "My Fast Random Effect That Changes Quickly" + transition_length: 4ms + update_interval: 5ms + - random: + name: "Random Effect With Medium Length Name Here" transition_length: 100ms update_interval: 200ms + - random: + name: "Another Random Effect With Different Parameters" + transition_length: 2ms + update_interval: 3ms + - random: + name: "Yet Another Random Effect To Test Memory" + transition_length: 15ms + update_interval: 20ms - strobe: name: "Strobe Effect" - pulse: @@ -73,6 +92,17 @@ light: red: test_red green: test_green blue: test_blue + effects: + # Same random effects to test for cross-contamination + - random: + - random: + name: "RGB Slow Random" + transition_length: 20ms + update_interval: 25ms + - random: + name: "RGB Fast Random" + transition_length: 2ms + update_interval: 3ms - platform: binary name: "Test Binary Light" diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 1a0a9e553f..af90ddbe86 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -108,14 +108,51 @@ async def test_light_calls( # Wait for flash to end state = await wait_for_state_change(rgbcw_light.key) - # Test 13: effect only + # Test 13: effect only - test all random effects # First ensure light is on client.light_command(key=rgbcw_light.key, state=True) state = await wait_for_state_change(rgbcw_light.key) - # Now set effect - client.light_command(key=rgbcw_light.key, effect="Random Effect") + + # Test 13a: Default random effect (no name, gets default name "Random") + client.light_command(key=rgbcw_light.key, effect="Random") state = await wait_for_state_change(rgbcw_light.key) - assert state.effect == "Random Effect" + assert state.effect == "Random" + + # Test 13b: Slow random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Very Slow Random Effect With Long Name" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Very Slow Random Effect With Long Name" + + # Test 13c: Fast random effect with long name + client.light_command( + key=rgbcw_light.key, effect="My Fast Random Effect That Changes Quickly" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "My Fast Random Effect That Changes Quickly" + + # Test 13d: Random effect with medium length name + client.light_command( + key=rgbcw_light.key, effect="Random Effect With Medium Length Name Here" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Random Effect With Medium Length Name Here" + + # Test 13e: Another random effect + client.light_command( + key=rgbcw_light.key, + effect="Another Random Effect With Different Parameters", + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Another Random Effect With Different Parameters" + + # Test 13f: Yet another random effect + client.light_command( + key=rgbcw_light.key, effect="Yet Another Random Effect To Test Memory" + ) + state = await wait_for_state_change(rgbcw_light.key) + assert state.effect == "Yet Another Random Effect To Test Memory" # Test 14: stop effect client.light_command(key=rgbcw_light.key, effect="None") From 7a459c8c2006ed7662c7eea2c32b668a7fa6aa35 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 25 Aug 2025 07:04:32 +1000 Subject: [PATCH 37/42] [web_server] Use oi.esphome.io for css and js assets (#10296) --- esphome/components/web_server/__init__.py | 4 ++-- esphome/components/web_server/web_server.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 695757e137..be193bbab8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -52,9 +52,9 @@ def default_url(config: ConfigType) -> ConfigType: config = config.copy() if config[CONF_VERSION] == 1: if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "https://esphome.io/_static/webserver-v1.min.css" + config[CONF_CSS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.css" if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://esphome.io/_static/webserver-v1.min.js" + config[CONF_JS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.js" if config[CONF_VERSION] == 2: if CONF_CSS_URL not in config: config[CONF_CSS_URL] = "" diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6bece732fc..e42c35b32d 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -173,14 +173,14 @@ class WebServer : public Controller, public Component, public AsyncWebHandler { #if USE_WEBSERVER_VERSION == 1 /** Set the URL to the CSS that's sent to each client. Defaults to - * https://esphome.io/_static/webserver-v1.min.css + * https://oi.esphome.io/v1/webserver-v1.min.css * * @param css_url The url to the web server stylesheet. */ void set_css_url(const char *css_url); /** Set the URL to the script that's embedded in the index page. Defaults to - * https://esphome.io/_static/webserver-v1.min.js + * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ From d071a074ef6d45adc4e6f995e65cdce08ceb4d79 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:59:35 +1200 Subject: [PATCH 38/42] Bump version to 2025.8.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 5ed1a4afc0..24dcc2e859 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.8.0 +PROJECT_NUMBER = 2025.8.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 69da0b9af0..555434e4b9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.8.0" +__version__ = "2025.8.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 2aceb56606ec8afec5f49c92e140c8050a6ccbe5 Mon Sep 17 00:00:00 2001 From: Jonathan Rascher Date: Sun, 24 Aug 2025 22:40:19 -0500 Subject: [PATCH 39/42] Merge commit from fork Ensures auth check doesn't pass erroneously when the client-supplied digest is shorter than the correct digest, but happens to match a prefix of the correct value (e.g., same username + certain substrings of the password). --- esphome/components/web_server_idf/web_server_idf.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 3226444b1b..55b07c0f5e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -253,7 +253,7 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw esp_crypto_base64_encode(reinterpret_cast(digest.get()), n, &out, reinterpret_cast(user_info.c_str()), user_info.size()); - return strncmp(digest.get(), auth_str + auth_prefix_len, auth.value().size() - auth_prefix_len) == 0; + return strcmp(digest.get(), auth_str + auth_prefix_len) == 0; } void AsyncWebServerRequest::requestAuthentication(const char *realm) const { From 5e508f7461349948c63e4fa85c845f3086156b95 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 26 Aug 2025 07:46:54 +1200 Subject: [PATCH 40/42] [core] Dont copy platform source files if there are no entities of that type (#10436) --- esphome/config.py | 5 ++++- esphome/writer.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index 90325cbf6e..ebf1989693 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +from collections import defaultdict from contextlib import contextmanager import contextvars import functools @@ -37,11 +38,13 @@ from esphome.yaml_util import ESPForceValue, ESPHomeDataBase, is_secret _LOGGER = logging.getLogger(__name__) -def iter_components(config): +def iter_components(config: ConfigType, platform_counts: defaultdict[str, int]): for domain, conf in config.items(): component = get_component(domain) yield domain, component if component.is_platform_component: + if not platform_counts[domain]: + continue for p_config in conf: p_name = f"{domain}.{p_config[CONF_PLATFORM]}" platform = get_platform(domain, p_config[CONF_PLATFORM]) diff --git a/esphome/writer.py b/esphome/writer.py index 4b25a25f7e..45aa996295 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -181,7 +181,7 @@ the custom_components folder or the external_components feature. def copy_src_tree(): source_files: list[loader.FileResource] = [] - for _, component in iter_components(CORE.config): + for _, component in iter_components(CORE.config, CORE.platform_counts): source_files += component.resources source_files_map = { Path(x.package.replace(".", "/") + "/" + x.resource): x for x in source_files From c01a26607ee469853fdf1af90be7d5616c5665be Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Mon, 25 Aug 2025 23:45:03 +0200 Subject: [PATCH 41/42] improve const imports of `esphome.const` (#10438) --- esphome/components/esp32_ble/__init__.py | 9 +++++++-- esphome/components/sdm_meter/sensor.py | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index cc06058b65..d2eaa3ce6f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -5,9 +5,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv -from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, CONF_NAME +from esphome.const import ( + CONF_ENABLE_ON_BOOT, + CONF_ESPHOME, + CONF_ID, + CONF_NAME, + CONF_NAME_ADD_MAC_SUFFIX, +) from esphome.core import CORE, TimePeriod -from esphome.core.config import CONF_NAME_ADD_MAC_SUFFIX import esphome.final_validate as fv DEPENDENCIES = ["esp32"] diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 24ae32c7bc..affbc0409e 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -1,6 +1,5 @@ import esphome.codegen as cg from esphome.components import modbus, sensor -from esphome.components.atm90e32.sensor import CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -12,7 +11,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_PHASE_A, CONF_PHASE_ANGLE, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_TOTAL_POWER, From 9007621fd70621b37df418203b76afdf4eee70c4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:15:44 +1000 Subject: [PATCH 42/42] Revert "[core] Dont copy platform source files if there are no entities of that type" (#10441) --- esphome/config.py | 5 +---- esphome/writer.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index ebf1989693..90325cbf6e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -from collections import defaultdict from contextlib import contextmanager import contextvars import functools @@ -38,13 +37,11 @@ from esphome.yaml_util import ESPForceValue, ESPHomeDataBase, is_secret _LOGGER = logging.getLogger(__name__) -def iter_components(config: ConfigType, platform_counts: defaultdict[str, int]): +def iter_components(config): for domain, conf in config.items(): component = get_component(domain) yield domain, component if component.is_platform_component: - if not platform_counts[domain]: - continue for p_config in conf: p_name = f"{domain}.{p_config[CONF_PLATFORM]}" platform = get_platform(domain, p_config[CONF_PLATFORM]) diff --git a/esphome/writer.py b/esphome/writer.py index 45aa996295..4b25a25f7e 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -181,7 +181,7 @@ the custom_components folder or the external_components feature. def copy_src_tree(): source_files: list[loader.FileResource] = [] - for _, component in iter_components(CORE.config, CORE.platform_counts): + for _, component in iter_components(CORE.config): source_files += component.resources source_files_map = { Path(x.package.replace(".", "/") + "/" + x.resource): x for x in source_files