From a1ab19d127f3aeb28a22fbc401beff00c2799529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Nov 2025 21:44:19 -0600 Subject: [PATCH 01/12] [ci] Reduce release time by removing 21 redundant ESP32-S3 IDF tests (#11850) --- .../binary_sensor/test.esp32-s3-idf.yaml | 2 - .../bme68x_bsec2_i2c/test.esp32-s3-idf.yaml | 4 -- tests/components/debug/test.esp32-s3-idf.yaml | 1 - .../matrix_keypad/test.esp32-s3-idf.yaml | 15 ------ .../components/mcp3221/test.esp32-s3-idf.yaml | 4 -- .../mlx90393/test.esp32-s3-idf.yaml | 4 -- tests/components/npi19/test.esp32-s3-idf.yaml | 4 -- tests/components/ntc/test.esp32-s3-idf.yaml | 4 -- .../resistance/test.esp32-s3-idf.yaml | 4 -- .../components/switch/test.esp32-s3-idf.yaml | 2 - .../components/tem3200/test.esp32-s3-idf.yaml | 8 ---- .../template/test.esp32-s3-idf.yaml | 2 - ...max_with_usb_serial_jtag.esp32-s3-idf.yaml | 48 ------------------- .../wk2132_i2c/test.esp32-s3-idf.yaml | 9 ---- .../wk2132_spi/test.esp32-s3-idf.yaml | 11 ----- .../wk2168_i2c/test.esp32-s3-idf.yaml | 9 ---- .../wk2168_spi/test.esp32-s3-idf.yaml | 11 ----- .../wk2204_i2c/test.esp32-s3-idf.yaml | 9 ---- .../wk2204_spi/test.esp32-s3-idf.yaml | 11 ----- .../wk2212_i2c/test.esp32-s3-idf.yaml | 9 ---- .../wk2212_spi/test.esp32-s3-idf.yaml | 11 ----- 21 files changed, 182 deletions(-) delete mode 100644 tests/components/binary_sensor/test.esp32-s3-idf.yaml delete mode 100644 tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml delete mode 100644 tests/components/debug/test.esp32-s3-idf.yaml delete mode 100644 tests/components/matrix_keypad/test.esp32-s3-idf.yaml delete mode 100644 tests/components/mcp3221/test.esp32-s3-idf.yaml delete mode 100644 tests/components/mlx90393/test.esp32-s3-idf.yaml delete mode 100644 tests/components/npi19/test.esp32-s3-idf.yaml delete mode 100644 tests/components/ntc/test.esp32-s3-idf.yaml delete mode 100644 tests/components/resistance/test.esp32-s3-idf.yaml delete mode 100644 tests/components/switch/test.esp32-s3-idf.yaml delete mode 100644 tests/components/tem3200/test.esp32-s3-idf.yaml delete mode 100644 tests/components/template/test.esp32-s3-idf.yaml delete mode 100644 tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2132_i2c/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2132_spi/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2168_i2c/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2168_spi/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2204_i2c/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2204_spi/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2212_i2c/test.esp32-s3-idf.yaml delete mode 100644 tests/components/wk2212_spi/test.esp32-s3-idf.yaml diff --git a/tests/components/binary_sensor/test.esp32-s3-idf.yaml b/tests/components/binary_sensor/test.esp32-s3-idf.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/binary_sensor/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/debug/test.esp32-s3-idf.yaml b/tests/components/debug/test.esp32-s3-idf.yaml deleted file mode 100644 index dade44d145..0000000000 --- a/tests/components/debug/test.esp32-s3-idf.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common.yaml diff --git a/tests/components/matrix_keypad/test.esp32-s3-idf.yaml b/tests/components/matrix_keypad/test.esp32-s3-idf.yaml deleted file mode 100644 index a491f2ed59..0000000000 --- a/tests/components/matrix_keypad/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,15 +0,0 @@ -packages: - common: !include common.yaml - -matrix_keypad: - id: keypad - rows: - - pin: 10 - - pin: 11 - columns: - - pin: 12 - - pin: 13 - keys: "1234" - has_pulldowns: true - on_key: - - lambda: ESP_LOGI("KEY", "key %d pressed", x); diff --git a/tests/components/mcp3221/test.esp32-s3-idf.yaml b/tests/components/mcp3221/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/mcp3221/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/mlx90393/test.esp32-s3-idf.yaml b/tests/components/mlx90393/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/mlx90393/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/npi19/test.esp32-s3-idf.yaml b/tests/components/npi19/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/npi19/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/ntc/test.esp32-s3-idf.yaml b/tests/components/ntc/test.esp32-s3-idf.yaml deleted file mode 100644 index 37fb325f4a..0000000000 --- a/tests/components/ntc/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -substitutions: - pin: GPIO4 - -<<: !include common.yaml diff --git a/tests/components/resistance/test.esp32-s3-idf.yaml b/tests/components/resistance/test.esp32-s3-idf.yaml deleted file mode 100644 index 1910f325ae..0000000000 --- a/tests/components/resistance/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -substitutions: - pin: GPIO1 - -<<: !include common.yaml diff --git a/tests/components/switch/test.esp32-s3-idf.yaml b/tests/components/switch/test.esp32-s3-idf.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/switch/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml diff --git a/tests/components/tem3200/test.esp32-s3-idf.yaml b/tests/components/tem3200/test.esp32-s3-idf.yaml deleted file mode 100644 index e9d826aa7c..0000000000 --- a/tests/components/tem3200/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,8 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/template/test.esp32-s3-idf.yaml b/tests/components/template/test.esp32-s3-idf.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/template/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml diff --git a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml deleted file mode 100644 index 88a806eb92..0000000000 --- a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml +++ /dev/null @@ -1,48 +0,0 @@ -<<: !include ../logger/common-usb_serial_jtag.yaml - -esphome: - on_boot: - then: - - uart.write: - id: uart_1 - data: 'Hello World' - - uart.write: - id: uart_1 - data: [0x00, 0x20, 0x42] - -uart: - - id: uart_1 - tx_pin: 4 - rx_pin: 5 - flow_control_pin: 6 - baud_rate: 9600 - data_bits: 8 - rx_buffer_size: 512 - rx_full_threshold: 10 - rx_timeout: 1 - parity: EVEN - stop_bits: 2 - - - id: uart_2 - tx_pin: 7 - rx_pin: 8 - flow_control_pin: 9 - baud_rate: 9600 - data_bits: 8 - rx_buffer_size: 512 - rx_full_threshold: 10 - rx_timeout: 1 - parity: EVEN - stop_bits: 2 - - - id: uart_3 - tx_pin: 10 - rx_pin: 11 - flow_control_pin: 12 - baud_rate: 9600 - data_bits: 8 - rx_buffer_size: 512 - rx_full_threshold: 10 - rx_timeout: 1 - parity: EVEN - stop_bits: 2 diff --git a/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index d7b149a6fd..0000000000 --- a/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2132_spi/test.esp32-s3-idf.yaml b/tests/components/wk2132_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 9c7d36996e..0000000000 --- a/tests/components/wk2132_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 115812be97..0000000000 --- a/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2168_spi/test.esp32-s3-idf.yaml b/tests/components/wk2168_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 374fe64d16..0000000000 --- a/tests/components/wk2168_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 115812be97..0000000000 --- a/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2204_spi/test.esp32-s3-idf.yaml b/tests/components/wk2204_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 374fe64d16..0000000000 --- a/tests/components/wk2204_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 115812be97..0000000000 --- a/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2212_spi/test.esp32-s3-idf.yaml b/tests/components/wk2212_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 374fe64d16..0000000000 --- a/tests/components/wk2212_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml From 4f088c93c9f9135cb948dd6317b8024eeeeb888a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:41:22 -0500 Subject: [PATCH 02/12] [esp32] Update the recommended platform to 55.03.31-2 (#11865) --- esphome/components/esp32/__init__.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6981662d77..61511cba0c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -334,12 +334,14 @@ def _is_framework_url(source: str) -> str: # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "recommended": cv.Version(3, 3, 2), - "latest": cv.Version(3, 3, 2), - "dev": cv.Version(3, 3, 2), + "latest": cv.Version(3, 3, 4), + "dev": cv.Version(3, 3, 4), } ARDUINO_PLATFORM_VERSION_LOOKUP = { - cv.Version(3, 3, 2): cv.Version(55, 3, 31, "1"), - cv.Version(3, 3, 1): cv.Version(55, 3, 31, "1"), + cv.Version(3, 3, 4): cv.Version(55, 3, 31, "2"), + cv.Version(3, 3, 3): cv.Version(55, 3, 31, "2"), + cv.Version(3, 3, 2): cv.Version(55, 3, 31, "2"), + cv.Version(3, 3, 1): cv.Version(55, 3, 31, "2"), cv.Version(3, 3, 0): cv.Version(55, 3, 30, "2"), cv.Version(3, 2, 1): cv.Version(54, 3, 21, "2"), cv.Version(3, 2, 0): cv.Version(54, 3, 20), @@ -357,8 +359,8 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(5, 5, 1), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { - cv.Version(5, 5, 1): cv.Version(55, 3, 31, "1"), - cv.Version(5, 5, 0): cv.Version(55, 3, 31, "1"), + cv.Version(5, 5, 1): cv.Version(55, 3, 31, "2"), + cv.Version(5, 5, 0): cv.Version(55, 3, 31, "2"), cv.Version(5, 4, 3): cv.Version(55, 3, 32), cv.Version(5, 4, 2): cv.Version(54, 3, 21, "2"), cv.Version(5, 4, 1): cv.Version(54, 3, 21, "2"), @@ -373,9 +375,9 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 31, "1"), - "latest": cv.Version(55, 3, 31, "1"), - "dev": cv.Version(55, 3, 31, "1"), + "recommended": cv.Version(55, 3, 31, "2"), + "latest": cv.Version(55, 3, 31, "2"), + "dev": cv.Version(55, 3, 31, "2"), } From a859ecaad1cb64d7648c5293771c93109f58c2c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 11:56:19 -0600 Subject: [PATCH 03/12] [core] Fix wait_until hanging when used in on_boot automations (#11869) --- esphome/core/base_automation.h | 7 +- .../fixtures/wait_until_on_boot.yaml | 47 ++++++++++ tests/integration/test_wait_until_on_boot.py | 91 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/wait_until_on_boot.yaml create mode 100644 tests/integration/test_wait_until_on_boot.py diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 6f392c8959..a5e6139182 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -412,7 +412,12 @@ template class WaitUntilAction : public Action, public Co void setup() override { // Start with loop disabled - only enable when there's work to do - this->disable_loop(); + // IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already + // called before our setup() (e.g., from on_boot trigger at same priority level) + // and we must not undo its enable_loop() call + if (this->num_running_ == 0) { + this->disable_loop(); + } } void play_complex(const Ts &...x) override { diff --git a/tests/integration/fixtures/wait_until_on_boot.yaml b/tests/integration/fixtures/wait_until_on_boot.yaml new file mode 100644 index 0000000000..358bef971b --- /dev/null +++ b/tests/integration/fixtures/wait_until_on_boot.yaml @@ -0,0 +1,47 @@ +# Test for wait_until in on_boot automation +# Reproduces bug where wait_until in on_boot would hang forever +# because WaitUntilAction::setup() would disable_loop() after +# play_complex() had already enabled it. + +esphome: + name: wait-until-on-boot + on_boot: + then: + - logger.log: "on_boot: Starting wait_until test" + - globals.set: + id: on_boot_started + value: 'true' + - wait_until: + condition: + lambda: return id(test_flag); + timeout: 5s + - logger.log: "on_boot: wait_until completed successfully" + +host: + +logger: + level: DEBUG + +globals: + - id: on_boot_started + type: bool + initial_value: 'false' + - id: test_flag + type: bool + initial_value: 'false' + +api: + actions: + - action: set_test_flag + then: + - globals.set: + id: test_flag + value: 'true' + - action: check_on_boot_started + then: + - lambda: |- + if (id(on_boot_started)) { + ESP_LOGI("test", "on_boot has started"); + } else { + ESP_LOGI("test", "on_boot has NOT started"); + } diff --git a/tests/integration/test_wait_until_on_boot.py b/tests/integration/test_wait_until_on_boot.py new file mode 100644 index 0000000000..b42c530c54 --- /dev/null +++ b/tests/integration/test_wait_until_on_boot.py @@ -0,0 +1,91 @@ +"""Integration test for wait_until in on_boot automation. + +This test validates that wait_until works correctly when triggered from on_boot, +which runs at the same setup priority as WaitUntilAction itself. This was broken +before the fix because WaitUntilAction::setup() would unconditionally disable_loop(), +even if play_complex() had already been called and enabled the loop. + +The bug: on_boot fires during StartupTrigger::setup(), which calls WaitUntilAction::play_complex() +before WaitUntilAction::setup() has run. Then when WaitUntilAction::setup() runs, it calls +disable_loop(), undoing the enable_loop() from play_complex(), causing wait_until to hang forever. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_on_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that wait_until works in on_boot automation with a condition that becomes true later.""" + loop = asyncio.get_running_loop() + + on_boot_started = False + on_boot_completed = False + + on_boot_started_pattern = re.compile(r"on_boot: Starting wait_until test") + on_boot_complete_pattern = re.compile(r"on_boot: wait_until completed successfully") + + on_boot_started_future = loop.create_future() + on_boot_complete_future = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for test progress.""" + nonlocal on_boot_started, on_boot_completed + + if on_boot_started_pattern.search(line): + on_boot_started = True + if not on_boot_started_future.done(): + on_boot_started_future.set_result(True) + + if on_boot_complete_pattern.search(line): + on_boot_completed = True + if not on_boot_complete_future.done(): + on_boot_complete_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Wait for on_boot to start + await asyncio.wait_for(on_boot_started_future, timeout=10.0) + assert on_boot_started, "on_boot did not start" + + # At this point, on_boot is blocked in wait_until waiting for test_flag to become true + # If the bug exists, wait_until's loop is disabled and it will never complete + # even after we set the flag + + # Give a moment for setup to complete + await asyncio.sleep(0.5) + + # Now set the flag that wait_until is waiting for + _, services = await client.list_entities_services() + set_flag_service = next( + (s for s in services if s.name == "set_test_flag"), None + ) + assert set_flag_service is not None, "set_test_flag service not found" + + client.execute_service(set_flag_service, {}) + + # If the fix works, wait_until's loop() will check the condition and proceed + # If the bug exists, wait_until is stuck with disabled loop and will timeout + try: + await asyncio.wait_for(on_boot_complete_future, timeout=2.0) + assert on_boot_completed, ( + "on_boot wait_until did not complete after flag was set" + ) + except TimeoutError: + pytest.fail( + "wait_until in on_boot did not complete within 2s after condition became true. " + "This indicates the bug where WaitUntilAction::setup() disables the loop " + "after play_complex() has already enabled it." + ) From 6df0264d51d7b0be1cecb2870cf076b55e0b9092 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:26:36 -0600 Subject: [PATCH 04/12] [api] Eliminate heap allocations when transmitting Event types (#11773) --- esphome/components/api/api_connection.cpp | 14 +++---- esphome/components/api/api_connection.h | 48 ++++------------------- 2 files changed, 14 insertions(+), 48 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7eb61f08b6..ca9ddaedf4 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1294,11 +1294,11 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe #endif #ifdef USE_EVENT -void APIConnection::send_event(event::Event *event, const std::string &event_type) { +void APIConnection::send_event(event::Event *event, const char *event_type) { this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, EventResponse::ESTIMATED_SIZE); } -uint16_t APIConnection::try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn, +uint16_t APIConnection::try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single) { EventResponse resp; resp.set_event_type(StringRef(event_type)); @@ -1650,9 +1650,7 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c // O(n) but optimized for RAM and not performance. for (auto &item : items) { if (item.entity == entity && item.message_type == message_type) { - // Clean up old creator before replacing - item.creator.cleanup(message_type); - // Move assign the new creator + // Replace with new creator item.creator = std::move(creator); return; } @@ -1822,7 +1820,7 @@ void APIConnection::process_batch_() { // Handle remaining items more efficiently if (items_processed < this->deferred_batch_.size()) { - // Remove processed items from the beginning with proper cleanup + // Remove processed items from the beginning this->deferred_batch_.remove_front(items_processed); // Reschedule for remaining items this->schedule_batch_(); @@ -1835,10 +1833,10 @@ void APIConnection::process_batch_() { uint16_t APIConnection::MessageCreator::operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, uint8_t message_type) const { #ifdef USE_EVENT - // Special case: EventResponse uses string pointer + // Special case: EventResponse uses const char * pointer if (message_type == EventResponse::MESSAGE_TYPE) { auto *e = static_cast(entity); - return APIConnection::try_send_event_response(e, *data_.string_ptr, conn, remaining_size, is_single); + return APIConnection::try_send_event_response(e, data_.const_char_ptr, conn, remaining_size, is_single); } #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 284fa11a95..a77c93a2d5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -177,7 +177,7 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_EVENT - void send_event(event::Event *event, const std::string &event_type); + void send_event(event::Event *event, const char *event_type); #endif #ifdef USE_UPDATE @@ -450,7 +450,7 @@ class APIConnection final : public APIServerConnection { bool is_single); #endif #ifdef USE_EVENT - static uint16_t try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn, + static uint16_t try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single); static uint16_t try_send_event_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); #endif @@ -508,10 +508,8 @@ class APIConnection final : public APIServerConnection { // Constructor for function pointer MessageCreator(MessageCreatorPtr ptr) { data_.function_ptr = ptr; } - // Constructor for string state capture - explicit MessageCreator(const std::string &str_value) { data_.string_ptr = new std::string(str_value); } - - // No destructor - cleanup must be called explicitly with message_type + // Constructor for const char * (Event types - no allocation needed) + explicit MessageCreator(const char *str_value) { data_.const_char_ptr = str_value; } // Delete copy operations - MessageCreator should only be moved MessageCreator(const MessageCreator &other) = delete; @@ -523,8 +521,6 @@ class APIConnection final : public APIServerConnection { // Move assignment MessageCreator &operator=(MessageCreator &&other) noexcept { if (this != &other) { - // IMPORTANT: Caller must ensure cleanup() was called if this contains a string! - // In our usage, this happens in add_item() deduplication and vector::erase() data_ = other.data_; other.data_.function_ptr = nullptr; } @@ -535,20 +531,10 @@ class APIConnection final : public APIServerConnection { uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, uint8_t message_type) const; - // Manual cleanup method - must be called before destruction for string types - void cleanup(uint8_t message_type) { -#ifdef USE_EVENT - if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) { - delete data_.string_ptr; - data_.string_ptr = nullptr; - } -#endif - } - private: union Data { MessageCreatorPtr function_ptr; - std::string *string_ptr; + const char *const_char_ptr; } data_; // 4 bytes on 32-bit, 8 bytes on 64-bit - same as before }; @@ -568,42 +554,24 @@ class APIConnection final : public APIServerConnection { std::vector items; uint32_t batch_start_time{0}; - private: - // Helper to cleanup items from the beginning - void cleanup_items_(size_t count) { - for (size_t i = 0; i < count; i++) { - items[i].creator.cleanup(items[i].message_type); - } - } - - public: DeferredBatch() { // Pre-allocate capacity for typical batch sizes to avoid reallocation items.reserve(8); } - ~DeferredBatch() { - // Ensure cleanup of any remaining items - clear(); - } - // Add item to the batch void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); // Add item to the front of the batch (for high priority messages like ping) void add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); - // Clear all items with proper cleanup + // Clear all items void clear() { - cleanup_items_(items.size()); items.clear(); batch_start_time = 0; } - // Remove processed items from the front with proper cleanup - void remove_front(size_t count) { - cleanup_items_(count); - items.erase(items.begin(), items.begin() + count); - } + // Remove processed items from the front + void remove_front(size_t count) { items.erase(items.begin(), items.begin() + count); } bool empty() const { return items.empty(); } size_t size() const { return items.size(); } From 799cfe1de4353022304af4e9c403c69119735c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:26:46 -0600 Subject: [PATCH 05/12] [esp32_ble_tracker] Use initializer_list to eliminate compiler warning and reduce flash usage (#11861) --- esphome/components/esp32_ble_tracker/automation.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 054cbaa7df..bbf7992fa4 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -10,7 +10,7 @@ namespace esphome::esp32_ble_tracker { class ESPBTAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } - void set_addresses(const std::vector &addresses) { this->address_vec_ = addresses; } + void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } bool parse_device(const ESPBTDevice &device) override { uint64_t u64_addr = device.address_uint64(); From 5a2e6697e0ed9a11494eb52a776238b878b69e4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 17:42:50 -0600 Subject: [PATCH 06/12] [api][event] Send events immediately to prevent loss during rapid triggers (#11777) --- esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_connection.h | 54 ++++++++++++++++++----- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ca9ddaedf4..b4230576de 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1295,8 +1295,8 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe #ifdef USE_EVENT void APIConnection::send_event(event::Event *event, const char *event_type) { - this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, - EventResponse::ESTIMATED_SIZE); + this->send_message_smart_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, + EventResponse::ESTIMATED_SIZE); } uint16_t APIConnection::try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a77c93a2d5..6cfd108927 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -650,21 +650,30 @@ class APIConnection final : public APIServerConnection { } #endif + // Helper to check if a message type should bypass batching + // Returns true if: + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. It's an EventResponse (events are edge-triggered - every occurrence matters) + // 3. OR: User has opted into immediate sending (should_try_send_immediately = true + // AND batch_delay = 0) + inline bool should_send_immediately_(uint8_t message_type) const { + return ( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif +#ifdef USE_EVENT + message_type == EventResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)); + } + // Helper method to send a message either immediately or via batching + // Tries immediate send if should_send_immediately_() returns true and buffer has space + // Falls back to batching if immediate send fails or isn't applicable bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { - // Try to send immediately if: - // 1. It's an UpdateStateResponse (always send immediately to handle cases where - // the main loop is blocked, e.g., during OTA updates) - // 2. OR: We should try to send immediately (should_try_send_immediately = true) - // AND Batch delay is 0 (user has opted in to immediate sending) - // 3. AND: Buffer has space available - if (( -#ifdef USE_UPDATE - message_type == UpdateStateResponse::MESSAGE_TYPE || -#endif - (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && - this->helper_->can_write_without_blocking()) { + if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { @@ -682,6 +691,27 @@ class APIConnection final : public APIServerConnection { return this->schedule_message_(entity, creator, message_type, estimated_size); } + // Overload for MessageCreator (used by events which need to capture event_type) + bool send_message_smart_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) { + // Try to send immediately if message type should bypass batching and buffer has space + if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { + // Now actually encode and send + if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true, message_type) && + this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { +#ifdef HAS_PROTO_MESSAGE_DUMP + // Log the message in verbose mode + this->log_proto_message_(entity, creator, message_type); +#endif + return true; + } + + // If immediate send failed, fall through to batching + } + + // Fall back to scheduled batching + return this->schedule_message_(entity, std::move(creator), message_type, estimated_size); + } + // Helper function to schedule a deferred message with known message type bool schedule_message_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) { this->deferred_batch_.add_item(entity, std::move(creator), message_type, estimated_size); From 72da3d0f1e3e202f7546299316bf242fbc81b439 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 17:55:06 -0600 Subject: [PATCH 07/12] [thermostat] Replace std::map with FixedVector, reduce flash usage (#11875) --- esphome/components/thermostat/climate.py | 38 ++++++++- .../thermostat/thermostat_climate.cpp | 82 +++++++++++------- .../thermostat/thermostat_climate.h | 33 +++++-- .../climate_custom_fan_modes_and_presets.yaml | 1 + .../integration/test_climate_custom_modes.py | 85 ++++++++++++++++++- 5 files changed, 195 insertions(+), 44 deletions(-) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index a928d208f3..a3c155aac0 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -945,6 +945,10 @@ async def to_code(config): cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: + # Separate standard and custom presets, and build preset config variables + standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] + custom_presets: list[tuple[str, cg.MockObj]] = [] + for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] standard_preset = None @@ -987,9 +991,39 @@ async def to_code(config): ) if standard_preset is not None: - cg.add(var.set_preset_config(standard_preset, preset_target_variable)) + standard_presets.append((standard_preset, preset_target_variable)) else: - cg.add(var.set_custom_preset_config(name, preset_target_variable)) + custom_presets.append((name, preset_target_variable)) + + # Build initializer list for standard presets + if standard_presets: + cg.add( + var.set_preset_config( + [ + cg.StructInitializer( + thermostat_ns.struct("ThermostatPresetEntry"), + ("preset", preset), + ("config", preset_var), + ) + for preset, preset_var in standard_presets + ] + ) + ) + + # Build initializer list for custom presets + if custom_presets: + cg.add( + var.set_custom_preset_config( + [ + cg.StructInitializer( + thermostat_ns.struct("ThermostatCustomPresetEntry"), + ("name", cg.RawExpression(f'"{name}"')), + ("config", preset_var), + ) + for name, preset_var in custom_presets + ] + ) + ) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d533ef93ec..2b51f58f4f 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -53,8 +53,8 @@ void ThermostatClimate::setup() { if (use_default_preset) { if (this->default_preset_ != climate::ClimatePreset::CLIMATE_PRESET_NONE) { this->change_preset_(this->default_preset_); - } else if (!this->default_custom_preset_.empty()) { - this->change_custom_preset_(this->default_custom_preset_.c_str()); + } else if (this->default_custom_preset_ != nullptr) { + this->change_custom_preset_(this->default_custom_preset_); } } @@ -319,16 +319,16 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (auto &it : this->preset_config_) { - traits.add_supported_preset(it.first); + for (const auto &entry : this->preset_config_) { + traits.add_supported_preset(entry.preset); } - // Extract custom preset names from the custom_preset_config_ map + // Extract custom preset names from the custom_preset_config_ vector if (!this->custom_preset_config_.empty()) { std::vector custom_preset_names; custom_preset_names.reserve(this->custom_preset_config_.size()); - for (const auto &it : this->custom_preset_config_) { - custom_preset_names.push_back(it.first.c_str()); + for (const auto &entry : this->custom_preset_config_) { + custom_preset_names.push_back(entry.name); } traits.set_supported_custom_presets(custom_preset_names); } @@ -1154,12 +1154,18 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { - auto config = this->preset_config_.find(preset); + // Linear search through preset configurations + const ThermostatClimateTargetTempConfig *config = nullptr; + for (const auto &entry : this->preset_config_) { + if (entry.preset == preset) { + config = &entry.config; + break; + } + } - if (config != this->preset_config_.end()) { + if (config != nullptr) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); - if (this->change_preset_internal_(config->second) || (!this->preset.has_value()) || - this->preset.value() != preset) { + if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire any preset changed trigger if defined Trigger<> *trig = this->preset_change_trigger_; this->set_preset_(preset); @@ -1178,11 +1184,18 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { } void ThermostatClimate::change_custom_preset_(const char *custom_preset) { - auto config = this->custom_preset_config_.find(custom_preset); + // Linear search through custom preset configurations + const ThermostatClimateTargetTempConfig *config = nullptr; + for (const auto &entry : this->custom_preset_config_) { + if (strcmp(entry.name, custom_preset) == 0) { + config = &entry.config; + break; + } + } - if (config != this->custom_preset_config_.end()) { + if (config != nullptr) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); - if (this->change_preset_internal_(config->second) || !this->has_custom_preset() || + if (this->change_preset_internal_(*config) || !this->has_custom_preset() || strcmp(this->get_custom_preset(), custom_preset) != 0) { // Fire any preset changed trigger if defined Trigger<> *trig = this->preset_change_trigger_; @@ -1247,14 +1260,12 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_preset_config(climate::ClimatePreset preset, - const ThermostatClimateTargetTempConfig &config) { - this->preset_config_[preset] = config; +void ThermostatClimate::set_preset_config(std::initializer_list presets) { + this->preset_config_ = presets; } -void ThermostatClimate::set_custom_preset_config(const std::string &name, - const ThermostatClimateTargetTempConfig &config) { - this->custom_preset_config_[name] = config; +void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { + this->custom_preset_config_ = presets; } ThermostatClimate::ThermostatClimate() @@ -1293,8 +1304,16 @@ ThermostatClimate::ThermostatClimate() humidity_control_humidify_action_trigger_(new Trigger<>()), humidity_control_off_action_trigger_(new Trigger<>()) {} -void ThermostatClimate::set_default_preset(const std::string &custom_preset) { - this->default_custom_preset_ = custom_preset; +void ThermostatClimate::set_default_preset(const char *custom_preset) { + // Find the preset in custom_preset_config_ and store pointer from there + for (const auto &entry : this->custom_preset_config_) { + if (strcmp(entry.name, custom_preset) == 0) { + this->default_custom_preset_ = entry.name; + return; + } + } + // If not found, it will be caught during validation + this->default_custom_preset_ = nullptr; } void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } @@ -1605,19 +1624,22 @@ void ThermostatClimate::dump_config() { if (!this->preset_config_.empty()) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (auto &it : this->preset_config_) { - const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(it.first)); - ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_preset_ ? " (default)" : ""); - this->dump_preset_config_(preset_name, it.second); + for (const auto &entry : this->preset_config_) { + const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); + ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? " (default)" : ""); + this->dump_preset_config_(preset_name, entry.config); } } if (!this->custom_preset_config_.empty()) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (auto &it : this->custom_preset_config_) { - const auto *preset_name = it.first.c_str(); - ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_custom_preset_ ? " (default)" : ""); - this->dump_preset_config_(preset_name, it.second); + for (const auto &entry : this->custom_preset_config_) { + const auto *preset_name = entry.name; + ESP_LOGCONFIG(TAG, " %s:%s", preset_name, + (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) + ? " (default)" + : ""); + this->dump_preset_config_(preset_name, entry.config); } } } diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index c9795d9666..76391f800c 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -3,12 +3,12 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/components/climate/climate.h" #include "esphome/components/sensor/sensor.h" #include #include -#include namespace esphome { namespace thermostat { @@ -72,14 +72,29 @@ struct ThermostatClimateTargetTempConfig { optional mode_{}; }; +/// Entry for standard preset lookup +struct ThermostatPresetEntry { + climate::ClimatePreset preset; + ThermostatClimateTargetTempConfig config; +}; + +/// Entry for custom preset lookup +struct ThermostatCustomPresetEntry { + const char *name; + ThermostatClimateTargetTempConfig config; +}; + class ThermostatClimate : public climate::Climate, public Component { public: + using PresetEntry = ThermostatPresetEntry; + using CustomPresetEntry = ThermostatCustomPresetEntry; + ThermostatClimate(); void setup() override; void dump_config() override; void loop() override; - void set_default_preset(const std::string &custom_preset); + void set_default_preset(const char *custom_preset); void set_default_preset(climate::ClimatePreset preset); void set_on_boot_restore_from(OnBootRestoreFrom on_boot_restore_from); void set_set_point_minimum_differential(float differential); @@ -131,8 +146,8 @@ class ThermostatClimate : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points); - void set_preset_config(climate::ClimatePreset preset, const ThermostatClimateTargetTempConfig &config); - void set_custom_preset_config(const std::string &name, const ThermostatClimateTargetTempConfig &config); + void set_preset_config(std::initializer_list presets); + void set_custom_preset_config(std::initializer_list presets); Trigger<> *get_cool_action_trigger() const; Trigger<> *get_supplemental_cool_action_trigger() const; @@ -516,9 +531,6 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *prev_swing_mode_trigger_{nullptr}; Trigger<> *prev_humidity_control_trigger_{nullptr}; - /// Default custom preset to use on start up - std::string default_custom_preset_{}; - /// Climate action timers std::array timer_{ ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)), @@ -534,9 +546,12 @@ class ThermostatClimate : public climate::Climate, public Component { }; /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - std::map preset_config_{}; + FixedVector preset_config_{}; /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - std::map custom_preset_config_{}; + FixedVector custom_preset_config_{}; + /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + private: + const char *default_custom_preset_{nullptr}; }; } // namespace thermostat diff --git a/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml index bf4ef9eafd..3996d0f169 100644 --- a/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml +++ b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml @@ -14,6 +14,7 @@ climate: id: test_thermostat name: Test Thermostat Custom Modes sensor: thermostat_sensor + default_preset: "Eco Plus" preset: - name: Away default_target_temperature_low: 16°C diff --git a/tests/integration/test_climate_custom_modes.py b/tests/integration/test_climate_custom_modes.py index ce34959d88..67a7b0581a 100644 --- a/tests/integration/test_climate_custom_modes.py +++ b/tests/integration/test_climate_custom_modes.py @@ -2,9 +2,13 @@ from __future__ import annotations -from aioesphomeapi import ClimateInfo, ClimatePreset +import asyncio + +import aioesphomeapi +from aioesphomeapi import ClimateInfo, ClimatePreset, EntityState import pytest +from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction @@ -14,15 +18,50 @@ async def test_climate_custom_fan_modes_and_presets( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that custom presets are properly exposed via API.""" + """Test that custom presets are properly exposed and can be changed.""" + loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - # Get entities and services + states: dict[int, EntityState] = {} + super_saver_future: asyncio.Future[EntityState] = loop.create_future() + vacation_future: asyncio.Future[EntityState] = loop.create_future() + + def on_state(state: EntityState) -> None: + states[state.key] = state + if isinstance(state, aioesphomeapi.ClimateState): + # Wait for Super Saver preset + if ( + state.custom_preset == "Super Saver" + and state.target_temperature_low == 20.0 + and state.target_temperature_high == 24.0 + and not super_saver_future.done() + ): + super_saver_future.set_result(state) + # Wait for Vacation Mode preset + elif ( + state.custom_preset == "Vacation Mode" + and state.target_temperature_low == 15.0 + and state.target_temperature_high == 18.0 + and not vacation_future.done() + ): + vacation_future.set_result(state) + + # Get entities and set up state synchronization entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] assert len(climate_infos) == 1, "Expected exactly 1 climate entity" test_climate = climate_infos[0] + # Subscribe with the wrapper that filters initial states + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be broadcast + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + # Verify enum presets are exposed (from preset: config map) assert ClimatePreset.AWAY in test_climate.supported_presets, ( "Expected AWAY in enum presets" @@ -40,3 +79,43 @@ async def test_climate_custom_fan_modes_and_presets( assert "Vacation Mode" in custom_presets, ( "Expected 'Vacation Mode' in custom presets" ) + + # Get initial state and verify default preset + initial_state = initial_state_helper.initial_states.get(test_climate.key) + assert initial_state is not None, "Climate initial state not found" + assert isinstance(initial_state, aioesphomeapi.ClimateState) + assert initial_state.custom_preset == "Eco Plus", ( + f"Expected default preset 'Eco Plus', got '{initial_state.custom_preset}'" + ) + assert initial_state.target_temperature_low == 18.0, ( + f"Expected low temp 18.0, got {initial_state.target_temperature_low}" + ) + assert initial_state.target_temperature_high == 22.0, ( + f"Expected high temp 22.0, got {initial_state.target_temperature_high}" + ) + + # Test changing to "Super Saver" custom preset + client.climate_command(test_climate.key, custom_preset="Super Saver") + + try: + super_saver_state = await asyncio.wait_for(super_saver_future, timeout=5.0) + except TimeoutError: + pytest.fail("Super Saver preset change not received within 5 seconds") + + assert isinstance(super_saver_state, aioesphomeapi.ClimateState) + assert super_saver_state.custom_preset == "Super Saver" + assert super_saver_state.target_temperature_low == 20.0 + assert super_saver_state.target_temperature_high == 24.0 + + # Test changing to "Vacation Mode" custom preset + client.climate_command(test_climate.key, custom_preset="Vacation Mode") + + try: + vacation_state = await asyncio.wait_for(vacation_future, timeout=5.0) + except TimeoutError: + pytest.fail("Vacation Mode preset change not received within 5 seconds") + + assert isinstance(vacation_state, aioesphomeapi.ClimateState) + assert vacation_state.custom_preset == "Vacation Mode" + assert vacation_state.target_temperature_low == 15.0 + assert vacation_state.target_temperature_high == 18.0 From ff107a0674091d24e51a9f2c3fd04857d857b90a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:40:26 -0600 Subject: [PATCH 08/12] [mqtt] Fix crash with empty broker during upload/logs (#11866) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/mqtt.py | 13 ++++- tests/unit_tests/test_main.py | 50 +++++++++++++++++++ tests/unit_tests/test_mqtt.py | 91 +++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_mqtt.py diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 093ee64df4..0d50edbc2c 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -30,6 +30,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError from esphome.helpers import get_int_env, get_str_env from esphome.log import AnsiFore, color +from esphome.types import ConfigType from esphome.util import safe_print _LOGGER = logging.getLogger(__name__) @@ -154,8 +155,12 @@ def show_discover(config, username=None, password=None, client_id=None): def get_esphome_device_ip( - config, username=None, password=None, client_id=None, timeout=25 -): + config: ConfigType, + username: str | None = None, + password: str | None = None, + client_id: str | None = None, + timeout: int | float = 25, +) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( "Cannot discover IP via MQTT as the config does not include the mqtt: " @@ -166,6 +171,10 @@ def get_esphome_device_ip( "Cannot discover IP via MQTT as the config does not include the device name: " "component" ) + if not config[CONF_MQTT].get(CONF_BROKER): + raise EsphomeError( + "Cannot discover IP via MQTT as the broker is not configured" + ) dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9e5f399381..ccbc5a1306 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1166,6 +1166,56 @@ def test_upload_program_ota_with_mqtt_resolution( ) +def test_upload_program_ota_with_mqtt_empty_broker( + mock_mqtt_get_ip: Mock, + mock_is_ip_address: Mock, + mock_run_ota: Mock, + tmp_path: Path, + caplog: CaptureFixture, +) -> None: + """Test upload_program with OTA when MQTT broker is empty (issue #11653).""" + setup_core(address="192.168.1.50", platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_is_ip_address.return_value = True + mock_mqtt_get_ip.side_effect = EsphomeError( + "Cannot discover IP via MQTT as the broker is not configured" + ) + mock_run_ota.return_value = (0, "192.168.1.50") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["MQTTIP", "192.168.1.50"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.50" + # Verify MQTT was attempted but failed gracefully + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + # Verify we fell back to the IP address + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.50"], 3232, None, expected_firmware + ) + # Verify warning was logged + assert "MQTT IP discovery failed" in caplog.text + + @patch("esphome.__main__.importlib.import_module") def test_upload_program_platform_specific_handler( mock_import: Mock, diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py new file mode 100644 index 0000000000..4c2c34dff1 --- /dev/null +++ b/tests/unit_tests/test_mqtt.py @@ -0,0 +1,91 @@ +"""Unit tests for esphome.mqtt module.""" + +from __future__ import annotations + +import pytest + +from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME +from esphome.core import EsphomeError +from esphome.mqtt import get_esphome_device_ip + + +def test_get_esphome_device_ip_empty_broker() -> None: + """Test that get_esphome_device_ip raises EsphomeError when broker is empty.""" + config = { + CONF_MQTT: { + CONF_BROKER: "", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the broker is not configured", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_none_broker() -> None: + """Test that get_esphome_device_ip raises EsphomeError when broker is None.""" + config = { + CONF_MQTT: { + CONF_BROKER: None, + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the broker is not configured", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_missing_mqtt() -> None: + """Test that get_esphome_device_ip raises EsphomeError when mqtt config is missing.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the config does not include the mqtt:", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_missing_esphome() -> None: + """Test that get_esphome_device_ip raises EsphomeError when esphome config is missing.""" + config = { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the config does not include the device name:", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_missing_name() -> None: + """Test that get_esphome_device_ip raises EsphomeError when device name is missing.""" + config = { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: {}, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the config does not include the device name:", + ): + get_esphome_device_ip(config) From afed58107967d519c8a62779fbce809cdbe79af0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:41:28 -0600 Subject: [PATCH 09/12] [light] Fix dangling reference in compute_color_mode causing memory corruption (#11868) --- esphome/components/api/api_connection.cpp | 3 ++- esphome/components/light/light_call.cpp | 2 +- esphome/components/light/light_traits.h | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b4230576de..4acd2fc15c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -476,8 +476,9 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); + auto supported_modes = traits.get_supported_color_modes(); // Pass pointer to ColorModeMask so the iterator can encode actual ColorMode enum values - msg.supported_color_modes = &traits.get_supported_color_modes(); + msg.supported_color_modes = &supported_modes; if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { msg.min_mireds = traits.get_min_mireds(); diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index df17f53adc..8365ac77cd 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -406,7 +406,7 @@ void LightCall::transform_parameters_() { } } ColorMode LightCall::compute_color_mode_() { - const auto &supported_modes = this->parent_->get_traits().get_supported_color_modes(); + auto supported_modes = this->parent_->get_traits().get_supported_color_modes(); int supported_count = supported_modes.size(); // Some lights don't support any color modes (e.g. monochromatic light), leave it at unknown. diff --git a/esphome/components/light/light_traits.h b/esphome/components/light/light_traits.h index 294b0cad1d..c3bb27a964 100644 --- a/esphome/components/light/light_traits.h +++ b/esphome/components/light/light_traits.h @@ -18,7 +18,8 @@ class LightTraits { public: LightTraits() = default; - const ColorModeMask &get_supported_color_modes() const { return this->supported_color_modes_; } + // Return by value to avoid dangling reference when get_traits() returns a temporary + ColorModeMask get_supported_color_modes() const { return this->supported_color_modes_; } void set_supported_color_modes(ColorModeMask supported_color_modes) { this->supported_color_modes_ = supported_color_modes; } From 1d8b08dcce066b876d055a44b7f7ad192571985f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:43:51 -0600 Subject: [PATCH 10/12] [wifi][ethernet] Fix spurious warnings and unclear status after PR #9823 (#11871) --- esphome/components/ethernet/ethernet_component.cpp | 5 ++++- esphome/components/wifi/wifi_component.cpp | 13 ++++++++++++- esphome/components/wifi/wifi_component.h | 3 +++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 +- esphome/components/wifi/wifi_component_esp_idf.cpp | 11 +++++++---- .../components/wifi/wifi_component_libretiny.cpp | 2 +- esphome/components/wifi/wifi_component_pico_w.cpp | 2 +- 7 files changed, 29 insertions(+), 9 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 5888ddce60..cad963b299 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -381,7 +381,10 @@ void EthernetComponent::dump_config() { break; } - ESP_LOGCONFIG(TAG, "Ethernet:"); + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Connected: %s", + YESNO(this->is_connected())); this->dump_connect_params_(); #ifdef USE_ETHERNET_SPI ESP_LOGCONFIG(TAG, diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 817419107f..33aa6c8139 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -743,6 +743,14 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { } const LogString *get_signal_bars(int8_t rssi) { + // Check for disconnected sentinel value first + if (rssi == WIFI_RSSI_DISCONNECTED) { + // MULTIPLICATION SIGN + // Unicode: U+00D7, UTF-8: C3 97 + return LOG_STR("\033[0;31m" // red + "\xc3\x97\xc3\x97\xc3\x97\xc3\x97" + "\033[0m"); + } // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 // LOWER HALF BLOCK @@ -1022,7 +1030,10 @@ void WiFiComponent::check_scanning_finished() { } void WiFiComponent::dump_config() { - ESP_LOGCONFIG(TAG, "WiFi:"); + ESP_LOGCONFIG(TAG, + "WiFi:\n" + " Connected: %s", + YESNO(this->is_connected())); this->print_connect_params_(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 713e6f223f..5023cf3428 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -52,6 +52,9 @@ extern "C" { namespace esphome { namespace wifi { +/// Sentinel value for RSSI when WiFi is not connected +static constexpr int8_t WIFI_RSSI_DISCONNECTED = -127; + struct SavedWifiSettings { char ssid[33]; char password[65]; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index bcb5dc4cf7..bdaae5382a 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -870,7 +870,7 @@ bssid_t WiFiComponent::wifi_bssid() { return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } +int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; } int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {(const ip_addr_t *) WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t *) WiFi.gatewayIP()}; } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index fd7e85fb6b..8c27fe92db 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1029,7 +1029,8 @@ bssid_t WiFiComponent::wifi_bssid() { wifi_ap_record_t info; esp_err_t err = esp_wifi_sta_get_ap_info(&info); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); + // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) + ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); return bssid; } std::copy(info.bssid, info.bssid + 6, bssid.begin()); @@ -1039,7 +1040,8 @@ std::string WiFiComponent::wifi_ssid() { wifi_ap_record_t info{}; esp_err_t err = esp_wifi_sta_get_ap_info(&info); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); + // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) + ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); return ""; } auto *ssid_s = reinterpret_cast(info.ssid); @@ -1050,8 +1052,9 @@ int8_t WiFiComponent::wifi_rssi() { wifi_ap_record_t info; esp_err_t err = esp_wifi_sta_get_ap_info(&info); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); - return 0; + // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) + ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); + return WIFI_RSSI_DISCONNECTED; } return info.rssi; } diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 2946b9e831..8c6c28ac75 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -484,7 +484,7 @@ bssid_t WiFiComponent::wifi_bssid() { return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } +int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; } int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()}; } diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 7025ba16bd..073b752886 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -200,7 +200,7 @@ bssid_t WiFiComponent::wifi_bssid() { return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } +int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; } int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { From 1675408161b8e1631b3b1b53909580cfd7a1fb99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:44:22 -0600 Subject: [PATCH 11/12] [wifi] Fix slow reconnection after connection loss for all network types (#11873) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 33aa6c8139..880928c3e3 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -465,6 +465,8 @@ void WiFiComponent::loop() { if (!this->is_connected()) { ESP_LOGW(TAG, "Connection lost; reconnecting"); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + // Clear error flag before reconnecting so first attempt is not seen as immediate failure + this->error_from_callback_ = false; this->retry_connect(); } else { this->status_clear_warning(); @@ -1058,6 +1060,10 @@ void WiFiComponent::check_connecting_finished() { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; + // Ensure next connection attempt does not inherit error state + // so when WiFi disconnects later we start fresh and don't see + // the first connection as a failure. + this->error_from_callback_ = false; this->print_connect_params_(); @@ -1144,6 +1150,11 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP } #endif + // Check if we should try explicit hidden networks before scanning + // This handles reconnection after connection loss where first network is hidden + if (!this->sta_.empty() && this->sta_[0].get_hidden()) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } // No more APs to try, fall back to scan return WiFiRetryPhase::SCAN_CONNECTING; From 382483b0635353b70cd1975af6eaaa4118f2091e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:56:11 -0500 Subject: [PATCH 12/12] Bump version to 2025.11.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 8025c71c19..8766b8f00c 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.11.0b1 +PROJECT_NUMBER = 2025.11.0b2 # 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 00975753c2..8c6020a868 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.11.0b1" +__version__ = "2025.11.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (