From 398dba4fc871efed35873a98a5aa44cb29217be0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Nov 2025 21:44:19 -0600 Subject: [PATCH 001/320] [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 748aee584a819dcab58dc07645f18af1d615cdfe 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 002/320] [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 9de80b635a9c127488557604fc693d34549f8e64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 11:56:19 -0600 Subject: [PATCH 003/320] [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 5d613ada8319e95d0d233beccdd322745d42e176 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:00:50 -0600 Subject: [PATCH 004/320] Bump pytest from 9.0.0 to 9.0.1 (#11874) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 35010ad52f..5c7cccaf25 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.0 +pytest==9.0.1 pytest-cov==7.0.0 pytest-mock==3.15.1 pytest-asyncio==1.3.0 From 3872a2fd919fb8b33fe0829001d0dce50a54001d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:01:07 -0600 Subject: [PATCH 005/320] [captive_portal] Warn when enabled without WiFi AP configured (#11856) --- esphome/components/captive_portal/__init__.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 99acb76bcf..9bd3ef8a05 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,9 +1,12 @@ +import logging + import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( + CONF_AP, CONF_ID, PLATFORM_BK72XX, PLATFORM_ESP32, @@ -14,6 +17,10 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) def AUTO_LOAD() -> list[str]: @@ -50,6 +57,27 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + wifi_conf = full_config.get("wifi") + + if wifi_conf is None: + # This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway + raise cv.Invalid("Captive portal requires the wifi component to be configured") + + if CONF_AP not in wifi_conf: + _LOGGER.warning( + "Captive portal is enabled but no WiFi AP is configured. " + "The captive portal will not be accessible. " + "Add 'ap:' to your WiFi configuration to enable the captive portal." + ) + + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL) async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) From 4b58cb4ce61cb07a3b8e1d97c7274e82e98b8db5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:01:19 -0600 Subject: [PATCH 006/320] [wifi] Pass ManualIP by const reference to reduce stack usage (#11858) --- esphome/components/network/ip_address.h | 8 ++++---- esphome/components/wifi/wifi_component.h | 4 ++-- esphome/components/wifi/wifi_component_esp8266.cpp | 4 ++-- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++-- esphome/components/wifi/wifi_component_libretiny.cpp | 4 ++-- esphome/components/wifi/wifi_component_pico_w.cpp | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 5e6b0dbd96..5ec6450cce 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -118,10 +118,10 @@ struct IPAddress { operator arduino_ns::IPAddress() const { return ip_addr_get_ip4_u32(&ip_addr_); } #endif - bool is_set() { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr) - bool is_ip4() { return IP_IS_V4(&ip_addr_); } - bool is_ip6() { return IP_IS_V6(&ip_addr_); } - bool is_multicast() { return ip_addr_ismulticast(&ip_addr_); } + bool is_set() const { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr) + bool is_ip4() const { return IP_IS_V4(&ip_addr_); } + bool is_ip6() const { return IP_IS_V6(&ip_addr_); } + bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } std::string str() const { return str_lower_case(ipaddr_ntoa(&ip_addr_)); } bool operator==(const IPAddress &other) const { return ip_addr_cmp(&ip_addr_, &other.ip_addr_); } bool operator!=(const IPAddress &other) const { return !ip_addr_cmp(&ip_addr_, &other.ip_addr_); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 713e6f223f..d37367b88c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -426,7 +426,7 @@ class WiFiComponent : public Component { bool wifi_sta_pre_setup_(); bool wifi_apply_output_power_(float output_power); bool wifi_apply_power_save_(); - bool wifi_sta_ip_config_(optional manual_ip); + bool wifi_sta_ip_config_(const optional &manual_ip); bool wifi_apply_hostname_(); bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); @@ -434,7 +434,7 @@ class WiFiComponent : public Component { bool wifi_scan_start_(bool passive); #ifdef USE_WIFI_AP - bool wifi_ap_ip_config_(optional manual_ip); + bool wifi_ap_ip_config_(const optional &manual_ip); bool wifi_start_ap_(const WiFiAP &ap); #endif // USE_WIFI_AP diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index bcb5dc4cf7..b787446a39 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -117,7 +117,7 @@ void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t }; #endif -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -730,7 +730,7 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { // enable AP if (!this->wifi_mode_({}, true)) return false; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index fd7e85fb6b..824adb5cf5 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -487,7 +487,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return true; } -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -884,7 +884,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { esp_err_t err; // enable AP diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 2946b9e831..eea7a7e933 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -68,7 +68,7 @@ bool WiFiComponent::wifi_sta_pre_setup_() { return true; } bool WiFiComponent::wifi_apply_power_save_() { return WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); } -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -434,7 +434,7 @@ void WiFiComponent::wifi_scan_done_callback_() { } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { // enable AP if (!this->wifi_mode_({}, true)) return false; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 7025ba16bd..54f03f803d 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -72,7 +72,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); } -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { if (!manual_ip.has_value()) { return true; } @@ -146,7 +146,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { esphome::network::IPAddress ip_address, gateway, subnet, dns; if (manual_ip.has_value()) { ip_address = manual_ip->static_ip; From 5a550cc579bee3f7c07ef7c9ccdc707ac776d836 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:26:36 -0600 Subject: [PATCH 007/320] [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 2f39b10baa9348c68d54413ad60177a6d0050995 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:26:46 -0600 Subject: [PATCH 008/320] [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 5f0fa68d732cd672fc83a249148bcde130bb812a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:26:57 -0600 Subject: [PATCH 009/320] [esp32_ble] Use stack allocation for MAC formatting in dump_config (#11860) --- esphome/components/esp32_ble/ble.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 8bbb21e3ca..d0bfb6f843 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -634,11 +634,13 @@ void ESP32BLE::dump_config() { io_capability_s = "invalid"; break; } + char mac_s[18]; + format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" " MAC address: %s\n" " IO Capability: %s", - format_mac_address_pretty(mac_address).c_str(), io_capability_s); + mac_s, io_capability_s); } else { ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled"); } From 29a50da6355b2ad936dc78a19efb4cc4eeedc57e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 14:27:06 -0600 Subject: [PATCH 010/320] [wifi] Use stack allocation for BSSID formatting in logging (#11859) --- esphome/components/wifi/wifi_component.cpp | 24 +++++++++++-------- .../wifi/wifi_component_esp8266.cpp | 6 +++-- .../wifi/wifi_component_esp_idf.cpp | 6 +++-- .../wifi/wifi_component_libretiny.cpp | 6 +++-- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 817419107f..e33cd7cf2d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -668,25 +668,25 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority - std::string bssid_formatted; + char bssid_s[18]; int8_t priority = 0; if (ap.get_bssid().has_value()) { - bssid_formatted = format_mac_address_pretty(ap.get_bssid().value().data()); + format_mac_addr_upper(ap.get_bssid().value().data(), bssid_s); priority = this->get_sta_priority(ap.get_bssid().value()); } ESP_LOGI(TAG, "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...", - ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_formatted.c_str() : LOG_STR_LITERAL("any"), - priority, this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), + ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_s : LOG_STR_LITERAL("any"), priority, + this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); #ifdef ESPHOME_LOG_HAS_VERBOSE ESP_LOGV(TAG, "Connection Params:"); ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str()); if (ap.get_bssid().has_value()) { - ESP_LOGV(TAG, " BSSID: %s", format_mac_address_pretty(ap.get_bssid()->data()).c_str()); + ESP_LOGV(TAG, " BSSID: %s", bssid_s); } else { ESP_LOGV(TAG, " BSSID: Not Set"); } @@ -787,6 +787,8 @@ const LogString *get_signal_bars(int8_t rssi) { void WiFiComponent::print_connect_params_() { bssid_t bssid = wifi_bssid(); + char bssid_s[18]; + format_mac_addr_upper(bssid.data(), bssid_s); ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty().c_str()); if (this->is_disabled()) { @@ -809,9 +811,9 @@ void WiFiComponent::print_connect_params_() { " Gateway: %s\n" " DNS1: %s\n" " DNS2: %s", - wifi_ssid().c_str(), format_mac_address_pretty(bssid.data()).c_str(), App.get_name().c_str(), rssi, - LOG_STR_ARG(get_signal_bars(rssi)), get_wifi_channel(), wifi_subnet_mask_().str().c_str(), - wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); + wifi_ssid().c_str(), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)), + get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(), + wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); #ifdef ESPHOME_LOG_HAS_VERBOSE if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) { ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(*config->get_bssid())); @@ -1390,8 +1392,10 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); this->set_sta_priority(failed_bssid.value(), new_priority); } - ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(), - format_mac_address_pretty(failed_bssid.value().data()).c_str(), old_priority, new_priority); + char bssid_s[18]; + format_mac_addr_upper(failed_bssid.value().data(), bssid_s); + ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(), bssid_s, + old_priority, new_priority); // After adjusting priority, check if all priorities are now at minimum // If so, clear the vector to save memory and reset for fresh start diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index b787446a39..78f336ab15 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -525,8 +525,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); s_sta_connect_not_found = true; } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), LOG_STR_ARG(get_disconnect_reason_str(it.reason))); + char bssid_s[18]; + format_mac_addr_upper(it.bssid, bssid_s); + ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s, + LOG_STR_ARG(get_disconnect_reason_str(it.reason))); s_sta_connect_error = true; } s_sta_connected = false; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 824adb5cf5..df29565554 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -746,8 +746,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGI(TAG, "Disconnected ssid='%s' reason='Station Roaming'", buf); return; } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason)); + char bssid_s[18]; + format_mac_addr_upper(it.bssid, bssid_s); + ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s, + get_disconnect_reason_str(it.reason)); s_sta_connect_error = true; } s_sta_connected = false; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index eea7a7e933..7f0c35c8c8 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -299,8 +299,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason)); + char bssid_s[18]; + format_mac_addr_upper(it.bssid, bssid_s); + ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s, + get_disconnect_reason_str(it.reason)); } uint8_t reason = it.reason; From 859101ddc9fa55db7f31dfbb4a9411f1d4c736b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 17:42:50 -0600 Subject: [PATCH 011/320] [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 3a5b3ad77d35f9b1b1cceade282038100fb00603 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 17:55:06 -0600 Subject: [PATCH 012/320] [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 769137fc09873de801f2a3e90ce38dc35573c2be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:40:26 -0600 Subject: [PATCH 013/320] [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 735bf9930aa12335bfef6d939439521150b82109 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:41:28 -0600 Subject: [PATCH 014/320] [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 47fe84e92208f3afab6fb871ec9842423caf3584 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:43:51 -0600 Subject: [PATCH 015/320] [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 e33cd7cf2d..7d239349ff 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 @@ -1024,7 +1032,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 d37367b88c..2fd7fa6cd4 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 78f336ab15..a543628e27 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -872,7 +872,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 df29565554..4aac03885a 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1031,7 +1031,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()); @@ -1041,7 +1042,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); @@ -1052,8 +1054,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 7f0c35c8c8..98cbfddb1d 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -486,7 +486,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 54f03f803d..91766e8ab5 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 2d6618da3c2f1410a8a66f19ab15f8529412e9d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Nov 2025 18:44:22 -0600 Subject: [PATCH 016/320] [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 7d239349ff..51a5a47323 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(); @@ -1060,6 +1062,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_(); @@ -1146,6 +1152,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 d869108416ed030caa93729b419256ac45e4667b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 13 Nov 2025 03:06:20 +0100 Subject: [PATCH 017/320] [nrf52] add settings for dcdc converter (#11841) --- esphome/components/nrf52/__init__.py | 3 +++ tests/components/nrf52/test.nrf52-adafruit.yaml | 1 + 2 files changed, 4 insertions(+) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index a3b79bf139..03927e8ea2 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -103,6 +103,7 @@ nrf52_ns = cg.esphome_ns.namespace("nrf52") DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" +CONF_DCDC = "dcdc" CONF_REG0 = "reg0" CONF_UICR_ERASE = "uicr_erase" @@ -121,6 +122,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, } ), + cv.Optional(CONF_DCDC, default=True): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { cv.Required(CONF_VOLTAGE): cv.All( @@ -196,6 +198,7 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) if reg0_config := config.get(CONF_REG0): value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 72fd015953..5fa0d6e88f 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -15,6 +15,7 @@ nrf52: inverted: true mode: output: true + dcdc: False reg0: voltage: 2.1V uicr_erase: true From ed7e5cd325f8d376f01704f36fba2557dbfd69a7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:00:47 +1300 Subject: [PATCH 018/320] Bump version to 2025.12.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 8766b8f00c..a19120b9da 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.0b2 +PROJECT_NUMBER = 2025.12.0-dev # 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 8c6020a868..a25114d80e 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.0b2" +__version__ = "2025.12.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 952bdfaac238181819ef8d8f85267623dd00873b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Nov 2025 00:55:48 +1000 Subject: [PATCH 019/320] [esp32] Make esp-idf default framework for P4 (#11884) --- esphome/components/esp32/__init__.py | 163 ++++++++++++--------------- 1 file changed, 74 insertions(+), 89 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 61511cba0c..9741dc76a1 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -381,8 +381,9 @@ PLATFORM_VERSION_LOOKUP = { } -def _check_versions(value): - value = value.copy() +def _check_versions(config): + config = config.copy() + value = config[CONF_FRAMEWORK] if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: @@ -447,7 +448,7 @@ def _check_versions(value): "If there are connectivity or build issues please remove the manual version." ) - return value + return config def _parse_platform_version(value): @@ -598,89 +599,72 @@ def _validate_idf_component(config: ConfigType) -> ConfigType: FRAMEWORK_ESP_IDF = "esp-idf" FRAMEWORK_ARDUINO = "arduino" -FRAMEWORK_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_TYPE, default=FRAMEWORK_ARDUINO): cv.one_of( - FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO - ), - cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, - cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( - *LOG_LEVELS_IDF, upper=True - ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( - { - cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( - *ASSERTION_LEVELS, upper=True - ), - cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( - *COMPILER_OPTIMIZATIONS, upper=True - ), - cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, - cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, - cv.Optional( - CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False - ): cv.boolean, - cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, - # DHCP server is needed for WiFi AP mode. When WiFi component is used, - # it will handle disabling DHCP server when AP is not configured. - # Default to false (disabled) when WiFi is not used. - cv.OnlyWithout( - CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_MDNS_QUERIES, default=True - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_VFS_SUPPORT_SELECT, default=True - ): cv.boolean, - cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, - cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, - cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( - min=8192, max=32768 - ), - } - ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( - cv.All( - cv.Schema( - { - cv.Required(CONF_NAME): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.git_ref, - cv.Optional(CONF_REF): cv.string, - cv.Optional(CONF_PATH): cv.string, - cv.Optional(CONF_REFRESH): cv.All( - cv.string, cv.source_refresh - ), - } - ), - _validate_idf_component, - ) - ), - } - ), - _check_versions, +FRAMEWORK_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), + cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, + cv.Optional(CONF_RELEASE): cv.string_strict, + cv.Optional(CONF_SOURCE): cv.string_strict, + cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { + cv.string_strict: cv.string_strict + }, + cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( + *LOG_LEVELS_IDF, upper=True + ), + cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + { + cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( + *ASSERTION_LEVELS, upper=True + ), + cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( + *COMPILER_OPTIMIZATIONS, upper=True + ), + cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, + cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, + # DHCP server is needed for WiFi AP mode. When WiFi component is used, + # it will handle disabling DHCP server when AP is not configured. + # Default to false (disabled) when WiFi is not used. + cv.OnlyWithout( + CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False + ): cv.boolean, + cv.Optional(CONF_ENABLE_LWIP_MDNS_QUERIES, default=True): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True + ): cv.boolean, + cv.Optional(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, + cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( + min=8192, max=32768 + ), + } + ), + cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.All( + cv.Schema( + { + cv.Required(CONF_NAME): cv.string_strict, + cv.Optional(CONF_SOURCE): cv.git_ref, + cv.Optional(CONF_REF): cv.string, + cv.Optional(CONF_PATH): cv.string, + cv.Optional(CONF_REFRESH): cv.All(cv.string, cv.source_refresh), + } + ), + _validate_idf_component, + ) + ), + } ) @@ -743,11 +727,11 @@ def _show_framework_migration_message(name: str, variant: str) -> None: def _set_default_framework(config): + config = config.copy() if CONF_FRAMEWORK not in config: - config = config.copy() - - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) + if CONF_TYPE not in config[CONF_FRAMEWORK]: + variant = config[CONF_VARIANT] if variant in ARDUINO_ALLOWED_VARIANTS: config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO _show_framework_migration_message( @@ -787,6 +771,7 @@ CONFIG_SCHEMA = cv.All( ), _detect_variant, _set_default_framework, + _check_versions, set_core_data, cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), ) From 0afcf67c32bcc22d60579a38fd42e3823c7072b4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:52:08 +1000 Subject: [PATCH 020/320] [esp32] Add sdkconfig flag to make OTA work for 32MB flash (#11883) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/__init__.py | 81 +++++++++---------- tests/components/esp32/test.esp32-p4-idf.yaml | 27 +++++++ 2 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 tests/components/esp32/test.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9741dc76a1..0f85e585f7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -498,6 +498,8 @@ def final_validate(config): from esphome.components.psram import DOMAIN as PSRAM_DOMAIN errs = [] + conf_fw = config[CONF_FRAMEWORK] + advanced = conf_fw[CONF_ADVANCED] full_config = fv.full_config.get() if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS): pio_flash_size_key = "board_upload.flash_size" @@ -514,22 +516,14 @@ def final_validate(config): f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" ) ) - if ( - config[CONF_VARIANT] != VARIANT_ESP32 - and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK]) - and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED] - ): + if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_IGNORE_EFUSE_MAC_CRC]: errs.append( cv.Invalid( f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}", path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC], ) ) - if ( - config.get(CONF_FRAMEWORK, {}) - .get(CONF_ADVANCED, {}) - .get(CONF_EXECUTE_FROM_PSRAM) - ): + if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] != VARIANT_ESP32S3: errs.append( cv.Invalid( @@ -545,6 +539,17 @@ def final_validate(config): ) ) + if ( + config[CONF_FLASH_SIZE] == "32MB" + and "ota" in full_config + and not advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES] + ): + errs.append( + cv.Invalid( + f"OTA with 32MB flash requires '{CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES}' to be set in the '{CONF_ADVANCED}' section of the esp32 configuration", + path=[CONF_FLASH_SIZE], + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -620,10 +625,12 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( *COMPILER_OPTIMIZATIONS, upper=True ), - cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, + cv.Optional( + CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, default=False + ): cv.boolean, cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, - cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -644,7 +651,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, - cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), @@ -790,9 +797,7 @@ def _configure_lwip_max_sockets(conf: dict) -> None: from esphome.components.socket import KEY_SOCKET_CONSUMERS # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS - user_max_sockets = conf.get(CONF_SDKCONFIG_OPTIONS, {}).get( - "CONFIG_LWIP_MAX_SOCKETS" - ) + user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") socket_consumers: dict[str, int] = CORE.data.get(KEY_SOCKET_CONSUMERS, {}) total_sockets = sum(socket_consumers.values()) @@ -962,23 +967,18 @@ async def to_code(config): # WiFi component handles its own optimization when AP mode is not used # When using Arduino with Ethernet, DHCP server functions must be available # for the Network library to compile, even if not actively used - if ( - CONF_ENABLE_LWIP_DHCP_SERVER in advanced - and not advanced[CONF_ENABLE_LWIP_DHCP_SERVER] - and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO - and "ethernet" in CORE.loaded_integrations - ) + if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( + conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations ): add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) - if not advanced.get(CONF_ENABLE_LWIP_MDNS_QUERIES, True): + if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) - if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): + if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) _configure_lwip_max_sockets(conf) - if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) @@ -989,23 +989,22 @@ async def to_code(config): # - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default) # - Up to 200% slower under load when all operations queue through tcpip_thread # Enabling this makes ESP-IDF socket performance match Arduino framework. - if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True): + if advanced[CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING]: add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True) - if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): + if advanced[CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY]: add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) # Disable placing libc locks in IRAM to save RAM # This is safe for ESPHome since no IRAM ISRs (interrupts that run while cache is disabled) # use libc lock APIs. Saves approximately 1.3KB (1,356 bytes) of IRAM. - if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): + if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) # Disable VFS support for termios (terminal I/O functions) # ESPHome doesn't use termios functions on ESP32 (only used in host UART driver). # Saves approximately 1.8KB of flash when disabled (default). add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True), + "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] ) # Disable VFS support for select() with file descriptors @@ -1019,8 +1018,7 @@ async def to_code(config): else: # No component needs it - allow user to control (default: disabled) add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True), + "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] ) # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) @@ -1033,8 +1031,7 @@ async def to_code(config): else: # No component needs it - allow user to control (default: disabled) add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True), + "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] ) cg.add_platformio_option("board_build.partitions", "partitions.csv") @@ -1048,7 +1045,7 @@ async def to_code(config): add_idf_sdkconfig_option(flag, assertion_level == key) add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) - compiler_optimization = advanced.get(CONF_COMPILER_OPTIMIZATION) + compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) @@ -1057,18 +1054,20 @@ async def to_code(config): conf[CONF_ADVANCED][CONF_ENABLE_LWIP_ASSERT], ) - if advanced.get(CONF_IGNORE_EFUSE_MAC_CRC): + if advanced[CONF_IGNORE_EFUSE_MAC_CRC]: add_idf_sdkconfig_option("CONFIG_ESP_MAC_IGNORE_MAC_CRC_ERROR", True) add_idf_sdkconfig_option("CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE", False) - if advanced.get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): + if advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES]: _LOGGER.warning( "Using experimental features in ESP-IDF may result in unexpected failures." ) add_idf_sdkconfig_option("CONFIG_IDF_EXPERIMENTAL_FEATURES", True) + if config[CONF_FLASH_SIZE] == "32MB": + add_idf_sdkconfig_option( + "CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", True + ) - cg.add_define( - "ESPHOME_LOOP_TASK_STACK_SIZE", advanced.get(CONF_LOOP_TASK_STACK_SIZE) - ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( "USE_ESP_IDF_VERSION_CODE", diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..a4c930f236 --- /dev/null +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -0,0 +1,27 @@ +esp32: + variant: esp32p4 + flash_size: 32MB + cpu_frequency: 400MHz + framework: + type: esp-idf + advanced: + enable_idf_experimental_features: yes + +ota: + platform: esphome + +wifi: + ssid: MySSID + password: password1 + +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 From 2290eb0dd2cac9d8b044aa011629dd1c134f2975 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:08:06 +0100 Subject: [PATCH 021/320] [light] Fix missing `ColorMode::BRIGHTNESS` case in logging (#11836) --- esphome/components/light/light_call.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 8365ac77cd..b15ff84b97 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -52,8 +52,10 @@ static void log_invalid_parameter(const char *name, const LogString *message) { } static const LogString *color_mode_to_human(ColorMode color_mode) { - if (color_mode == ColorMode::UNKNOWN) - return LOG_STR("Unknown"); + if (color_mode == ColorMode::ON_OFF) + return LOG_STR("On/Off"); + if (color_mode == ColorMode::BRIGHTNESS) + return LOG_STR("Brightness"); if (color_mode == ColorMode::WHITE) return LOG_STR("White"); if (color_mode == ColorMode::COLOR_TEMPERATURE) @@ -68,7 +70,7 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { return LOG_STR("RGB + cold/warm white"); if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE) return LOG_STR("RGB + color temperature"); - return LOG_STR(""); + return LOG_STR("Unknown"); } // Helper to log percentage values From 67524e14eec345fecf60daf7251e67be7ff9a364 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:05:02 +0000 Subject: [PATCH 022/320] Bump pylint from 4.0.2 to 4.0.3 (#11894) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5c7cccaf25..8d4e0ca246 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.2 +pylint==4.0.3 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.4 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating From e49a943cf7cd93c5123ba4b4e10fc861d8b8f4a1 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:13:48 +0100 Subject: [PATCH 023/320] [wifi] Allow `use_psram` with Arduino (#11902) --- esphome/components/wifi/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4dbb425e4b..11bd7798e2 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,7 +12,6 @@ from esphome.components.network import ( from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.config_validation import only_with_esp_idf from esphome.const import ( CONF_AP, CONF_BSSID, @@ -352,7 +351,7 @@ CONFIG_SCHEMA = cv.All( single=True ), cv.Optional(CONF_USE_PSRAM): cv.All( - only_with_esp_idf, cv.requires_component("psram"), cv.boolean + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), } ), From 2bf6d48fcf06cce35a8e4d3211e0bc77c433d341 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:06:08 -0500 Subject: [PATCH 024/320] [uart] Improve error handling and validate buffer size (#11895) Co-authored-by: J. Nick Koston --- esphome/components/uart/__init__.py | 19 ++++++++++ .../uart/uart_component_esp_idf.cpp | 35 +++++++++++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index cbc11d0db0..7b0d9726b8 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from logging import getLogger import math import re @@ -35,6 +36,8 @@ from esphome.core import CORE, ID import esphome.final_validate as fv from esphome.yaml_util import make_data_base +_LOGGER = getLogger(__name__) + CODEOWNERS = ["@esphome/core"] uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") @@ -130,6 +133,21 @@ def validate_host_config(config): return config +def validate_rx_buffer_size(config): + if CORE.is_esp32: + # ESP32 UART hardware FIFO is 128 bytes (LP UART is 16 bytes, but we use 128 as safe minimum) + # rx_buffer_size must be greater than the hardware FIFO length + min_buffer_size = 128 + if config[CONF_RX_BUFFER_SIZE] <= min_buffer_size: + _LOGGER.warning( + "UART rx_buffer_size (%d bytes) is too small and must be greater than the hardware " + "FIFO size (%d bytes). The buffer size will be automatically adjusted at runtime.", + config[CONF_RX_BUFFER_SIZE], + min_buffer_size, + ) + return config + + def _uart_declare_type(value): if CORE.is_esp8266: return cv.declare_id(ESP8266UartComponent)(value) @@ -247,6 +265,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT), validate_host_config, + validate_rx_buffer_size, ) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 73813d2d5b..70a13c9e37 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -91,6 +91,16 @@ void IDFUARTComponent::setup() { this->uart_num_ = static_cast(next_uart_num++); this->lock_ = xSemaphoreCreateMutex(); +#if (SOC_UART_LP_NUM >= 1) + size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN); +#else + size_t fifo_len = SOC_UART_FIFO_LEN; +#endif + if (this->rx_buffer_size_ <= fifo_len) { + ESP_LOGW(TAG, "rx_buffer_size is too small, must be greater than %zu", fifo_len); + this->rx_buffer_size_ = fifo_len * 2; + } + xSemaphoreTake(this->lock_, portMAX_DELAY); this->load_settings(false); @@ -237,8 +247,12 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { xSemaphoreTake(this->lock_, portMAX_DELAY); - uart_write_bytes(this->uart_num_, data, len); + int32_t write_len = uart_write_bytes(this->uart_num_, data, len); xSemaphoreGive(this->lock_); + if (write_len != (int32_t) len) { + ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len); + this->mark_failed(); + } #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); @@ -267,6 +281,7 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) { bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { size_t length_to_read = len; + int32_t read_len = 0; if (!this->check_read_timeout_(len)) return false; xSemaphoreTake(this->lock_, portMAX_DELAY); @@ -277,25 +292,31 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { this->has_peek_ = false; } if (length_to_read > 0) - uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); + read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); xSemaphoreGive(this->lock_); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_RX, data[i]); } #endif - return true; + return read_len == (int32_t) length_to_read; } int IDFUARTComponent::available() { - size_t available; + size_t available = 0; + esp_err_t err; xSemaphoreTake(this->lock_, portMAX_DELAY); - uart_get_buffered_data_len(this->uart_num_, &available); - if (this->has_peek_) - available++; + err = uart_get_buffered_data_len(this->uart_num_, &available); xSemaphoreGive(this->lock_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err)); + this->mark_failed(); + } + if (this->has_peek_) { + available++; + } return available; } From c32891ec025f05fd387cabd26cf18413f7881411 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:09:59 -0600 Subject: [PATCH 025/320] Bump github/codeql-action from 4.31.2 to 4.31.3 (#11911) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ab938b3436..2273975328 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 with: category: "/language:${{matrix.language}}" From 1df996601dce60fca1218f272d42b5974644afb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 19:14:07 +0000 Subject: [PATCH 026/320] Bump ruff from 0.14.4 to 0.14.5 (#11910) 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 dab660b03f..b86d00f2aa 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.14.4 + rev: v0.14.5 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 8d4e0ca246..e238faa77e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.3 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.4 # also change in .pre-commit-config.yaml when updating +ruff==0.14.5 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating pre-commit From eb759efb3d6503dd76f8e7e414250775b533b19b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:48:02 +1000 Subject: [PATCH 027/320] [font] Store glyph data in flash only (#11926) --- esphome/components/font/__init__.py | 29 ++++++---------- esphome/components/font/font.cpp | 46 ++++++++++++-------------- esphome/components/font/font.h | 44 ++++++++++++------------ esphome/components/lvgl/font.cpp | 4 +-- esphome/components/lvgl/lvgl_esphome.h | 4 +-- esphome/core/helpers.h | 17 ++++++++++ 6 files changed, 75 insertions(+), 69 deletions(-) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index ddcee14635..32e803f405 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -36,7 +36,6 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.helpers import cpp_string_escape from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,6 @@ font_ns = cg.esphome_ns.namespace("font") Font = font_ns.class_("Font") Glyph = font_ns.class_("Glyph") -GlyphData = font_ns.struct("GlyphData") CONF_BPP = "bpp" CONF_EXTRAS = "extras" @@ -463,7 +461,7 @@ FONT_SCHEMA = cv.Schema( ) ), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(GlyphData), + cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(Glyph), }, ) @@ -583,22 +581,15 @@ async def to_code(config): # Create the glyph table that points to data in the above array. glyph_initializer = [ - cg.StructInitializer( - GlyphData, - ( - "a_char", - cg.RawExpression(f"(const uint8_t *){cpp_string_escape(x.glyph)}"), - ), - ( - "data", - cg.RawExpression(f"{str(prog_arr)} + {str(y - len(x.bitmap_data))}"), - ), - ("advance", x.advance), - ("offset_x", x.offset_x), - ("offset_y", x.offset_y), - ("width", x.width), - ("height", x.height), - ) + [ + x.glyph, + prog_arr + (y - len(x.bitmap_data)), + x.advance, + x.offset_x, + x.offset_y, + x.width, + x.height, + ] for (x, y) in zip( glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args])) ) diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index 8b2420ac07..add403fe98 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -9,20 +9,19 @@ namespace font { static const char *const TAG = "font"; -const uint8_t *Glyph::get_char() const { return this->glyph_data_->a_char; } // Compare the char at the string position with this char. // Return true if this char is less than or equal the other. bool Glyph::compare_to(const uint8_t *str) const { // 1 -> this->char_ // 2 -> str for (uint32_t i = 0;; i++) { - if (this->glyph_data_->a_char[i] == '\0') + if (this->a_char[i] == '\0') return true; if (str[i] == '\0') return false; - if (this->glyph_data_->a_char[i] > str[i]) + if (this->a_char[i] > str[i]) return false; - if (this->glyph_data_->a_char[i] < str[i]) + if (this->a_char[i] < str[i]) return true; } // this should not happen @@ -30,35 +29,32 @@ bool Glyph::compare_to(const uint8_t *str) const { } int Glyph::match_length(const uint8_t *str) const { for (uint32_t i = 0;; i++) { - if (this->glyph_data_->a_char[i] == '\0') + if (this->a_char[i] == '\0') return i; - if (str[i] != this->glyph_data_->a_char[i]) + if (str[i] != this->a_char[i]) return 0; } // this should not happen return 0; } void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const { - *x1 = this->glyph_data_->offset_x; - *y1 = this->glyph_data_->offset_y; - *width = this->glyph_data_->width; - *height = this->glyph_data_->height; + *x1 = this->offset_x; + *y1 = this->offset_y; + *width = this->width; + *height = this->height; } -Font::Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, +Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp) - : baseline_(baseline), + : glyphs_(ConstVector(data, data_nr)), + baseline_(baseline), height_(height), descender_(descender), linegap_(height - baseline - descender), xheight_(xheight), capheight_(capheight), - bpp_(bpp) { - glyphs_.reserve(data_nr); - for (int i = 0; i < data_nr; ++i) - glyphs_.emplace_back(&data[i]); -} -int Font::match_next_glyph(const uint8_t *str, int *match_length) { + bpp_(bpp) {} +int Font::match_next_glyph(const uint8_t *str, int *match_length) const { int lo = 0; int hi = this->glyphs_.size() - 1; while (lo != hi) { @@ -88,18 +84,18 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in if (glyph_n < 0) { // Unknown char, skip if (!this->get_glyphs().empty()) - x += this->get_glyphs()[0].glyph_data_->advance; + x += this->get_glyphs()[0].advance; i++; continue; } const Glyph &glyph = this->glyphs_[glyph_n]; if (!has_char) { - min_x = glyph.glyph_data_->offset_x; + min_x = glyph.offset_x; } else { - min_x = std::min(min_x, x + glyph.glyph_data_->offset_x); + min_x = std::min(min_x, x + glyph.offset_x); } - x += glyph.glyph_data_->advance; + x += glyph.advance; i += match_length; has_char = true; @@ -118,7 +114,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo // Unknown char, skip ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]); if (!this->get_glyphs().empty()) { - uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->advance; + uint8_t glyph_width = this->get_glyphs()[0].advance; display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } @@ -130,7 +126,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo const Glyph &glyph = this->get_glyphs()[glyph_n]; glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height); - const uint8_t *data = glyph.glyph_data_->data; + const uint8_t *data = glyph.data; const int max_x = x_at + scan_x1 + scan_width; const int max_y = y_start + scan_y1 + scan_height; @@ -168,7 +164,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo } } } - x_at += glyph.glyph_data_->advance; + x_at += glyph.advance; i += match_length; } diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 28832d647d..cb6cc89137 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -12,21 +12,19 @@ namespace font { class Font; -struct GlyphData { - const uint8_t *a_char; - const uint8_t *data; - int advance; - int offset_x; - int offset_y; - int width; - int height; -}; - class Glyph { public: - Glyph(const GlyphData *data) : glyph_data_(data) {} + constexpr Glyph(const char *a_char, const uint8_t *data, int advance, int offset_x, int offset_y, int width, + int height) + : a_char(a_char), + data(data), + advance(advance), + offset_x(offset_x), + offset_y(offset_y), + width(width), + height(height) {} - const uint8_t *get_char() const; + const uint8_t *get_char() const { return reinterpret_cast(this->a_char); } bool compare_to(const uint8_t *str) const; @@ -34,12 +32,16 @@ class Glyph { void scan_area(int *x1, int *y1, int *width, int *height) const; - const GlyphData *get_glyph_data() const { return this->glyph_data_; } + const char *a_char; + const uint8_t *data; + int advance; + int offset_x; + int offset_y; + int width; + int height; protected: friend Font; - - const GlyphData *glyph_data_; }; class Font @@ -50,8 +52,8 @@ class Font public: /** Construct the font with the given glyphs. * - * @param data A vector of glyphs, must be sorted lexicographically. - * @param data_nr The number of glyphs in data. + * @param data A list of glyphs, must be sorted lexicographically. + * @param data_nr The number of glyphs * @param baseline The y-offset from the top of the text to the baseline. * @param height The y-offset from the top of the text to the bottom. * @param descender The y-offset from the baseline to the lowest stroke in the font (e.g. from letters like g or p). @@ -59,10 +61,10 @@ class Font * @param capheight The height of capital letters, usually measured at the "X" glyph. * @param bpp The bits per pixel used for this font. Used to read data out of the glyph bitmaps. */ - Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, + Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp = 1); - int match_next_glyph(const uint8_t *str, int *match_length); + int match_next_glyph(const uint8_t *str, int *match_length) const; #ifdef USE_DISPLAY void print(int x_start, int y_start, display::Display *display, Color color, const char *text, @@ -78,10 +80,10 @@ class Font inline int get_capheight() { return this->capheight_; } inline int get_bpp() { return this->bpp_; } - const std::vector> &get_glyphs() const { return glyphs_; } + const ConstVector &get_glyphs() const { return glyphs_; } protected: - std::vector> glyphs_; + ConstVector glyphs_; int baseline_; int height_; int descender_; diff --git a/esphome/components/lvgl/font.cpp b/esphome/components/lvgl/font.cpp index a0d5127570..1976fb9608 100644 --- a/esphome/components/lvgl/font.cpp +++ b/esphome/components/lvgl/font.cpp @@ -43,7 +43,7 @@ FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) { const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; } -const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) { +const font::Glyph *FontEngine::get_glyph_data(uint32_t unicode_letter) { if (unicode_letter == last_letter_) return this->last_data_; uint8_t unicode[5]; @@ -67,7 +67,7 @@ const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) { int glyph_n = this->font_->match_next_glyph(unicode, &match_length); if (glyph_n < 0) return nullptr; - this->last_data_ = this->font_->get_glyphs()[glyph_n].get_glyph_data(); + this->last_data_ = &this->font_->get_glyphs()[glyph_n]; this->last_letter_ = unicode_letter; return this->last_data_; } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 1ae05f933f..196a0d1cb4 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -140,7 +140,7 @@ class FontEngine { FontEngine(font::Font *esp_font); const lv_font_t *get_lv_font(); - const font::GlyphData *get_glyph_data(uint32_t unicode_letter); + const font::Glyph *get_glyph_data(uint32_t unicode_letter); uint16_t baseline{}; uint16_t height{}; uint8_t bpp{}; @@ -148,7 +148,7 @@ class FontEngine { protected: font::Font *font_{}; uint32_t last_letter_{}; - const font::GlyphData *last_data_{}; + const font::Glyph *last_data_{}; lv_font_t lv_font_{}; }; #endif // USE_LVGL_FONT diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 52a0746057..16eab8b8f6 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -111,6 +111,23 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); /// @name Container utilities ///@{ +/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory) +/// Avoids copying data from flash to RAM by keeping a pointer to the flash data. +/// Similar to std::span but with minimal overhead for embedded systems. + +template class ConstVector { + public: + constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} + + const constexpr T &operator[](size_t i) const { return data_[i]; } + constexpr size_t size() const { return size_; } + constexpr bool empty() const { return size_ == 0; } + + protected: + const T *data_; + size_t size_; +}; + /// Minimal static vector - saves memory by avoiding std::vector overhead template class StaticVector { public: From 5710cab972485a60af1488a62b71dca775002fb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:03:43 -0600 Subject: [PATCH 028/320] [ld2412] Fix stuck targets by adding timeout filter (#11919) --- esphome/components/ld2412/sensor.py | 76 ++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index abb823faad..0bfbd9bf1d 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -31,36 +31,84 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_LIGHTBULB, unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor ), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -74,7 +122,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, @@ -82,7 +136,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, From 6b158e760d379033b811cd7bf73be0f95b33a09c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:04:25 -0600 Subject: [PATCH 029/320] [ld2410] Add timeout filter to prevent stuck targets (#11920) --- esphome/components/ld2410/sensor.py | 76 ++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index fca2b2ceca..3bd34963bc 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -31,35 +31,83 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_LIGHTBULB, ), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[{"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}], + filters=[ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, + ], icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), @@ -73,7 +121,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, @@ -81,7 +135,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, filters=[ - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)} + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, ], icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, From fc546ca3f6b63aaa43e208fe472c12eaa9c194b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:20:57 -0600 Subject: [PATCH 030/320] [scheduler] Fix timing breakage after 49 days of uptime on ESP8266/RP2040 (#11924) --- esphome/core/scheduler.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d285af2d0e..d2e0f0dab4 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -609,13 +609,12 @@ uint64_t Scheduler::millis_64_(uint32_t now) { if (now < last && (last - now) > HALF_MAX_UINT32) { this->millis_major_++; major++; + this->last_millis_ = now; #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ - } - - // Only update if time moved forward - if (now > last) { + } else if (now > last) { + // Only update if time moved forward this->last_millis_ = now; } From ea2b4c3e2500a21d01d28a255a755c34391e237a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:21:06 -0600 Subject: [PATCH 031/320] [binary_sensor] Modernize to C++17 nested namespaces and remove redundant qualifications (#11929) --- .../components/binary_sensor/automation.cpp | 18 ++++++++---------- esphome/components/binary_sensor/automation.h | 6 ++---- .../components/binary_sensor/binary_sensor.cpp | 8 ++------ .../components/binary_sensor/binary_sensor.h | 7 ++----- esphome/components/binary_sensor/filter.cpp | 8 ++------ esphome/components/binary_sensor/filter.h | 8 ++------ 6 files changed, 18 insertions(+), 37 deletions(-) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index 64a0d3db8d..66d8d6e90f 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -1,12 +1,11 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace binary_sensor { +namespace esphome::binary_sensor { static const char *const TAG = "binary_sensor.automation"; -void binary_sensor::MultiClickTrigger::on_state_(bool state) { +void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -67,7 +66,7 @@ void binary_sensor::MultiClickTrigger::on_state_(bool state) { *this->at_index_ = *this->at_index_ + 1; } -void binary_sensor::MultiClickTrigger::schedule_cooldown_() { +void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout("cooldown", this->invalid_cooldown_, [this]() { @@ -79,7 +78,7 @@ void binary_sensor::MultiClickTrigger::schedule_cooldown_() { this->cancel_timeout("is_valid"); this->cancel_timeout("is_not_valid"); } -void binary_sensor::MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { +void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -90,19 +89,19 @@ void binary_sensor::MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void binary_sensor::MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout("is_not_valid", max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = false; this->schedule_cooldown_(); }); } -void binary_sensor::MultiClickTrigger::cancel() { +void MultiClickTrigger::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void binary_sensor::MultiClickTrigger::trigger_() { +void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); this->at_index_.reset(); this->cancel_timeout("trigger"); @@ -118,5 +117,4 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { return length >= min_length && length <= max_length; } } -} // namespace binary_sensor -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index f6971a2fc4..f8b130e08a 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -9,8 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace binary_sensor { +namespace esphome::binary_sensor { struct MultiClickTriggerEvent { bool state; @@ -172,5 +171,4 @@ template class BinarySensorInvalidateAction : public Action filters) { } bool BinarySensor::is_status_binary_sensor() const { return false; } -} // namespace binary_sensor - -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index c1661d710f..0dca3e1520 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -6,9 +6,7 @@ #include -namespace esphome { - -namespace binary_sensor { +namespace esphome::binary_sensor { class BinarySensor; void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj); @@ -70,5 +68,4 @@ class BinarySensorInitiallyOff : public BinarySensor { bool has_state() const override { return true; } }; -} // namespace binary_sensor -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 8f31cf6fc2..9c7238f6d7 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -2,9 +2,7 @@ #include "binary_sensor.h" -namespace esphome { - -namespace binary_sensor { +namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; @@ -132,6 +130,4 @@ optional SettleFilter::new_value(bool value) { float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace binary_sensor - -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 2d473c3b64..59bc43eeba 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -4,9 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { - -namespace binary_sensor { +namespace esphome::binary_sensor { class BinarySensor; @@ -139,6 +137,4 @@ class SettleFilter : public Filter, public Component { bool steady_{true}; }; -} // namespace binary_sensor - -} // namespace esphome +} // namespace esphome::binary_sensor From 6f4042f401a27e8f6c46fa4b52f0e4f3c406be84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Nov 2025 22:21:38 -0600 Subject: [PATCH 032/320] Add tests for sensor timeout filters (#11923) --- .../fixtures/sensor_timeout_filter.yaml | 150 ++++++++++++++ .../integration/test_sensor_timeout_filter.py | 185 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 tests/integration/fixtures/sensor_timeout_filter.yaml create mode 100644 tests/integration/test_sensor_timeout_filter.py diff --git a/tests/integration/fixtures/sensor_timeout_filter.yaml b/tests/integration/fixtures/sensor_timeout_filter.yaml new file mode 100644 index 0000000000..dbd4db3242 --- /dev/null +++ b/tests/integration/fixtures/sensor_timeout_filter.yaml @@ -0,0 +1,150 @@ +esphome: + name: test-timeout-filters + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Template sensors that we'll use to publish values +sensor: + - platform: template + name: "Source Timeout Last" + id: source_timeout_last + accuracy_decimals: 1 + + - platform: template + name: "Source Timeout Reset" + id: source_timeout_reset + accuracy_decimals: 1 + + - platform: template + name: "Source Timeout Static" + id: source_timeout_static + accuracy_decimals: 1 + + - platform: template + name: "Source Timeout Lambda" + id: source_timeout_lambda + accuracy_decimals: 1 + + # Test 1: TimeoutFilter - "last" mode (outputs last received value) + - platform: copy + source_id: source_timeout_last + name: "Timeout Last Sensor" + id: timeout_last_sensor + filters: + - timeout: + timeout: 100ms + value: last # Explicitly specify "last" mode to use TimeoutFilter class + + # Test 2: TimeoutFilter - reset behavior (same filter, different source) + - platform: copy + source_id: source_timeout_reset + name: "Timeout Reset Sensor" + id: timeout_reset_sensor + filters: + - timeout: + timeout: 100ms + value: last # Explicitly specify "last" mode + + # Test 3: TimeoutFilterConfigured - static value mode + - platform: copy + source_id: source_timeout_static + name: "Timeout Static Sensor" + id: timeout_static_sensor + filters: + - timeout: + timeout: 100ms + value: 99.9 + + # Test 4: TimeoutFilterConfigured - lambda mode + - platform: copy + source_id: source_timeout_lambda + name: "Timeout Lambda Sensor" + id: timeout_lambda_sensor + filters: + - timeout: + timeout: 100ms + value: !lambda "return -1.0;" + +# Scripts to publish values with controlled timing +script: + # Test 1: Single value followed by timeout + - id: test_timeout_last_script + then: + # Publish initial value + - sensor.template.publish: + id: source_timeout_last + state: 42.0 + # Wait for timeout to fire (100ms + margin) + - delay: 150ms + + # Test 2: Multiple values before timeout (should reset timer) + - id: test_timeout_reset_script + then: + # Publish first value + - sensor.template.publish: + id: source_timeout_reset + state: 10.0 + # Wait 50ms (halfway to timeout) + - delay: 50ms + # Publish second value (resets timeout) + - sensor.template.publish: + id: source_timeout_reset + state: 20.0 + # Wait 50ms (halfway to timeout again) + - delay: 50ms + # Publish third value (resets timeout) + - sensor.template.publish: + id: source_timeout_reset + state: 30.0 + # Wait for timeout to fire (100ms + margin) + - delay: 150ms + + # Test 3: Static value timeout + - id: test_timeout_static_script + then: + # Publish initial value + - sensor.template.publish: + id: source_timeout_static + state: 55.5 + # Wait for timeout to fire + - delay: 150ms + + # Test 4: Lambda value timeout + - id: test_timeout_lambda_script + then: + # Publish initial value + - sensor.template.publish: + id: source_timeout_lambda + state: 77.7 + # Wait for timeout to fire + - delay: 150ms + +# Buttons to trigger each test scenario +button: + - platform: template + name: "Test Timeout Last Button" + id: test_timeout_last_button + on_press: + - script.execute: test_timeout_last_script + + - platform: template + name: "Test Timeout Reset Button" + id: test_timeout_reset_button + on_press: + - script.execute: test_timeout_reset_script + + - platform: template + name: "Test Timeout Static Button" + id: test_timeout_static_button + on_press: + - script.execute: test_timeout_static_script + + - platform: template + name: "Test Timeout Lambda Button" + id: test_timeout_lambda_button + on_press: + - script.execute: test_timeout_lambda_script diff --git a/tests/integration/test_sensor_timeout_filter.py b/tests/integration/test_sensor_timeout_filter.py new file mode 100644 index 0000000000..9b4704bb7b --- /dev/null +++ b/tests/integration/test_sensor_timeout_filter.py @@ -0,0 +1,185 @@ +"""Test sensor timeout filter functionality.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, SensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_sensor_timeout_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test TimeoutFilter and TimeoutFilterConfigured with all modes.""" + loop = asyncio.get_running_loop() + + # Track state changes for all sensors + timeout_last_states: list[float] = [] + timeout_reset_states: list[float] = [] + timeout_static_states: list[float] = [] + timeout_lambda_states: list[float] = [] + + # Futures for each test scenario + test1_complete = loop.create_future() # TimeoutFilter - last mode + test2_complete = loop.create_future() # TimeoutFilter - reset behavior + test3_complete = loop.create_future() # TimeoutFilterConfigured - static value + test4_complete = loop.create_future() # TimeoutFilterConfigured - lambda + + def on_state(state: EntityState) -> None: + """Track sensor state updates.""" + if not isinstance(state, SensorState): + return + + if state.missing_state: + return + + sensor_name = key_to_sensor.get(state.key) + + # Test 1: TimeoutFilter - last mode + if sensor_name == "timeout_last_sensor": + timeout_last_states.append(state.state) + # Expect 2 values: initial 42.0 + timeout fires with 42.0 + if len(timeout_last_states) >= 2 and not test1_complete.done(): + test1_complete.set_result(True) + + # Test 2: TimeoutFilter - reset behavior + elif sensor_name == "timeout_reset_sensor": + timeout_reset_states.append(state.state) + # Expect 4 values: 10.0, 20.0, 30.0, then timeout fires with 30.0 + if len(timeout_reset_states) >= 4 and not test2_complete.done(): + test2_complete.set_result(True) + + # Test 3: TimeoutFilterConfigured - static value + elif sensor_name == "timeout_static_sensor": + timeout_static_states.append(state.state) + # Expect 2 values: initial 55.5 + timeout fires with 99.9 + if len(timeout_static_states) >= 2 and not test3_complete.done(): + test3_complete.set_result(True) + + # Test 4: TimeoutFilterConfigured - lambda + elif sensor_name == "timeout_lambda_sensor": + timeout_lambda_states.append(state.state) + # Expect 2 values: initial 77.7 + timeout fires with -1.0 + if len(timeout_lambda_states) >= 2 and not test4_complete.done(): + test4_complete.set_result(True) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + + key_to_sensor = build_key_to_entity_mapping( + entities, + [ + "timeout_last_sensor", + "timeout_reset_sensor", + "timeout_static_sensor", + "timeout_lambda_sensor", + ], + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Helper to find buttons by object_id substring + def find_button(object_id_substring: str) -> int: + """Find a button by object_id substring and return its key.""" + button = next( + (e for e in entities if object_id_substring in e.object_id.lower()), + None, + ) + assert button is not None, f"Button '{object_id_substring}' not found" + return button.key + + # Find all test buttons + test1_button_key = find_button("test_timeout_last_button") + test2_button_key = find_button("test_timeout_reset_button") + test3_button_key = find_button("test_timeout_static_button") + test4_button_key = find_button("test_timeout_lambda_button") + + # === Test 1: TimeoutFilter - last mode === + client.button_command(test1_button_key) + try: + await asyncio.wait_for(test1_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 1 timeout. Received states: {timeout_last_states}") + + assert len(timeout_last_states) == 2, ( + f"Test 1: Should have 2 states, got {len(timeout_last_states)}: {timeout_last_states}" + ) + assert timeout_last_states[0] == pytest.approx(42.0), ( + f"Test 1: First state should be 42.0, got {timeout_last_states[0]}" + ) + assert timeout_last_states[1] == pytest.approx(42.0), ( + f"Test 1: Timeout should output last value (42.0), got {timeout_last_states[1]}" + ) + + # === Test 2: TimeoutFilter - reset behavior === + client.button_command(test2_button_key) + try: + await asyncio.wait_for(test2_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 2 timeout. Received states: {timeout_reset_states}") + + assert len(timeout_reset_states) == 4, ( + f"Test 2: Should have 4 states, got {len(timeout_reset_states)}: {timeout_reset_states}" + ) + assert timeout_reset_states[0] == pytest.approx(10.0), ( + f"Test 2: First state should be 10.0, got {timeout_reset_states[0]}" + ) + assert timeout_reset_states[1] == pytest.approx(20.0), ( + f"Test 2: Second state should be 20.0, got {timeout_reset_states[1]}" + ) + assert timeout_reset_states[2] == pytest.approx(30.0), ( + f"Test 2: Third state should be 30.0, got {timeout_reset_states[2]}" + ) + assert timeout_reset_states[3] == pytest.approx(30.0), ( + f"Test 2: Timeout should output last value (30.0), got {timeout_reset_states[3]}" + ) + + # === Test 3: TimeoutFilterConfigured - static value === + client.button_command(test3_button_key) + try: + await asyncio.wait_for(test3_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 3 timeout. Received states: {timeout_static_states}") + + assert len(timeout_static_states) == 2, ( + f"Test 3: Should have 2 states, got {len(timeout_static_states)}: {timeout_static_states}" + ) + assert timeout_static_states[0] == pytest.approx(55.5), ( + f"Test 3: First state should be 55.5, got {timeout_static_states[0]}" + ) + assert timeout_static_states[1] == pytest.approx(99.9), ( + f"Test 3: Timeout should output configured value (99.9), got {timeout_static_states[1]}" + ) + + # === Test 4: TimeoutFilterConfigured - lambda === + client.button_command(test4_button_key) + try: + await asyncio.wait_for(test4_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 4 timeout. Received states: {timeout_lambda_states}") + + assert len(timeout_lambda_states) == 2, ( + f"Test 4: Should have 2 states, got {len(timeout_lambda_states)}: {timeout_lambda_states}" + ) + assert timeout_lambda_states[0] == pytest.approx(77.7), ( + f"Test 4: First state should be 77.7, got {timeout_lambda_states[0]}" + ) + assert timeout_lambda_states[1] == pytest.approx(-1.0), ( + f"Test 4: Timeout should evaluate lambda (-1.0), got {timeout_lambda_states[1]}" + ) From 4fc4da6ed2d69d9cdb47d777abc24e0e5e0c29c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 07:35:31 -0600 Subject: [PATCH 033/320] [analyze-memory] Show all core symbols > 100 B instead of top 15 (#11909) --- esphome/analyze_memory/cli.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 718f42330d..44ade221f8 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -15,6 +15,11 @@ from . import ( class MemoryAnalyzerCLI(MemoryAnalyzer): """Memory analyzer with CLI-specific report generation.""" + # Symbol size threshold for detailed analysis + SYMBOL_SIZE_THRESHOLD: int = ( + 100 # Show symbols larger than this in detailed analysis + ) + # Column width constants COL_COMPONENT: int = 29 COL_FLASH_TEXT: int = 14 @@ -191,14 +196,21 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): f"{len(symbols):>{self.COL_CORE_COUNT}} | {percentage:>{self.COL_CORE_PERCENT - 1}.1f}%" ) - # Top 15 largest core symbols + # All core symbols above threshold lines.append("") - lines.append(f"Top 15 Largest {_COMPONENT_CORE} Symbols:") sorted_core_symbols = sorted( self._esphome_core_symbols, key=lambda x: x[2], reverse=True ) + large_core_symbols = [ + (symbol, demangled, size) + for symbol, demangled, size in sorted_core_symbols + if size > self.SYMBOL_SIZE_THRESHOLD + ] - for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:15]): + lines.append( + f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" + ) + for i, (symbol, demangled, size) in enumerate(large_core_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append("=" * self.TABLE_WIDTH) @@ -268,13 +280,15 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append("") - # Show all symbols > 100 bytes for better visibility + # Show all symbols above threshold for better visibility large_symbols = [ - (sym, dem, size) for sym, dem, size in sorted_symbols if size > 100 + (sym, dem, size) + for sym, dem, size in sorted_symbols + if size > self.SYMBOL_SIZE_THRESHOLD ] lines.append( - f"{comp_name} Symbols > 100 B ({len(large_symbols)} symbols):" + f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):" ) for i, (symbol, demangled, size) in enumerate(large_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") From 320120883cab070fa21ba187e13f0f3c6447c3b8 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 17 Nov 2025 07:47:54 +1000 Subject: [PATCH 034/320] [lvgl] Migrate lv_font creation into Font class and optimise (#11915) --- esphome/components/font/__init__.py | 2 + esphome/components/font/font.cpp | 263 ++++++++++++++++------- esphome/components/font/font.h | 33 +-- esphome/components/lvgl/__init__.py | 14 +- esphome/components/lvgl/font.cpp | 76 ------- esphome/components/lvgl/lv_validation.py | 5 +- esphome/components/lvgl/lvgl_esphome.h | 26 +-- esphome/components/lvgl/types.py | 1 - tests/components/lvgl/lvgl-package.yaml | 4 +- tests/components/lvgl/test.host.yaml | 6 + 10 files changed, 232 insertions(+), 198 deletions(-) delete mode 100644 esphome/components/lvgl/font.cpp diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 32e803f405..2667dbdbdf 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -486,6 +486,8 @@ class GlyphInfo: def glyph_to_glyphinfo(glyph, font, size, bpp): + # Convert to 32 bit unicode codepoint + glyph = ord(glyph) scale = 256 // (1 << bpp) if not font.is_scalable: sizes = [pt_to_px(x.size) for x in font.available_sizes] diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index add403fe98..5e3bf1dd20 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -6,42 +6,147 @@ namespace esphome { namespace font { - static const char *const TAG = "font"; -// Compare the char at the string position with this char. -// Return true if this char is less than or equal the other. -bool Glyph::compare_to(const uint8_t *str) const { - // 1 -> this->char_ - // 2 -> str - for (uint32_t i = 0;; i++) { - if (this->a_char[i] == '\0') - return true; - if (str[i] == '\0') - return false; - if (this->a_char[i] > str[i]) - return false; - if (this->a_char[i] < str[i]) - return true; +#ifdef USE_LVGL_FONT +const uint8_t *Font::get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { + auto *fe = (Font *) font->dsc; + const auto *gd = fe->get_glyph_data_(unicode_letter); + if (gd == nullptr) { + return nullptr; } - // this should not happen - return false; + return gd->data; } -int Glyph::match_length(const uint8_t *str) const { - for (uint32_t i = 0;; i++) { - if (this->a_char[i] == '\0') - return i; - if (str[i] != this->a_char[i]) + +bool Font::get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { + auto *fe = (Font *) font->dsc; + const auto *gd = fe->get_glyph_data_(unicode_letter); + if (gd == nullptr) { + return false; + } + dsc->adv_w = gd->advance; + dsc->ofs_x = gd->offset_x; + dsc->ofs_y = fe->height_ - gd->height - gd->offset_y - fe->lv_font_.base_line; + dsc->box_w = gd->width; + dsc->box_h = gd->height; + dsc->is_placeholder = 0; + dsc->bpp = fe->get_bpp(); + return true; +} + +const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) { + if (unicode_letter == this->last_letter_ && this->last_letter_ != 0) + return this->last_data_; + auto *glyph = this->find_glyph(unicode_letter); + if (glyph == nullptr) { + return nullptr; + } + this->last_data_ = glyph; + this->last_letter_ = unicode_letter; + return glyph; +} +#endif + +/** + * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. + * If successful, return the codepoint and set the length to the number of bytes read. + * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to + * 0. + * + * @param utf8_str The input string + * @param length Pointer to length storage + * @return The extracted code point + */ +static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { + // Safely cast to uint8_t* for correct bitwise operations on bytes + const uint8_t *current = reinterpret_cast(utf8_str); + uint32_t code_point = 0; + uint8_t c1 = *current++; + + // check for end of string + if (c1 == 0) { + *length = 0; + return 0; + } + + // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- + if (c1 < 0x80) { + // Valid ASCII byte. + code_point = c1; + // Optimization: No need to check for continuation bytes. + } + // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- + else if ((c1 & 0xE0) == 0xC0) { + uint8_t c2 = *current++; + + // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) + if ((c2 & 0xC0) != 0x80) { + *length = 0; return 0; + } + + code_point = (c1 & 0x1F) << 6; + code_point |= (c2 & 0x3F); + + // Error Check 2: Overlong check (2-byte must be > 0x7F) + if (code_point <= 0x7F) { + *length = 0; + return 0; + } } - // this should not happen - return 0; -} -void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const { - *x1 = this->offset_x; - *y1 = this->offset_y; - *width = this->width; - *height = this->height; + // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF0) == 0xE0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x0F) << 12; + code_point |= (c2 & 0x3F) << 6; + code_point |= (c3 & 0x3F); + + // Error Check 2: Overlong check (3-byte must be > 0x7FF) + // Also check for surrogates (0xD800-0xDFFF) + if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { + *length = 0; + return 0; + } + } + // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF8) == 0xF0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + uint8_t c4 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x07) << 18; + code_point |= (c2 & 0x3F) << 12; + code_point |= (c3 & 0x3F) << 6; + code_point |= (c4 & 0x3F); + + // Error Check 2: Overlong check (4-byte must be > 0xFFFF) + // Also check for valid Unicode range (must be <= 0x10FFFF) + if (code_point <= 0xFFFF || code_point > 0x10FFFF) { + *length = 0; + return 0; + } + } + // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- + else { + *length = 0; + return 0; + } + *length = current - reinterpret_cast(utf8_str); + return code_point; } Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, @@ -53,82 +158,93 @@ Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descend linegap_(height - baseline - descender), xheight_(xheight), capheight_(capheight), - bpp_(bpp) {} -int Font::match_next_glyph(const uint8_t *str, int *match_length) const { + bpp_(bpp) { +#ifdef USE_LVGL_FONT + this->lv_font_.dsc = this; + this->lv_font_.line_height = this->get_height(); + this->lv_font_.base_line = this->lv_font_.line_height - this->get_baseline(); + this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; + this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; + this->lv_font_.subpx = LV_FONT_SUBPX_NONE; + this->lv_font_.underline_position = -1; + this->lv_font_.underline_thickness = 1; +#endif +} + +const Glyph *Font::find_glyph(uint32_t codepoint) const { int lo = 0; int hi = this->glyphs_.size() - 1; while (lo != hi) { int mid = (lo + hi + 1) / 2; - if (this->glyphs_[mid].compare_to(str)) { + if (this->glyphs_[mid].is_less_or_equal(codepoint)) { lo = mid; } else { hi = mid - 1; } } - *match_length = this->glyphs_[lo].match_length(str); - if (*match_length <= 0) - return -1; - return lo; + auto *result = &this->glyphs_[lo]; + if (result->code_point == codepoint) + return result; + return nullptr; } + #ifdef USE_DISPLAY void Font::measure(const char *str, int *width, int *x_offset, int *baseline, int *height) { *baseline = this->baseline_; *height = this->height_; - int i = 0; int min_x = 0; bool has_char = false; int x = 0; - while (str[i] != '\0') { - int match_length; - int glyph_n = this->match_next_glyph((const uint8_t *) str + i, &match_length); - if (glyph_n < 0) { + for (;;) { + size_t length; + auto code_point = extract_unicode_codepoint(str, &length); + if (length == 0) + break; + str += length; + auto *glyph = this->find_glyph(code_point); + if (glyph == nullptr) { // Unknown char, skip - if (!this->get_glyphs().empty()) - x += this->get_glyphs()[0].advance; - i++; + if (!this->glyphs_.empty()) + x += this->glyphs_[0].advance; continue; } - const Glyph &glyph = this->glyphs_[glyph_n]; if (!has_char) { - min_x = glyph.offset_x; + min_x = glyph->offset_x; } else { - min_x = std::min(min_x, x + glyph.offset_x); + min_x = std::min(min_x, x + glyph->offset_x); } - x += glyph.advance; + x += glyph->advance; - i += match_length; has_char = true; } *x_offset = min_x; *width = x - min_x; } + void Font::print(int x_start, int y_start, display::Display *display, Color color, const char *text, Color background) { - int i = 0; int x_at = x_start; - int scan_x1, scan_y1, scan_width, scan_height; - while (text[i] != '\0') { - int match_length; - int glyph_n = this->match_next_glyph((const uint8_t *) text + i, &match_length); - if (glyph_n < 0) { + for (;;) { + size_t length; + auto code_point = extract_unicode_codepoint(text, &length); + if (length == 0) + break; + text += length; + auto *glyph = this->find_glyph(code_point); + if (glyph == nullptr) { // Unknown char, skip - ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]); - if (!this->get_glyphs().empty()) { - uint8_t glyph_width = this->get_glyphs()[0].advance; - display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color); + ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point); + if (!this->glyphs_.empty()) { + uint8_t glyph_width = this->glyphs_[0].advance; + display->rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } - - i++; continue; } - const Glyph &glyph = this->get_glyphs()[glyph_n]; - glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height); - - const uint8_t *data = glyph.data; - const int max_x = x_at + scan_x1 + scan_width; - const int max_y = y_start + scan_y1 + scan_height; + const uint8_t *data = glyph->data; + const int max_x = x_at + glyph->offset_x + glyph->width; + const int max_y = y_start + glyph->offset_y + glyph->height; uint8_t bitmask = 0; uint8_t pixel_data = 0; @@ -141,10 +257,10 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo auto b_g = (float) background.g; auto b_b = (float) background.b; auto b_w = (float) background.w; - for (int glyph_y = y_start + scan_y1; glyph_y != max_y; glyph_y++) { - for (int glyph_x = x_at + scan_x1; glyph_x != max_x; glyph_x++) { + for (int glyph_y = y_start + glyph->offset_y; glyph_y != max_y; glyph_y++) { + for (int glyph_x = x_at + glyph->offset_x; glyph_x != max_x; glyph_x++) { uint8_t pixel = 0; - for (int bit_num = 0; bit_num != this->bpp_; bit_num++) { + for (uint8_t bit_num = 0; bit_num != this->bpp_; bit_num++) { if (bitmask == 0) { pixel_data = progmem_read_byte(data++); bitmask = 0x80; @@ -164,12 +280,9 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo } } } - x_at += glyph.advance; - - i += match_length; + x_at += glyph->advance; } } #endif - } // namespace font } // namespace esphome diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index cb6cc89137..262ded3be4 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -6,6 +6,9 @@ #ifdef USE_DISPLAY #include "esphome/components/display/display.h" #endif +#ifdef USE_LVGL_FONT +#include +#endif namespace esphome { namespace font { @@ -14,9 +17,9 @@ class Font; class Glyph { public: - constexpr Glyph(const char *a_char, const uint8_t *data, int advance, int offset_x, int offset_y, int width, + constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, int height) - : a_char(a_char), + : code_point(code_point), data(data), advance(advance), offset_x(offset_x), @@ -24,24 +27,15 @@ class Glyph { width(width), height(height) {} - const uint8_t *get_char() const { return reinterpret_cast(this->a_char); } + bool is_less_or_equal(uint32_t other) const { return this->code_point <= other; } - bool compare_to(const uint8_t *str) const; - - int match_length(const uint8_t *str) const; - - void scan_area(int *x1, int *y1, int *width, int *height) const; - - const char *a_char; + const uint32_t code_point; const uint8_t *data; int advance; int offset_x; int offset_y; int width; int height; - - protected: - friend Font; }; class Font @@ -64,7 +58,7 @@ class Font Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp = 1); - int match_next_glyph(const uint8_t *str, int *match_length) const; + const Glyph *find_glyph(uint32_t codepoint) const; #ifdef USE_DISPLAY void print(int x_start, int y_start, display::Display *display, Color color, const char *text, @@ -79,6 +73,9 @@ class Font inline int get_xheight() { return this->xheight_; } inline int get_capheight() { return this->capheight_; } inline int get_bpp() { return this->bpp_; } +#ifdef USE_LVGL_FONT + const lv_font_t *get_lv_font() const { return &this->lv_font_; } +#endif const ConstVector &get_glyphs() const { return glyphs_; } @@ -91,6 +88,14 @@ class Font int xheight_; int capheight_; uint8_t bpp_; // bits per pixel +#ifdef USE_LVGL_FONT + lv_font_t lv_font_{}; + static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter); + static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next); + const Glyph *get_glyph_data_(uint32_t unicode_letter); + uint32_t last_letter_{}; + const Glyph *last_data_{}; +#endif }; } // namespace font diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2a24f343c3..eaa37b54dd 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,15 +52,7 @@ from .schemas import ( from .styles import add_top_layer, styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code from .trigger import add_on_boot_triggers, generate_triggers -from .types import ( - FontEngine, - IdleTrigger, - PlainTrigger, - lv_font_t, - lv_group_t, - lv_style_t, - lvgl_ns, -) +from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns from .widgets import ( LvScrActType, Widget, @@ -244,7 +236,6 @@ async def to_code(configs): cg.add_global(lvgl_ns.using) for font in helpers.esphome_fonts_used: await cg.get_variable(font) - cg.new_Pvariable(ID(f"{font}_engine", True, type=FontEngine), MockObj(font)) default_font = config_0[df.CONF_DEFAULT_FONT] if not lvalid.is_lv_font(default_font): add_define( @@ -256,7 +247,8 @@ async def to_code(configs): type=lv_font_t.operator("ptr").operator("const"), ) cg.new_variable( - globfont_id, MockObj(await lvalid.lv_font.process(default_font)) + globfont_id, + MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(), ) add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) else: diff --git a/esphome/components/lvgl/font.cpp b/esphome/components/lvgl/font.cpp deleted file mode 100644 index 1976fb9608..0000000000 --- a/esphome/components/lvgl/font.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include "lvgl_esphome.h" - -#ifdef USE_LVGL_FONT -namespace esphome { -namespace lvgl { - -static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { - auto *fe = (FontEngine *) font->dsc; - const auto *gd = fe->get_glyph_data(unicode_letter); - if (gd == nullptr) - return nullptr; - // esph_log_d(TAG, "Returning bitmap @ %X", (uint32_t)gd->data); - - return gd->data; -} - -static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { - auto *fe = (FontEngine *) font->dsc; - const auto *gd = fe->get_glyph_data(unicode_letter); - if (gd == nullptr) - return false; - dsc->adv_w = gd->advance; - dsc->ofs_x = gd->offset_x; - dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline; - dsc->box_w = gd->width; - dsc->box_h = gd->height; - dsc->is_placeholder = 0; - dsc->bpp = fe->bpp; - return true; -} - -FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) { - this->bpp = esp_font->get_bpp(); - this->lv_font_.dsc = this; - this->lv_font_.line_height = this->height = esp_font->get_height(); - this->lv_font_.base_line = this->baseline = this->lv_font_.line_height - esp_font->get_baseline(); - this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; - this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; - this->lv_font_.subpx = LV_FONT_SUBPX_NONE; - this->lv_font_.underline_position = -1; - this->lv_font_.underline_thickness = 1; -} - -const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; } - -const font::Glyph *FontEngine::get_glyph_data(uint32_t unicode_letter) { - if (unicode_letter == last_letter_) - return this->last_data_; - uint8_t unicode[5]; - memset(unicode, 0, sizeof unicode); - if (unicode_letter > 0xFFFF) { - unicode[0] = 0xF0 + ((unicode_letter >> 18) & 0x7); - unicode[1] = 0x80 + ((unicode_letter >> 12) & 0x3F); - unicode[2] = 0x80 + ((unicode_letter >> 6) & 0x3F); - unicode[3] = 0x80 + (unicode_letter & 0x3F); - } else if (unicode_letter > 0x7FF) { - unicode[0] = 0xE0 + ((unicode_letter >> 12) & 0xF); - unicode[1] = 0x80 + ((unicode_letter >> 6) & 0x3F); - unicode[2] = 0x80 + (unicode_letter & 0x3F); - } else if (unicode_letter > 0x7F) { - unicode[0] = 0xC0 + ((unicode_letter >> 6) & 0x1F); - unicode[1] = 0x80 + (unicode_letter & 0x3F); - } else { - unicode[0] = unicode_letter; - } - int match_length; - int glyph_n = this->font_->match_next_glyph(unicode, &match_length); - if (glyph_n < 0) - return nullptr; - this->last_data_ = &this->font_->get_glyphs()[glyph_n]; - this->last_letter_ = unicode_letter; - return this->last_data_; -} -} // namespace lvgl -} // namespace esphome -#endif // USES_LVGL_FONT diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 045258555c..23c322c31f 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -493,6 +493,7 @@ class LvFont(LValidator): return LV_FONTS if is_lv_font(value): return lv_builtin_font(value) + add_lv_use("font") fontval = cv.use_id(Font)(value) esphome_fonts_used.add(fontval) return requires_component("font")(fontval) @@ -502,7 +503,9 @@ class LvFont(LValidator): async def process(self, value, args=()): if is_lv_font(value): return literal(f"&lv_font_{value}") - return literal(f"{value}_engine->get_lv_font()") + if isinstance(value, str): + return literal(f"{value}") + return await super().process(value, args) lv_font = LvFont() diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 196a0d1cb4..bd6f1fdb61 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -50,6 +50,14 @@ static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BIT static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332; #endif // LV_COLOR_DEPTH +#ifdef USE_LVGL_FONT +inline void lv_obj_set_style_text_font(lv_obj_t *obj, const font::Font *font, lv_style_selector_t part) { + lv_obj_set_style_text_font(obj, font->get_lv_font(), part); +} +inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { + lv_style_set_text_font(style, font->get_lv_font()); +} +#endif #ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image can easily be updated // from within a lambda. @@ -134,24 +142,6 @@ template class ObjUpdateAction : public Action { protected: std::function lamb_; }; -#ifdef USE_LVGL_FONT -class FontEngine { - public: - FontEngine(font::Font *esp_font); - const lv_font_t *get_lv_font(); - - const font::Glyph *get_glyph_data(uint32_t unicode_letter); - uint16_t baseline{}; - uint16_t height{}; - uint8_t bpp{}; - - protected: - font::Font *font_{}; - uint32_t last_letter_{}; - const font::Glyph *last_data_{}; - lv_font_t lv_font_{}; -}; -#endif // USE_LVGL_FONT #ifdef USE_LVGL_ANIMIMG void lv_animimg_stop(lv_obj_t *obj); #endif // USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 035320b6ac..b99c0ad5a3 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -45,7 +45,6 @@ lv_coord_t = cg.global_ns.namespace("lv_coord_t") lv_event_code_t = cg.global_ns.enum("lv_event_code_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_key_t = cg.global_ns.enum("lv_key_t") -FontEngine = lvgl_ns.class_("FontEngine") PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) DrawEndTrigger = esphome_ns.class_( "Trigger", automation.Trigger.template(cg.uint32, cg.uint32) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d7c342b16e..e42a813b40 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -76,7 +76,7 @@ lvgl: line_width: 8 line_rounded: true - id: date_style - text_font: roboto10 + text_font: !lambda return id(roboto10); align: center text_color: !lambda return color_id2; bg_opa: cover @@ -267,7 +267,7 @@ lvgl: snprintf(buf, sizeof(buf), "Setup: %d", 42); return std::string(buf); align: top_mid - text_font: space16 + text_font: !lambda return id(space16); - label: id: chip_info_label # Test complex setup lambda (real-world pattern) diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 39d9a0ebf3..00a8cd8c01 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -18,6 +18,7 @@ touchscreen: lvgl: - id: lvgl_0 + default_font: space16 displays: sdl0 - id: lvgl_1 displays: sdl1 @@ -39,3 +40,8 @@ lvgl: text: Click ME on_click: logger.log: Clicked + +font: + - file: "gfonts://Roboto" + id: space16 + bpp: 4 From 986d3c8f137aad0a6097606fae49b98a22949258 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:38:38 -0600 Subject: [PATCH 035/320] [sntp] Merge multiple instances to fix crash and undefined behavior (#11904) --- esphome/components/sntp/time.py | 68 +++++ tests/component_tests/sntp/__init__.py | 1 + .../sntp/config/sntp_test.yaml | 22 ++ tests/component_tests/sntp/test_init.py | 238 ++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 tests/component_tests/sntp/__init__.py create mode 100644 tests/component_tests/sntp/config/sntp_test.yaml create mode 100644 tests/component_tests/sntp/test_init.py diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index d27fc9991d..69a2436d3d 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -1,9 +1,14 @@ +import logging + import esphome.codegen as cg from esphome.components import time as time_ +from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_PLATFORM, CONF_SERVERS, + CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -12,13 +17,74 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] + +CONF_SNTP = "sntp" + sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] + +def _sntp_final_validate(config: ConfigType) -> None: + """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" + full_conf = fv.full_config.get() + time_confs = full_conf.get(CONF_TIME, []) + + sntp_configs: list[ConfigType] = [] + other_time_configs: list[ConfigType] = [] + + for time_conf in time_confs: + if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + sntp_configs.append(time_conf) + else: + other_time_configs.append(time_conf) + + if len(sntp_configs) <= 1: + return + + # Merge all SNTP configs into the first one + merged = sntp_configs[0] + for sntp_conf in sntp_configs[1:]: + # Validate that IDs are consistent if manually specified + if merged[CONF_ID].is_manual and sntp_conf[CONF_ID].is_manual: + raise cv.Invalid( + f"Found multiple SNTP configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, sntp_conf) + + # Deduplicate servers while preserving order + servers = merged[CONF_SERVERS] + unique_servers = list(dict.fromkeys(servers)) + + # Warn if we're dropping servers due to 3-server limit + if len(unique_servers) > 3: + dropped = unique_servers[3:] + unique_servers = unique_servers[:3] + _LOGGER.warning( + "SNTP supports maximum 3 servers. Dropped excess server(s): %s", + dropped, + ) + + merged[CONF_SERVERS] = unique_servers + + _LOGGER.warning( + "Found and merged %d SNTP time configurations into one instance", + len(sntp_configs), + ) + + # Replace time configs with merged SNTP + other time platforms + other_time_configs.append(merged) + full_conf[CONF_TIME] = other_time_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = cv.All( time_.TIME_SCHEMA.extend( { @@ -40,6 +106,8 @@ CONFIG_SCHEMA = cv.All( ), ) +FINAL_VALIDATE_SCHEMA = _sntp_final_validate + async def to_code(config): servers = config[CONF_SERVERS] diff --git a/tests/component_tests/sntp/__init__.py b/tests/component_tests/sntp/__init__.py new file mode 100644 index 0000000000..7d323a4980 --- /dev/null +++ b/tests/component_tests/sntp/__init__.py @@ -0,0 +1 @@ +"""Tests for SNTP component.""" diff --git a/tests/component_tests/sntp/config/sntp_test.yaml b/tests/component_tests/sntp/config/sntp_test.yaml new file mode 100644 index 0000000000..3942c9606b --- /dev/null +++ b/tests/component_tests/sntp/config/sntp_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: sntp-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "testssid" + password: "testpassword" + +# Test multiple SNTP instances that should be merged +time: + - platform: sntp + servers: + - 192.168.1.1 + - pool.ntp.org + - platform: sntp + servers: + - pool.ntp.org + - 192.168.1.2 diff --git a/tests/component_tests/sntp/test_init.py b/tests/component_tests/sntp/test_init.py new file mode 100644 index 0000000000..9197ff55d0 --- /dev/null +++ b/tests/component_tests/sntp/test_init.py @@ -0,0 +1,238 @@ +"""Tests for SNTP time configuration validation.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate +from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME +from esphome.core import ID +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("time_configs", "expected_count", "expected_servers", "warning_messages"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + } + ], + 1, + ["192.168.1.1", "pool.ntp.org"], + [], + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_preserves_order", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_3", is_manual=False), + CONF_SERVERS: ["pool3.ntp.org"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + [ + "SNTP supports maximum 3 servers. Dropped excess server(s): ['pool2.ntp.org', 'pool3.ntp.org']", + "Found and merged 3 SNTP time configurations into one instance", + ], + id="three_instances_drops_excess_servers", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: [ + "192.168.1.1", + "pool.ntp.org", + "pool.ntp.org", + "192.168.1.1", + ], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_multiple_duplicates", + ), + ], +) +def test_sntp_instance_merging( + time_configs: list[dict[str, Any]], + expected_count: int, + expected_servers: list[str], + warning_messages: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SNTP instance merging behavior.""" + # Create a mock full config with time configs + full_conf = {CONF_TIME: time_configs.copy()} + + # Set the context var + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + # Get the updated config + updated_conf = fv.full_config.get() + + # Check if merging occurred + if len(time_configs) > 1: + # Verify only one SNTP instance remains + sntp_instances = [ + tc + for tc in updated_conf[CONF_TIME] + if tc.get(CONF_PLATFORM) == CONF_SNTP + ] + assert len(sntp_instances) == expected_count + + # Verify server list + assert sntp_instances[0][CONF_SERVERS] == expected_servers + + # Verify warnings + for expected_msg in warning_messages: + assert any( + expected_msg in record.message for record in caplog.records + ), f"Expected warning message '{expected_msg}' not found in log" + else: + # Single instance should not trigger merging or warnings + assert len(caplog.records) == 0 + # Config should be unchanged + assert updated_conf[CONF_TIME] == time_configs + finally: + fv.full_config.reset(token) + + +def test_sntp_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + # Create configs with manual IDs that are inconsistent + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=True), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=True), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple SNTP configurations but id is inconsistent", + ): + _sntp_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> None: + """Test that SNTP merging doesn't affect other time platforms.""" + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: "homeassistant", + CONF_ID: ID("homeassistant_time", is_manual=False), + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + updated_conf = fv.full_config.get() + + # Should have 2 time platforms: 1 merged SNTP + 1 homeassistant + assert len(updated_conf[CONF_TIME]) == 2 + + # Find the platforms + platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]} + assert platforms == {CONF_SNTP, "homeassistant"} + + # Verify SNTP was merged + sntp_instances = [ + tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP + ] + assert len(sntp_instances) == 1 + assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"] + finally: + fv.full_config.reset(token) From 96ee38759d7f5a6dd0e9ea7ca4331007ef49e936 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:38:52 -0600 Subject: [PATCH 036/320] [web_server.ota] Merge multiple instances to prevent undefined behavior (#11905) --- esphome/components/web_server/ota/__init__.py | 58 ++++++- .../ota/test_web_server_ota.py | 153 ++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 4a98db8877..260e6aea6d 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -1,10 +1,17 @@ +import logging + import esphome.codegen as cg from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -12,6 +19,53 @@ DEPENDENCIES = ["network", "web_server_base"] web_server_ns = cg.esphome_ns.namespace("web_server") WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) + +def _web_server_ota_final_validate(config: ConfigType) -> None: + """Merge multiple web_server OTA instances into one. + + Multiple web_server OTA instances register duplicate HTTP handlers for /update, + causing undefined behavior. Merge them into a single instance. + """ + full_conf = fv.full_config.get() + ota_confs = full_conf.get(CONF_OTA, []) + + web_server_ota_configs: list[ConfigType] = [] + other_ota_configs: list[ConfigType] = [] + + for ota_conf in ota_confs: + if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + web_server_ota_configs.append(ota_conf) + else: + other_ota_configs.append(ota_conf) + + if len(web_server_ota_configs) <= 1: + return + + # Merge all web_server OTA configs into the first one + merged = web_server_ota_configs[0] + for ota_conf in web_server_ota_configs[1:]: + # Validate that IDs are consistent if manually specified + if ( + merged[CONF_ID].is_manual + and ota_conf[CONF_ID].is_manual + and merged[CONF_ID] != ota_conf[CONF_ID] + ): + raise cv.Invalid( + f"Found multiple web_server OTA configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, ota_conf) + + _LOGGER.warning( + "Found and merged %d web_server OTA configurations into one instance", + len(web_server_ota_configs), + ) + + # Replace OTA configs with merged web_server + other OTA platforms + other_ota_configs.append(merged) + full_conf[CONF_OTA] = other_ota_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = ( cv.Schema( { @@ -22,6 +76,8 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate + @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 0d8ff6f134..794eaac9be 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -1,6 +1,18 @@ """Tests for the web_server OTA platform.""" +from __future__ import annotations + from collections.abc import Callable +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server.ota import _web_server_ota_final_validate +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER +from esphome.core import ID +import esphome.final_validate as fv def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: @@ -100,3 +112,144 @@ def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None: # Check web server OTA component is present assert "WebServerOTAComponent" in main_cpp assert "web_server::WebServerOTAComponent" in main_cpp + + +@pytest.mark.parametrize( + ("ota_configs", "expected_count", "warning_expected"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=False), + } + ], + 1, + False, + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 1, + True, + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: "esphome", + CONF_ID: ID("ota_esphome", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 2, + True, + id="mixed_platforms_web_server_merged", + ), + ], +) +def test_web_server_ota_instance_merging( + ota_configs: list[dict[str, Any]], + expected_count: int, + warning_expected: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test web_server OTA instance merging behavior.""" + full_conf = {CONF_OTA: ota_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + + # Verify total number of OTA platforms + assert len(updated_conf[CONF_OTA]) == expected_count + + # Verify warning + if warning_expected: + assert any( + "Found and merged" in record.message + and "web_server OTA" in record.message + for record in caplog.records + ), "Expected merge warning not found in log" + else: + assert len(caplog.records) == 0, "Unexpected warnings logged" + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_consistent_manual_ids( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that consistent manual IDs can be merged successfully.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + assert len(updated_conf[CONF_OTA]) == 1 + assert updated_conf[CONF_OTA][0][CONF_ID].id == "ota_web" + assert any( + "Found and merged" in record.message and "web_server OTA" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple web_server OTA configurations but id is inconsistent", + ): + _web_server_ota_final_validate({}) + finally: + fv.full_config.reset(token) From 3b860e784c462ee07c74b79a0393d29f635d876d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 18:39:01 -0600 Subject: [PATCH 037/320] [web_server_idf] Fix lwIP assertion crash by shutting down sockets on connection close (#11937) --- .../components/web_server_idf/web_server_idf.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 0dab5e7e8c..ce91569de2 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -489,10 +489,18 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); - ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load()); - // Mark as dead by setting fd to 0 - will be cleaned up in the main loop - rsp->fd_.store(0); - // Note: We don't delete or remove from set here to avoid race conditions + int fd = rsp->fd_.exchange(0); // Atomically get and clear fd + + if (fd > 0) { + ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd); + // Immediately shut down the socket to prevent lwIP from delivering more data + // This prevents "recv_tcp: recv for wrong pcb!" assertions when the TCP stack + // tries to deliver queued data after the session is marked as dead + // See: https://github.com/esphome/esphome/issues/11936 + shutdown(fd, SHUT_RDWR); + // Note: We don't close() the socket - httpd owns it and will close it + } + // Session will be cleaned up in the main loop to avoid race conditions } // helper for allowing only unique entries in the queue From aa097a2fe6ba533a23b1598d44290c677d03ef04 Mon Sep 17 00:00:00 2001 From: Anton Sergunov Date: Mon, 17 Nov 2025 07:25:00 +0600 Subject: [PATCH 038/320] [uart] Setup uart pins only if flags are set (#11914) Co-authored-by: J. Nick Koston --- .../components/uart/uart_component_esp8266.cpp | 18 +++++++++++++----- .../components/uart/uart_component_esp_idf.cpp | 18 +++++++++++++----- .../uart/uart_component_libretiny.cpp | 2 +- .../components/uart/uart_component_rp2040.cpp | 18 +++++++++++++----- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 7a453dbb50..c84a877ef4 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -56,11 +56,19 @@ uint32_t ESP8266UartComponent::get_config() { } void ESP8266UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } // Use Arduino HardwareSerial UARTs if all used pins match the ones diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 70a13c9e37..61ca8c1c0c 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -133,11 +133,19 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 8d1d28fce4..1e408b169b 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -53,7 +53,7 @@ void LibreTinyUARTComponent::setup() { auto shouldFallbackToSoftwareSerial = [&]() -> bool { auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { - return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE; + return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index c78691653d..cd3905b5c1 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -52,11 +52,19 @@ uint16_t RP2040UartComponent::get_config() { } void RP2040UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } uint16_t config = get_config(); From 10bdb47eae14bd3fd1cd761737b0633cd9c9894b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Nov 2025 20:37:06 -0600 Subject: [PATCH 039/320] [cover] Modernize to C++17 nested namespaces (#11935) --- esphome/components/cover/automation.h | 6 ++---- esphome/components/cover/cover.cpp | 6 ++---- esphome/components/cover/cover.h | 6 ++---- esphome/components/cover/cover_traits.h | 6 ++---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 752e0398c1..c0345a7cc6 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "cover.h" -namespace esphome { -namespace cover { +namespace esphome::cover { template class OpenAction : public Action { public: @@ -131,5 +130,4 @@ class CoverClosedTrigger : public Trigger<> { } }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 3062dba28a..8f735982f1 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -6,8 +6,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace cover { +namespace esphome::cover { static const char *const TAG = "cover"; @@ -212,5 +211,4 @@ void CoverRestoreState::apply(Cover *cover) { cover->publish_state(); } -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index d5db6cfb4f..6c69c05e71 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -7,8 +7,7 @@ #include "cover_traits.h" -namespace esphome { -namespace cover { +namespace esphome::cover { const extern float COVER_OPEN; const extern float COVER_CLOSED; @@ -157,5 +156,4 @@ class Cover : public EntityBase, public EntityBase_DeviceClass { ESPPreferenceObject rtc_; }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover_traits.h b/esphome/components/cover/cover_traits.h index 79001c3b03..723516318b 100644 --- a/esphome/components/cover/cover_traits.h +++ b/esphome/components/cover/cover_traits.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace cover { +namespace esphome::cover { class CoverTraits { public: @@ -26,5 +25,4 @@ class CoverTraits { bool supports_stop_{false}; }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover From 9e1f8d83f884b8fb9c168c1ea3909ef3fc365c21 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Mon, 17 Nov 2025 08:03:11 +0100 Subject: [PATCH 040/320] [config] Support !remove and !extend with LVGL-style configs (#11534) --- esphome/config.py | 59 +++++++++++-------- .../05-extend-remove.approved.yaml | 24 ++++++++ .../substitutions/05-extend-remove.input.yaml | 44 ++++++++++++++ 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/esphome/config.py b/esphome/config.py index e508ca585b..4c8019de75 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -338,21 +338,44 @@ def check_replaceme(value): ) -def _build_list_index(lst): +def _get_item_id(item: Any) -> str | Extend | Remove | None: + """Attempts to get a list item's ID""" + if not isinstance(item, dict): + return None # not a dict, can't have ID + # 1.- Check regular case: + # - id: my_id + item_id = item.get(CONF_ID) + if item_id is None and len(item) == 1: + # 2.- Check single-key dict case: + # - obj: + # id: my_id + item = next(iter(item.values())) + if isinstance(item, dict): + item_id = item.get(CONF_ID) + if isinstance(item_id, Extend): + # Remove instances of Extend so they don't overwrite the original item when merging: + del item[CONF_ID] + return item_id + + +def _build_list_index( + lst: list[Any], +) -> tuple[ + OrderedDict[str | Extend | Remove, Any], list[tuple[int, str, Any]], set[str] +]: index = OrderedDict() extensions, removals = [], set() - for item in lst: + for pos, item in enumerate(lst): if item is None: removals.add(None) continue - item_id = None - if isinstance(item, dict) and (item_id := item.get(CONF_ID)): - if isinstance(item_id, Extend): - extensions.append(item) - continue - if isinstance(item_id, Remove): - removals.add(item_id.value) - continue + item_id = _get_item_id(item) + if isinstance(item_id, Extend): + extensions.append((pos, item_id.value, item)) + continue + if isinstance(item_id, Remove): + removals.add(item_id.value) + continue if not item_id or item_id in index: # no id or duplicate -> pass through with identity-based key item_id = id(item) @@ -360,7 +383,7 @@ def _build_list_index(lst): return index, extensions, removals -def resolve_extend_remove(value, is_key=None): +def resolve_extend_remove(value: Any, is_key: bool = False) -> None: if isinstance(value, ESPLiteralValue): return # do not check inside literal blocks if isinstance(value, list): @@ -368,26 +391,16 @@ def resolve_extend_remove(value, is_key=None): if extensions or removals: # Rebuild the original list after # processing all extensions and removals - for item in extensions: - item_id = item[CONF_ID].value + for pos, item_id, item in extensions: if item_id in removals: continue old = index.get(item_id) if old is None: # Failed to find source for extension - # Find index of item to show error at correct position - i = next( - ( - i - for i, d in enumerate(value) - if d.get(CONF_ID) == item[CONF_ID] - ) - ) - with cv.prepend_path(i): + with cv.prepend_path(pos): raise cv.Invalid( f"Source for extension of ID '{item_id}' was not found." ) - item[CONF_ID] = item_id index[item_id] = merge_config(old, item) for item_id in removals: index.pop(item_id, None) diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml index a479370f4b..35e3e6258f 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml @@ -7,3 +7,27 @@ some_component: value: 2 - id: component2 value: 5 +lvgl: + pages: + - id: page1 + widgets: + - obj: + id: object1 + x: 3 + y: 2 + width: 4 + - obj: + id: object3 + x: 6 + y: 12 + widgets: + - obj: + id: object4 + x: 14 + y: 9 + width: 15 + height: 13 + - obj: + id: object5 + x: 10 + y: 11 diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml index 2e0e60798d..617f09c31c 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml @@ -13,6 +13,30 @@ packages: value: 5 - id: component3 value: 6 + - lvgl: + pages: + - id: page1 + widgets: + - obj: + id: object1 + x: 1 + y: 2 + - obj: + id: object2 + x: 5 + - obj: + id: object3 + x: 6 + y: 7 + widgets: + - obj: + id: object4 + x: 8 + y: 9 + - obj: + id: object5 + x: 10 + y: 11 some_component: - id: !extend ${A} @@ -20,3 +44,23 @@ some_component: - id: component2 value: 3 - id: !remove ${C} + +lvgl: + pages: + - id: !extend page1 + widgets: + - obj: + id: !extend object1 + x: 3 + width: 4 + - obj: + id: !remove object2 + - obj: + id: !extend object3 + y: 12 + height: 13 + widgets: + - obj: + id: !extend object4 + x: 14 + width: 15 From 3d6c361037098f2a7990d1c41c0b4b50788afb64 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:32:08 -0500 Subject: [PATCH 041/320] [core] Add support for setting environment variables (#11953) --- esphome/const.py | 1 + esphome/core/config.py | 15 +++++++++++++++ tests/components/esphome/common.yaml | 3 +++ 3 files changed, 19 insertions(+) diff --git a/esphome/const.py b/esphome/const.py index a25114d80e..b4cd3cfd1c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -336,6 +336,7 @@ CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" CONF_ENUM_DATAPOINT = "enum_datapoint" +CONF_ENVIRONMENT_VARIABLES = "environment_variables" CONF_EQUATION = "equation" CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support" CONF_ESPHOME = "esphome" diff --git a/esphome/core/config.py b/esphome/core/config.py index 763f9ebd9f..0a239c5f5e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_DEBUG_SCHEDULER, CONF_DEVICES, + CONF_ENVIRONMENT_VARIABLES, CONF_ESPHOME, CONF_FRIENDLY_NAME, CONF_ID, @@ -215,6 +216,11 @@ CONFIG_SCHEMA = cv.All( cv.string_strict: cv.Any([cv.string], cv.string), } ), + cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + { + cv.string_strict: cv.string, + } + ), cv.Optional(CONF_ON_BOOT): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger), @@ -426,6 +432,12 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_environment_variables(env_vars: dict[str, str]) -> None: + # Set environment variables for the build process + os.environ.update(env_vars) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _add_automations(config): for conf in config.get(CONF_ON_BOOT, []): @@ -563,6 +575,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + if config[CONF_ENVIRONMENT_VARIABLES]: + CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES]) + # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index b2d7bccaa5..db75b08b38 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,6 +2,9 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio + environment_variables: + TEST_ENV_VAR: "test_value" + BUILD_NUMBER: "12345" area: id: testing_area name: Testing Area From 7a238028a7de73b37a6edd8abf0083cdca00aeb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:38:44 -0600 Subject: [PATCH 042/320] Bump ruamel-yaml-clib from 0.2.14 to 0.2.15 (#11956) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 40802422f2..6ae050b35b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ aioesphomeapi==42.7.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import -ruamel.yaml.clib==0.2.14 # dashboard_import +ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 cairosvg==2.8.2 From 23f85162d0dd2216001812d79b60786a12f8a130 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:39:01 -0600 Subject: [PATCH 043/320] Bump actions/checkout from 5.0.0 to 5.0.1 (#11957) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 30 +++++++++---------- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 8 ++--- .github/workflows/sync-device-classes.yml | 4 +-- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index dd1bc29d83..fb284c9d8c 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,7 +22,7 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 400373679f..be5af1aff1 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 78d1c2b87f..aebf07949d 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7111c61dda..9bb983b993 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index eea1d2c148..c82ae30f55 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16837b3186..5293c62d34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -132,7 +132,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -183,7 +183,7 @@ jobs: component-test-batches: ${{ steps.determine.outputs.component-test-batches }} steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -237,7 +237,7 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 @@ -273,7 +273,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -321,7 +321,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -400,7 +400,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -489,7 +489,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -577,7 +577,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -662,7 +662,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -688,7 +688,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ref: ${{ github.base_ref }} @@ -840,7 +840,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -908,7 +908,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2273975328..adb2a9d79f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75d88abf29..a064f6ef3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Download digests uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9479645ccc..4fc287b067 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Checkout Home Assistant - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: home-assistant/core path: lib/home-assistant From 1a73f49cd23080a500be9fd9d98de3ebccd02fbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 17:20:18 -0600 Subject: [PATCH 044/320] [number] Modernize to C++17 nested namespaces (#11945) --- esphome/components/number/automation.cpp | 6 ++---- esphome/components/number/automation.h | 6 ++---- esphome/components/number/number.cpp | 6 ++---- esphome/components/number/number.h | 6 ++---- esphome/components/number/number_call.cpp | 6 ++---- esphome/components/number/number_call.h | 6 ++---- esphome/components/number/number_traits.cpp | 6 ++---- esphome/components/number/number_traits.h | 6 ++---- 8 files changed, 16 insertions(+), 32 deletions(-) diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index bfc59d0465..78ffc255fe 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -1,8 +1,7 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number.automation"; @@ -52,5 +51,4 @@ void ValueRangeTrigger::on_state_(float state) { this->rtc_.save(&in_range); } -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 79eba883c4..a7cd04f083 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace number { +namespace esphome::number { class NumberStateTrigger : public Trigger { public: @@ -91,5 +90,4 @@ template class NumberInRangeCondition : public Condition float max_{NAN}; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index f12e0e9e1e..992100ead0 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number"; @@ -43,5 +42,4 @@ void Number::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index da91d70d53..472e06ad61 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -6,8 +6,7 @@ #include "number_call.h" #include "number_traits.h" -namespace esphome { -namespace number { +namespace esphome::number { class Number; void log_number(const char *tag, const char *prefix, const char *type, Number *obj); @@ -53,5 +52,4 @@ class Number : public EntityBase { CallbackManager state_callback_; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index 669dd65184..27a857c112 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -2,8 +2,7 @@ #include "number.h" #include "esphome/core/log.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number"; @@ -125,5 +124,4 @@ void NumberCall::perform() { this->parent_->control(target_value); } -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_call.h b/esphome/components/number/number_call.h index 807207f0ec..0f6889dcb6 100644 --- a/esphome/components/number/number_call.h +++ b/esphome/components/number/number_call.h @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "number_traits.h" -namespace esphome { -namespace number { +namespace esphome::number { class Number; @@ -44,5 +43,4 @@ class NumberCall { bool cycle_; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_traits.cpp b/esphome/components/number/number_traits.cpp index 89035661f5..1e4239ceca 100644 --- a/esphome/components/number/number_traits.cpp +++ b/esphome/components/number/number_traits.cpp @@ -1,10 +1,8 @@ #include "esphome/core/log.h" #include "number_traits.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number"; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index fa68c2390a..5ccbb9ba48 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -3,8 +3,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace number { +namespace esphome::number { enum NumberMode : uint8_t { NUMBER_MODE_AUTO = 0, @@ -35,5 +34,4 @@ class NumberTraits : public EntityBase_DeviceClass, public EntityBase_UnitOfMeas NumberMode mode_{NUMBER_MODE_AUTO}; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number From fdc7ae776071c0638774f122d3f216f2ed45af48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 17:20:32 -0600 Subject: [PATCH 045/320] [wifi] Skip redundant setter calls for default values (#11943) --- esphome/components/wifi/__init__.py | 9 ++++++--- esphome/components/wifi/wifi_component.h | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 11bd7798e2..f543d972c9 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -479,11 +479,14 @@ async def to_code(config): cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) if config[CONF_FAST_CONNECT]: cg.add_define("USE_WIFI_FAST_CONNECT") - cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN])) + # passive_scan defaults to false in C++ - only set if true + if config[CONF_PASSIVE_SCAN]: + cg.add(var.set_passive_scan(True)) if CONF_OUTPUT_POWER in config: cg.add(var.set_output_power(config[CONF_OUTPUT_POWER])) - - cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + # enable_on_boot defaults to true in C++ - only set if false + if not config[CONF_ENABLE_ON_BOOT]: + cg.add(var.set_enable_on_boot(False)) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 2fd7fa6cd4..66e2ccf1cb 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -526,7 +526,7 @@ class WiFiComponent : public Component { bool btm_{false}; bool rrm_{false}; #endif - bool enable_on_boot_; + bool enable_on_boot_{true}; bool got_ipv4_address_{false}; bool keep_scan_results_{false}; From 0923bcd2ca1a77ff8777bd71749a403b37ce00d4 Mon Sep 17 00:00:00 2001 From: strange_v Date: Tue, 18 Nov 2025 02:32:17 +0100 Subject: [PATCH 046/320] [mipi_rgb] Fix GUITION-4848S040 colors (#11709) --- esphome/components/mipi_rgb/mipi_rgb.cpp | 15 ++++++++------- esphome/components/mipi_rgb/models/guition.py | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 00c9c8cbff..080fb08c09 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -350,6 +350,7 @@ void MipiRgb::dump_config() { "\n Width: %u" "\n Height: %u" "\n Rotation: %d degrees" + "\n PCLK Inverted: %s" "\n HSync Pulse Width: %u" "\n HSync Back Porch: %u" "\n HSync Front Porch: %u" @@ -357,18 +358,18 @@ void MipiRgb::dump_config() { "\n VSync Back Porch: %u" "\n VSync Front Porch: %u" "\n Invert Colors: %s" - "\n Pixel Clock: %dMHz" + "\n Pixel Clock: %uMHz" "\n Reset Pin: %s" "\n DE Pin: %s" "\n PCLK Pin: %s" "\n HSYNC Pin: %s" "\n VSYNC Pin: %s", - this->model_, this->width_, this->height_, this->rotation_, this->hsync_pulse_width_, - this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, - this->vsync_front_porch_, YESNO(this->invert_colors_), this->pclk_frequency_ / 1000000, - get_pin_name(this->reset_pin_).c_str(), get_pin_name(this->de_pin_).c_str(), - get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(), - get_pin_name(this->vsync_pin_).c_str()); + this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_), + this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, + this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_), + (unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_).c_str(), + get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(), + get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str()); if (this->madctl_ & MADCTL_BGR) { this->dump_pins_(8, 13, "Blue", 0); diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index da433e686e..915b8beda0 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -11,6 +11,7 @@ st7701s.extend( vsync_pin=17, pclk_pin=21, pclk_frequency="12MHz", + pclk_inverted=False, pixel_mode="18bit", mirror_x=True, mirror_y=True, From 0d6c9623ce38f8aed4692006f52135f7e80d490e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 20:02:16 -0600 Subject: [PATCH 047/320] [dashboard_import] Store package import URL in .rodata instead of RAM (#11951) --- esphome/components/dashboard_import/dashboard_import.cpp | 6 +++--- esphome/components/dashboard_import/dashboard_import.h | 6 ++---- esphome/components/mdns/mdns_component.cpp | 3 +-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index c04696fd53..d4a95b81f6 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -3,10 +3,10 @@ namespace esphome { namespace dashboard_import { -static std::string g_package_import_url; // NOLINT +static const char *g_package_import_url = ""; // NOLINT -const std::string &get_package_import_url() { return g_package_import_url; } -void set_package_import_url(std::string url) { g_package_import_url = std::move(url); } +const char *get_package_import_url() { return g_package_import_url; } +void set_package_import_url(const char *url) { g_package_import_url = url; } } // namespace dashboard_import } // namespace esphome diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index edcda6b803..488bf80a2e 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,12 +1,10 @@ #pragma once -#include - namespace esphome { namespace dashboard_import { -const std::string &get_package_import_url(); -void set_package_import_url(std::string url); +const char *get_package_import_url(); +void set_package_import_url(const char *url); } // namespace dashboard_import } // namespace esphome diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 2c3150ff5d..b66129404e 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -135,8 +135,7 @@ void MDNSComponent::compile_records_(StaticVector Date: Tue, 18 Nov 2025 14:11:49 +1000 Subject: [PATCH 048/320] [build] Don't clear pio cache unless requested (#11966) --- esphome/writer.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index 8eee445cf1..b866a804b3 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -121,7 +121,7 @@ def update_storage_json() -> None: ) else: _LOGGER.info("Core config or version changed, cleaning build files...") - clean_build() + clean_build(clear_pio_cache=False) elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") clean_cmake_cache() @@ -301,7 +301,7 @@ def clean_cmake_cache(): pioenvs_cmake_path.unlink() -def clean_build(): +def clean_build(clear_pio_cache: bool = True): import shutil # Allow skipping cache cleaning for integration tests @@ -322,6 +322,9 @@ def clean_build(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + if not clear_pio_cache: + return + # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: From 11d0d4d1288c28aeb03b7bd27c36a857f9b308b4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:27:50 +1000 Subject: [PATCH 049/320] [lvgl] Apply scale to spinbox value (#11946) --- esphome/components/lvgl/widgets/spinbox.py | 5 ++++- tests/components/lvgl/lvgl-package.yaml | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index ac23ded723..c6f25e9587 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE +from esphome.cpp_generator import MockObj from ..automation import action_to_code from ..defines import ( @@ -114,7 +115,9 @@ class SpinboxType(WidgetType): w.obj, digits, digits - config[CONF_DECIMAL_PLACES] ) if (value := config.get(CONF_VALUE)) is not None: - lv.spinbox_set_value(w.obj, await lv_float.process(value)) + lv.spinbox_set_value( + w.obj, MockObj(await lv_float.process(value)) * w.get_scale() + ) def get_scale(self, config): return 10 ** config[CONF_DECIMAL_PLACES] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index e42a813b40..eabceff9d9 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -703,7 +703,9 @@ lvgl: on_value: - lvgl.spinbox.update: id: spinbox_id - value: !lambda return x; + value: !lambda |- + static float yyy = 83.0; + return yyy + .8; - button: styles: spin_button id: spin_up From 33983b051bf9a979e929c983ed4661d57ebbed1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 10:51:47 -0600 Subject: [PATCH 050/320] [ld24xx] Use stack allocation for MAC and version formatting (#11961) --- esphome/components/ld2410/ld2410.cpp | 26 ++++++++++++-------------- esphome/components/ld2412/ld2412.cpp | 26 ++++++++++++-------------- esphome/components/ld2450/ld2450.cpp | 26 ++++++++++++-------------- esphome/components/ld24xx/ld24xx.h | 24 +++++++++++++++++++++++- 4 files changed, 59 insertions(+), 43 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 608882565f..391f2024cd 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -13,8 +13,6 @@ namespace esphome { namespace ld2410 { static const char *const TAG = "ld2410"; -static const char *const UNKNOWN_MAC = "unknown"; -static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -181,15 +179,15 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); + char mac_s[18]; + char version_s[20]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ld24xx::format_version_str(this->version_, version_s); ESP_LOGCONFIG(TAG, "LD2410:\n" " Firmware version: %s\n" " MAC address: %s", - version.c_str(), mac_str.c_str()); + version_s, mac_str); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "Target", this->target_binary_sensor_); @@ -448,12 +446,12 @@ bool LD2410Component::handle_ack_data_() { case CMD_QUERY_VERSION: { std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); - ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); + char version_s[20]; + ld24xx::format_version_str(this->version_, version_s); + ESP_LOGV(TAG, "Firmware version: %s", version_s); #ifdef USE_TEXT_SENSOR if (this->version_text_sensor_ != nullptr) { - this->version_text_sensor_->publish_state(version); + this->version_text_sensor_->publish_state(version_s); } #endif break; @@ -506,9 +504,9 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); + char mac_s[18]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR if (this->mac_text_sensor_ != nullptr) { this->mac_text_sensor_->publish_state(mac_str); diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 5323a9a658..4f2fd7c2bd 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -14,8 +14,6 @@ namespace esphome { namespace ld2412 { static const char *const TAG = "ld2412"; -static const char *const UNKNOWN_MAC = "unknown"; -static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -200,15 +198,15 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); + char mac_s[18]; + char version_s[20]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ld24xx::format_version_str(this->version_, version_s); ESP_LOGCONFIG(TAG, "LD2412:\n" " Firmware version: %s\n" " MAC address: %s", - version.c_str(), mac_str.c_str()); + version_s, mac_str); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "DynamicBackgroundCorrectionStatus", @@ -492,12 +490,12 @@ bool LD2412Component::handle_ack_data_() { case CMD_QUERY_VERSION: { std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); - ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); + char version_s[20]; + ld24xx::format_version_str(this->version_, version_s); + ESP_LOGV(TAG, "Firmware version: %s", version_s); #ifdef USE_TEXT_SENSOR if (this->version_text_sensor_ != nullptr) { - this->version_text_sensor_->publish_state(version); + this->version_text_sensor_->publish_state(version_s); } #endif break; @@ -544,9 +542,9 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); + char mac_s[18]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR if (this->mac_text_sensor_ != nullptr) { this->mac_text_sensor_->publish_state(mac_str); diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index c9d4da47a4..8e5287aec7 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -17,8 +17,6 @@ namespace esphome { namespace ld2450 { static const char *const TAG = "ld2450"; -static const char *const UNKNOWN_MAC = "unknown"; -static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -192,15 +190,15 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); + char mac_s[18]; + char version_s[20]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ld24xx::format_version_str(this->version_, version_s); ESP_LOGCONFIG(TAG, "LD2450:\n" " Firmware version: %s\n" " MAC address: %s", - version.c_str(), mac_str.c_str()); + version_s, mac_str); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "MovingTarget", this->moving_target_binary_sensor_); @@ -642,12 +640,12 @@ bool LD2450Component::handle_ack_data_() { case CMD_QUERY_VERSION: { std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); - ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); + char version_s[20]; + ld24xx::format_version_str(this->version_, version_s); + ESP_LOGV(TAG, "Firmware version: %s", version_s); #ifdef USE_TEXT_SENSOR if (this->version_text_sensor_ != nullptr) { - this->version_text_sensor_->publish_state(version); + this->version_text_sensor_->publish_state(version_s); } #endif break; @@ -663,9 +661,9 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); + char mac_s[18]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR if (this->mac_text_sensor_ != nullptr) { this->mac_text_sensor_->publish_state(mac_str); diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index 1cd5e01163..e695b00705 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -1,11 +1,12 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include +#include #ifdef USE_SENSOR -#include "esphome/core/helpers.h" #include "esphome/components/sensor/sensor.h" #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ @@ -39,6 +40,27 @@ namespace esphome { namespace ld24xx { +static const char *const UNKNOWN_MAC = "unknown"; +static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; + +// Helper function to format MAC address with stack allocation +// Returns pointer to UNKNOWN_MAC constant or formatted buffer +// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { + if (mac_address_is_valid(mac_address)) { + format_mac_addr_upper(mac_address, buffer.data()); + return buffer.data(); + } + return UNKNOWN_MAC; +} + +// Helper function to format firmware version with stack allocation +// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety) +inline void format_version_str(const uint8_t *version, std::span buffer) { + snprintf(buffer.data(), buffer.size(), VERSION_FMT, version[1], version[0], version[5], version[4], version[3], + version[2]); +} + #ifdef USE_SENSOR // Helper class to store a sensor with a deduplicator & publish state only when the value changes template class SensorWithDedup { From c59af222170518a7b85154dba9b26c53a8db0d33 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:40:31 -0500 Subject: [PATCH 051/320] [esp32] Fix Arduino build on some ESP32 S2 boards (#11972) --- esphome/components/esp32/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0f85e585f7..6f577d2926 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -931,6 +931,12 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) + # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency + if get_esp32_variant() == VARIANT_ESP32S2: + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1") + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_build_flag("-Wno-nonnull-compare") add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) From 1888f5ffd582e3afdbfdff8faf9efbac8202ada4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 12:16:18 -0600 Subject: [PATCH 052/320] [scheduler] Add defensive nullptr checks and explicit locking requirements (#11974) --- esphome/core/scheduler.cpp | 14 ++++++++------ esphome/core/scheduler.h | 35 +++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d2e0f0dab4..09d50ee7c8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -154,8 +154,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // For retries, check if there's a cancelled timeout first if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && - (has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || - has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { + (has_cancelled_timeout_in_container_locked_(this->items_, component, name_cstr, /* match_retry= */ true) || + has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { // Skip scheduling - the retry was cancelled #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr); @@ -556,7 +556,8 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #ifndef ESPHOME_THREAD_SINGLE // Mark items in defer queue as cancelled (they'll be skipped when processed) if (type == SchedulerItem::TIMEOUT) { - total_cancelled += this->mark_matching_items_removed_(this->defer_queue_, component, name_cstr, type, match_retry); + total_cancelled += + this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_cstr, type, match_retry); } #endif /* not ESPHOME_THREAD_SINGLE */ @@ -565,19 +566,20 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // (removing the last element doesn't break heap structure) if (!this->items_.empty()) { auto &last_item = this->items_.back(); - if (this->matches_item_(last_item, component, name_cstr, type, match_retry)) { + if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) { this->recycle_item_(std::move(this->items_.back())); this->items_.pop_back(); total_cancelled++; } // For other items in heap, we can only mark for removal (can't remove from middle of heap) - size_t heap_cancelled = this->mark_matching_items_removed_(this->items_, component, name_cstr, type, match_retry); + size_t heap_cancelled = + this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry); total_cancelled += heap_cancelled; this->to_remove_ += heap_cancelled; // Track removals for heap items } // Cancel items in to_add_ - total_cancelled += this->mark_matching_items_removed_(this->to_add_, component, name_cstr, type, match_retry); + total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_cstr, type, match_retry); return total_cancelled > 0; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index fd16840240..bea1503df0 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -243,8 +243,18 @@ class Scheduler { } // Helper function to check if item matches criteria for cancellation - inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { + // IMPORTANT: Must be called with scheduler lock held + inline bool HOT matches_item_locked_(const std::unique_ptr &item, Component *component, + const char *name_cstr, SchedulerItem::Type type, bool match_retry, + bool skip_removed = true) const { + // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded + // platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries. + // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_() and + // has_cancelled_timeout_in_container_locked_()), but this check provides defense-in-depth: helper + // functions should be safe regardless of caller behavior. + // Fixes: https://github.com/esphome/esphome/issues/11940 + if (!item) + return false; if (item->component != component || item->type != type || (skip_removed && item->remove) || (match_retry && !item->is_retry)) { return false; @@ -304,8 +314,8 @@ class Scheduler { // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. // This is intentional and safe because: // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_ - // and has_cancelled_timeout_in_container_ in scheduler.h) + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ + // and has_cancelled_timeout_in_container_locked_ in scheduler.h) // 3. The lock protects concurrent access, but the nullptr remains until cleanup item = std::move(this->defer_queue_[this->defer_queue_front_]); this->defer_queue_front_++; @@ -393,10 +403,10 @@ class Scheduler { // Helper to mark matching items in a container as removed // Returns the number of items marked for removal - // IMPORTANT: Caller must hold the scheduler lock before calling this function. + // IMPORTANT: Must be called with scheduler lock held template - size_t mark_matching_items_removed_(Container &container, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool match_retry) { + size_t mark_matching_items_removed_locked_(Container &container, Component *component, const char *name_cstr, + SchedulerItem::Type type, bool match_retry) { size_t count = 0; for (auto &item : container) { // Skip nullptr items (can happen in defer_queue_ when items are being processed) @@ -405,7 +415,7 @@ class Scheduler { // the vector can still contain nullptr items from the processing loop. This check prevents crashes. if (!item) continue; - if (this->matches_item_(item, component, name_cstr, type, match_retry)) { + if (this->matches_item_locked_(item, component, name_cstr, type, match_retry)) { // Mark item for removal (platform-specific) this->set_item_removed_(item.get(), true); count++; @@ -415,9 +425,10 @@ class Scheduler { } // Template helper to check if any item in a container matches our criteria + // IMPORTANT: Must be called with scheduler lock held template - bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, - bool match_retry) const { + bool has_cancelled_timeout_in_container_locked_(const Container &container, Component *component, + const char *name_cstr, bool match_retry) const { for (const auto &item : container) { // Skip nullptr items (can happen in defer_queue_ when items are being processed) // The defer_queue_ uses index-based processing: items are std::moved out but left in the @@ -426,8 +437,8 @@ class Scheduler { if (!item) continue; if (is_item_removed_(item.get()) && - this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, - /* skip_removed= */ false)) { + this->matches_item_locked_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, + /* skip_removed= */ false)) { return true; } } From fe2befcec2c50704afb816f5434da1871393522d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:18:09 -0500 Subject: [PATCH 053/320] [bme68x] Print error when no sensors are configured (#11976) --- esphome/components/bme68x_bsec2/bme68x_bsec2.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f5dcfd65a1..91383c8d45 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -70,6 +70,9 @@ void BME68xBSEC2Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, "Communication failed (BSEC2 status: %d, BME68X status: %d)", this->bsec_status_, this->bme68x_status_); + if (this->bsec_status_ == BSEC_I_SU_SUBSCRIBEDOUTPUTGATES) { + ESP_LOGE(TAG, "No sensors, add at least one sensor to the config"); + } } if (this->algorithm_output_ != ALGORITHM_OUTPUT_IAQ) { From 72e4b16a5b58d0a2f542d619daad19873f2e5020 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:29:40 -0500 Subject: [PATCH 054/320] [sfa30] Fix negative temperature values (#11973) --- esphome/components/sfa30/sfa30.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index 99709d5fbb..bbe3bcd7d2 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -73,17 +73,17 @@ void SFA30Component::update() { } if (this->formaldehyde_sensor_ != nullptr) { - const float formaldehyde = raw_data[0] / 5.0f; + const float formaldehyde = static_cast(raw_data[0]) / 5.0f; this->formaldehyde_sensor_->publish_state(formaldehyde); } if (this->humidity_sensor_ != nullptr) { - const float humidity = raw_data[1] / 100.0f; + const float humidity = static_cast(raw_data[1]) / 100.0f; this->humidity_sensor_->publish_state(humidity); } if (this->temperature_sensor_ != nullptr) { - const float temperature = raw_data[2] / 200.0f; + const float temperature = static_cast(raw_data[2]) / 200.0f; this->temperature_sensor_->publish_state(temperature); } From 81fe5deaa9f376764facd3cd5f26c04e9d4e8aac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 08:12:42 +1300 Subject: [PATCH 055/320] Bump github/codeql-action from 4.31.3 to 4.31.4 (#11977) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index adb2a9d79f..21fff10c95 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 + uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 + uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 with: category: "/language:${{matrix.language}}" From 70ed9c7c4db30952faabdd8b7c6373cc28e41419 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 13:17:21 -0600 Subject: [PATCH 056/320] [wifi] Fix captive portal unusable when WiFi credentials are wrong (#11965) --- esphome/components/captive_portal/__init__.py | 10 +++ .../captive_portal/captive_portal.cpp | 10 ++- .../captive_portal/captive_portal.h | 4 ++ esphome/components/esp32_improv/__init__.py | 6 +- .../esp32_improv/esp32_improv_component.cpp | 1 + .../esp32_improv/esp32_improv_component.h | 1 + .../web_server_idf/web_server_idf.cpp | 15 +++++ .../web_server_idf/web_server_idf.h | 4 ++ esphome/components/wifi/__init__.py | 8 ++- esphome/components/wifi/wifi_component.cpp | 62 +++++++++++++++---- esphome/components/wifi/wifi_component.h | 5 ++ 11 files changed, 111 insertions(+), 15 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 9bd3ef8a05..25d0a22083 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -72,6 +72,16 @@ def _final_validate(config: ConfigType) -> ConfigType: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) + # Register socket needs for DNS server and additional HTTP connections + # - 1 UDP socket for DNS server + # - 3 additional TCP sockets for captive portal detection probes + configuration requests + # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. + # Need headroom for actual user configuration requests. + # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. + from esphome.components import socket + + socket.consume_sockets(4, "captive_portal")(config) + return config diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 30438747f2..459ac557c8 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -50,8 +50,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); - wifi::global_wifi_component->save_wifi_sta(ssid, psk); - wifi::global_wifi_component->start_scanning(); + // Defer save to main loop thread to avoid NVS operations from HTTP thread + this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); }); request->redirect(ESPHOME_F("/?save")); } @@ -63,6 +63,12 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); +#ifdef USE_ESP32 + // Enable LRU socket purging to handle captive portal detection probe bursts + // OS captive portal detection makes many simultaneous HTTP requests which can + // exhaust sockets. LRU purging automatically closes oldest idle connections. + this->base_->get_server()->set_lru_purge_enable(true); +#endif } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index f48c286f0c..ae9b9dfba0 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,6 +40,10 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests +#ifdef USE_ESP32 + // Disable LRU socket purging now that captive portal is done + this->base_->get_server()->set_lru_purge_enable(false); +#endif this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index 1a7194da81..2e69d400ca 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -20,6 +20,10 @@ CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" +# Default WiFi timeout - aligned with WiFi component ap_timeout +# Allows sufficient time to try all BSSIDs before starting provisioning mode +DEFAULT_WIFI_TIMEOUT = "90s" + improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") @@ -59,7 +63,7 @@ CONFIG_SCHEMA = ( CONF_AUTHORIZED_DURATION, default="1min" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_WIFI_TIMEOUT, default="1min" + CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 398b1d4251..0ad54bbb15 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -127,6 +127,7 @@ void ESP32ImprovComponent::loop() { // Set initial state based on whether we have an authorizer this->set_state_(this->get_initial_state_(), false); this->set_error_(improv::ERROR_NONE); + this->should_start_ = false; // Clear flag after starting ESP_LOGD(TAG, "Service started!"); } } diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 989552ea56..8f4cfd7958 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -45,6 +45,7 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { void start(); void stop(); bool is_active() const { return this->state_ != improv::STATE_STOPPED; } + bool should_start() const { return this->should_start_; } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK void add_on_state_callback(std::function &&callback) { diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index ce91569de2..f5a66f6bd9 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -94,6 +94,18 @@ void AsyncWebServer::end() { } } +void AsyncWebServer::set_lru_purge_enable(bool enable) { + if (this->lru_purge_enable_ == enable) { + return; // No change needed + } + this->lru_purge_enable_ = enable; + // If server is already running, restart it with new config + if (this->server_) { + this->end(); + this->begin(); + } +} + void AsyncWebServer::begin() { if (this->server_) { this->end(); @@ -101,6 +113,8 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; + // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) + config.lru_purge_enable = this->lru_purge_enable_; if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", @@ -242,6 +256,7 @@ void AsyncWebServerRequest::send(int code, const char *content_type, const char void AsyncWebServerRequest::redirect(const std::string &url) { httpd_resp_set_status(*this, "302 Found"); httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 5ec6fec009..b9f690b462 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,9 +199,13 @@ class AsyncWebServer { return *handler; } + void set_lru_purge_enable(bool enable); + httpd_handle_t get_server() { return this->server_; } + protected: uint16_t port_{}; httpd_handle_t server_{}; + bool lru_purge_enable_{false}; static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f543d972c9..2b21478f30 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -69,6 +69,12 @@ CONF_MIN_AUTH_MODE = "min_auth_mode" # Limited to 127 because selected_sta_index_ is int8_t in C++ MAX_WIFI_NETWORKS = 127 +# Default AP timeout - allows sufficient time to try all BSSIDs during initial connection +# After AP starts, WiFi scanning is skipped to avoid disrupting the AP, so we only +# get best-effort connection attempts. Longer timeout ensures we exhaust all options +# before falling back to AP mode. Aligned with improv wifi_timeout default. +DEFAULT_AP_TIMEOUT = "90s" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -177,7 +183,7 @@ CONF_AP_TIMEOUT = "ap_timeout" WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( { cv.Optional( - CONF_AP_TIMEOUT, default="1min" + CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, } ) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 51a5a47323..30340601fb 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -199,7 +199,12 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt -static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 1000; +static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; + +/// Cooldown duration when fallback AP is active and captive portal may be running +/// Longer interval gives users time to configure WiFi without constant connection attempts +/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown +static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { @@ -275,7 +280,9 @@ int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) { } } - if (!this->ssid_was_seen_in_scan_(sta.get_ssid())) { + // If we didn't scan this cycle, treat all networks as potentially hidden + // Otherwise, only retry networks that weren't seen in the scan + if (!this->did_scan_this_cycle_ || !this->ssid_was_seen_in_scan_(sta.get_ssid())) { ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast(i)); return static_cast(i); } @@ -417,10 +424,6 @@ void WiFiComponent::start() { void WiFiComponent::restart_adapter() { ESP_LOGW(TAG, "Restarting adapter"); this->wifi_mode_(false, {}); - // Enter cooldown state to allow WiFi hardware to stabilize after restart - // Don't set retry_phase_ or num_retried_ here - state machine handles transitions - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); this->error_from_callback_ = false; } @@ -441,7 +444,16 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); - if (now - this->action_started_ > WIFI_COOLDOWN_DURATION_MS) { + // Skip cooldown if new credentials were provided while connecting + if (this->skip_cooldown_next_cycle_) { + this->skip_cooldown_next_cycle_ = false; + this->check_connecting_finished(); + break; + } + // Use longer cooldown when captive portal/improv is active to avoid disrupting user config + bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; + if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of // a failure, or something tried to connect over and over // so we entered cooldown. In both cases we call @@ -495,7 +507,8 @@ void WiFiComponent::loop() { #endif // USE_WIFI_AP #ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active()) { + if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && + !esp32_improv::global_improv_component->should_start()) { if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) esp32_improv::global_improv_component->start(); @@ -605,6 +618,8 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; + // When new credentials are set (e.g., from improv), skip cooldown to retry immediately + this->skip_cooldown_next_cycle_ = true; } WiFiAP WiFiComponent::build_params_for_current_phase_() { @@ -666,6 +681,17 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa sta.set_ssid(ssid); sta.set_password(password); this->set_sta(sta); + + // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) + this->connect_soon_(); +} + +void WiFiComponent::connect_soon_() { + // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing + if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { + ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); + this->retry_connect(); + } } void WiFiComponent::start_connecting(const WiFiAP &ap) { @@ -963,6 +989,7 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; + this->did_scan_this_cycle_ = true; if (this->scan_result_.empty()) { ESP_LOGW(TAG, "No networks found"); @@ -1229,9 +1256,16 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: - // After restart, go back to explicit hidden if we went through it initially, otherwise scan - return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN - : WiFiRetryPhase::SCAN_CONNECTING; + // After restart, go back to explicit hidden if we went through it initially + if (this->went_through_explicit_hidden_phase_()) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } + // Skip scanning when captive portal/improv is active to avoid disrupting AP + // Even passive scans can cause brief AP disconnections on ESP32 + if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { + return WiFiRetryPhase::RETRY_HIDDEN; + } + return WiFiRetryPhase::SCAN_CONNECTING; } // Should never reach here @@ -1319,6 +1353,12 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); } + // Clear scan flag - we're starting a new retry cycle + this->did_scan_this_cycle_ = false; + // Always enter cooldown after restart (or skip-restart) to allow stabilization + // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 66e2ccf1cb..b3548078bc 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -291,6 +291,7 @@ class WiFiComponent : public Component { void set_passive_scan(bool passive); void save_wifi_sta(const std::string &ssid, const std::string &password); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup WiFi interface. @@ -424,6 +425,8 @@ class WiFiComponent : public Component { return true; } + void connect_soon_(); + void wifi_loop_(); bool wifi_mode_(optional sta, optional ap); bool wifi_sta_pre_setup_(); @@ -529,6 +532,8 @@ class WiFiComponent : public Component { bool enable_on_boot_{true}; bool got_ipv4_address_{false}; bool keep_scan_results_{false}; + bool did_scan_this_cycle_{false}; + bool skip_cooldown_next_cycle_{false}; // Pointers at the end (naturally aligned) Trigger<> *connect_trigger_{new Trigger<>()}; From 29374837c68d8643c61b35803130b829eca84268 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 17:06:34 -0600 Subject: [PATCH 057/320] [wifi, captive_portal, web_server, wifi_info] Use stack allocation for MAC address formatting (#11963) --- esphome/components/captive_portal/captive_portal.cpp | 6 ++++-- esphome/components/web_server/web_server.cpp | 4 ++-- esphome/components/wifi/wifi_component.cpp | 6 ++++-- esphome/components/wifi_info/wifi_info_text_sensor.h | 5 ++++- esphome/core/helpers.cpp | 12 +++++++++--- esphome/core/helpers.h | 5 +++++ 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 459ac557c8..4eb00835b1 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -13,14 +13,16 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); + char mac_s[18]; + const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); - stream->print(get_mac_address_pretty().c_str()); + stream->print(mac_str); stream->print(ESPHOME_F("\",\"name\":\"")); stream->print(App.get_name().c_str()); stream->print(ESPHOME_F("\",\"aps\":[{}")); #else - stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", get_mac_address_pretty().c_str(), App.get_name().c_str()); + stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif for (auto &scan : wifi::global_wifi_component->get_scan_result()) { diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5a8128ba43..cc51463fe7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -359,8 +359,8 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { AsyncWebServerResponse *response = request->beginResponse(200, ""); response->addHeader(HEADER_CORS_ALLOW_PNA, "true"); response->addHeader(HEADER_PNA_NAME, App.get_name().c_str()); - std::string mac = get_mac_address_pretty(); - response->addHeader(HEADER_PNA_ID, mac.c_str()); + char mac_s[18]; + response->addHeader(HEADER_PNA_ID, get_mac_address_pretty_into_buffer(mac_s)); request->send(response); } #endif diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 30340601fb..6f698bc2a8 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -341,10 +341,11 @@ void WiFiComponent::setup() { } void WiFiComponent::start() { + char mac_s[18]; ESP_LOGCONFIG(TAG, "Starting\n" " Local MAC: %s", - get_mac_address_pretty().c_str()); + get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL; @@ -826,7 +827,8 @@ void WiFiComponent::print_connect_params_() { char bssid_s[18]; format_mac_addr_upper(bssid.data(), bssid_s); - ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty().c_str()); + char mac_s[18]; + ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty_into_buffer(mac_s)); if (this->is_disabled()) { ESP_LOGCONFIG(TAG, " Disabled"); return; diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 04889d6bb3..0814336c43 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -126,7 +126,10 @@ class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { public: - void setup() override { this->publish_state(get_mac_address_pretty()); } + void setup() override { + char mac_s[18]; + this->publish_state(get_mac_address_pretty_into_buffer(mac_s)); + } void dump_config() override; }; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 568acb9f1b..50af71649c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -638,9 +638,8 @@ std::string get_mac_address() { } std::string get_mac_address_pretty() { - uint8_t mac[6]; - get_mac_address_raw(mac); - return format_mac_address_pretty(mac); + char buf[18]; + return std::string(get_mac_address_pretty_into_buffer(buf)); } void get_mac_address_into_buffer(std::span buf) { @@ -649,6 +648,13 @@ void get_mac_address_into_buffer(std::span buf) { format_mac_addr_lower_no_sep(mac, buf.data()); } +const char *get_mac_address_pretty_into_buffer(std::span buf) { + uint8_t mac[6]; + get_mac_address_raw(mac); + format_mac_addr_upper(mac, buf.data()); + return buf.data(); +} + #ifndef USE_ESP32 bool has_custom_mac_address() { return false; } #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 16eab8b8f6..d8c1f4647e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1052,6 +1052,11 @@ std::string get_mac_address_pretty(); /// Assumes buffer length is 13 (12 digits for hexadecimal representation followed by null terminator). void get_mac_address_into_buffer(std::span buf); +/// Get the device MAC address into the given buffer, in colon-separated uppercase hex notation. +/// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator). +/// Returns pointer to the buffer for convenience. +const char *get_mac_address_pretty_into_buffer(std::span buf); + #ifdef USE_ESP32 /// Set the MAC address to use from the provided byte array (6 bytes). void set_mac_address(uint8_t *mac); From 45c994e4de71c99bc0fb8051c8ca9ea484348ee8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 21:56:23 -0600 Subject: [PATCH 058/320] [light] Modernize namespace declarations to C++17 syntax (#11986) --- esphome/components/light/addressable_light.cpp | 6 ++---- esphome/components/light/addressable_light.h | 6 ++---- esphome/components/light/addressable_light_effect.h | 6 ++---- esphome/components/light/addressable_light_wrapper.h | 6 ++---- esphome/components/light/automation.cpp | 6 ++---- esphome/components/light/automation.h | 6 ++---- esphome/components/light/base_light_effects.h | 6 ++---- esphome/components/light/color_mode.h | 6 ++---- esphome/components/light/esp_color_correction.cpp | 6 ++---- esphome/components/light/esp_color_correction.h | 6 ++---- esphome/components/light/esp_color_view.h | 6 ++---- esphome/components/light/esp_hsv_color.cpp | 6 ++---- esphome/components/light/esp_hsv_color.h | 6 ++---- esphome/components/light/esp_range_view.cpp | 6 ++---- esphome/components/light/esp_range_view.h | 6 ++---- esphome/components/light/light_call.cpp | 6 ++---- esphome/components/light/light_color_values.h | 6 ++---- esphome/components/light/light_effect.cpp | 6 ++---- esphome/components/light/light_effect.h | 6 ++---- esphome/components/light/light_json_schema.cpp | 6 ++---- esphome/components/light/light_json_schema.h | 6 ++---- esphome/components/light/light_output.cpp | 6 ++---- esphome/components/light/light_output.h | 6 ++---- esphome/components/light/light_state.cpp | 6 ++---- esphome/components/light/light_state.h | 6 ++---- esphome/components/light/light_transformer.h | 6 ++---- esphome/components/light/transformers.h | 6 ++---- 27 files changed, 54 insertions(+), 108 deletions(-) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 5cbdcb0e86..2f6ffc9a38 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -1,8 +1,7 @@ #include "addressable_light.h" #include "esphome/core/log.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light.addressable"; @@ -112,5 +111,4 @@ optional AddressableLightTransformer::apply() { return {}; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 393cc679bc..2e4b984ce4 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -14,8 +14,7 @@ #include "esphome/components/power_supply/power_supply.h" #endif -namespace esphome { -namespace light { +namespace esphome::light { /// Convert the color information from a `LightColorValues` object to a `Color` object (does not apply brightness). Color color_from_light_color_values(LightColorValues val); @@ -116,5 +115,4 @@ class AddressableLightTransformer : public LightTransformer { Color target_color_{}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index 0847db3770..a85ea4661d 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -7,8 +7,7 @@ #include "esphome/components/light/light_state.h" #include "esphome/components/light/addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { inline static int16_t sin16_c(uint16_t theta) { static const uint16_t BASE[] = {0, 6393, 12539, 18204, 23170, 27245, 30273, 32137}; @@ -371,5 +370,4 @@ class AddressableFlickerEffect : public AddressableLightEffect { uint8_t intensity_{13}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/addressable_light_wrapper.h b/esphome/components/light/addressable_light_wrapper.h index d358502430..8665e62a79 100644 --- a/esphome/components/light/addressable_light_wrapper.h +++ b/esphome/components/light/addressable_light_wrapper.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { class AddressableLightWrapper : public light::AddressableLight { public: @@ -123,5 +122,4 @@ class AddressableLightWrapper : public light::AddressableLight { ColorMode color_mode_{ColorMode::UNKNOWN}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/automation.cpp b/esphome/components/light/automation.cpp index 8c1785f061..ddac2f9341 100644 --- a/esphome/components/light/automation.cpp +++ b/esphome/components/light/automation.cpp @@ -1,8 +1,7 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light.automation"; @@ -11,5 +10,4 @@ void addressableset_warn_about_scale(const char *field) { field); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 8899db8bba..9893c15e0c 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -4,8 +4,7 @@ #include "light_state.h" #include "addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; @@ -216,5 +215,4 @@ template class AddressableSet : public Action { } }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index 515afc5c59..2eeae574e7 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "light_effect.h" -namespace esphome { -namespace light { +namespace esphome::light { inline static float random_cubic_float() { const float r = random_float() * 2.0f - 1.0f; @@ -235,5 +234,4 @@ class FlickerLightEffect : public LightEffect { float alpha_{}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/color_mode.h b/esphome/components/light/color_mode.h index aa3448c145..0750ae250d 100644 --- a/esphome/components/light/color_mode.h +++ b/esphome/components/light/color_mode.h @@ -3,8 +3,7 @@ #include #include "esphome/core/finite_set_mask.h" -namespace esphome { -namespace light { +namespace esphome::light { /// Color capabilities are the various outputs that a light has and that can be independently controlled by the user. enum class ColorCapability : uint8_t { @@ -210,5 +209,4 @@ inline bool has_capability(const ColorModeMask &mask, ColorCapability capability return (mask.get_mask() & CAPABILITY_BITMASKS[capability_to_index(capability)]) != 0; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index e5e68264cc..1b511a94b2 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -2,8 +2,7 @@ #include "light_color_values.h" #include "esphome/core/log.h" -namespace esphome { -namespace light { +namespace esphome::light { void ESPColorCorrection::calculate_gamma_table(float gamma) { for (uint16_t i = 0; i < 256; i++) { @@ -23,5 +22,4 @@ void ESPColorCorrection::calculate_gamma_table(float gamma) { } } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 14c065058c..d275e045b7 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -2,8 +2,7 @@ #include "esphome/core/color.h" -namespace esphome { -namespace light { +namespace esphome::light { class ESPColorCorrection { public: @@ -73,5 +72,4 @@ class ESPColorCorrection { uint8_t local_brightness_{255}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 35117e7dd8..440a23e9c9 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -4,8 +4,7 @@ #include "esp_hsv_color.h" #include "esp_color_correction.h" -namespace esphome { -namespace light { +namespace esphome::light { class ESPColorSettable { public: @@ -106,5 +105,4 @@ class ESPColorView : public ESPColorSettable { const ESPColorCorrection *color_correction_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_hsv_color.cpp b/esphome/components/light/esp_hsv_color.cpp index 450c2e11ce..07205ea6d0 100644 --- a/esphome/components/light/esp_hsv_color.cpp +++ b/esphome/components/light/esp_hsv_color.cpp @@ -1,7 +1,6 @@ #include "esp_hsv_color.h" -namespace esphome { -namespace light { +namespace esphome::light { Color ESPHSVColor::to_rgb() const { // based on FastLED's hsv rainbow to rgb @@ -70,5 +69,4 @@ Color ESPHSVColor::to_rgb() const { return rgb; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_hsv_color.h b/esphome/components/light/esp_hsv_color.h index cdde91c71c..4b54039258 100644 --- a/esphome/components/light/esp_hsv_color.h +++ b/esphome/components/light/esp_hsv_color.h @@ -3,8 +3,7 @@ #include "esphome/core/color.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace light { +namespace esphome::light { struct ESPHSVColor { union { @@ -32,5 +31,4 @@ struct ESPHSVColor { Color to_rgb() const; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index e1f0a507bd..58d552031a 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -1,8 +1,7 @@ #include "esp_range_view.h" #include "addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { int32_t HOT interpret_index(int32_t index, int32_t size) { if (index < 0) @@ -92,5 +91,4 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT ESPColorView ESPRangeIterator::operator*() const { return this->range_.parent_->get(this->i_); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index 07d18af79f..f5e4ebb83f 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -3,8 +3,7 @@ #include "esp_color_view.h" #include "esp_hsv_color.h" -namespace esphome { -namespace light { +namespace esphome::light { int32_t interpret_index(int32_t index, int32_t size); @@ -76,5 +75,4 @@ class ESPRangeIterator { int32_t i_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index b15ff84b97..b3bdb16c73 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/optional.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light"; @@ -647,5 +646,4 @@ LightCall &LightCall::set_rgbw(float red, float green, float blue, float white) return *this; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 04d7d1e7d8..bedfad2c35 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -4,8 +4,7 @@ #include "color_mode.h" #include -namespace esphome { -namespace light { +namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } @@ -310,5 +309,4 @@ class LightColorValues { ColorMode color_mode_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_effect.cpp b/esphome/components/light/light_effect.cpp index a210b48e5b..81b923f7f9 100644 --- a/esphome/components/light/light_effect.cpp +++ b/esphome/components/light/light_effect.cpp @@ -1,8 +1,7 @@ #include "light_effect.h" #include "light_state.h" -namespace esphome { -namespace light { +namespace esphome::light { uint32_t LightEffect::get_index() const { if (this->state_ == nullptr) { @@ -32,5 +31,4 @@ uint32_t LightEffect::get_index_in_parent_() const { return 0; // Not found } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_effect.h b/esphome/components/light/light_effect.h index d4c2dc3582..aa1f6f7899 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -2,8 +2,7 @@ #include "esphome/core/component.h" -namespace esphome { -namespace light { +namespace esphome::light { class LightState; @@ -55,5 +54,4 @@ class LightEffect { uint32_t get_index_in_parent_() const; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index e754c453b5..1c9b92f504 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -3,8 +3,7 @@ #ifdef USE_JSON -namespace esphome { -namespace light { +namespace esphome::light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema @@ -169,7 +168,6 @@ void LightJSONSchema::parse_json(LightState &state, LightCall &call, JsonObject } } -} // namespace light -} // namespace esphome +} // namespace esphome::light #endif diff --git a/esphome/components/light/light_json_schema.h b/esphome/components/light/light_json_schema.h index c92dd7b655..dac81e32e3 100644 --- a/esphome/components/light/light_json_schema.h +++ b/esphome/components/light/light_json_schema.h @@ -8,8 +8,7 @@ #include "light_call.h" #include "light_state.h" -namespace esphome { -namespace light { +namespace esphome::light { class LightJSONSchema { public: @@ -22,7 +21,6 @@ class LightJSONSchema { static void parse_color_json(LightState &state, LightCall &call, JsonObject root); }; -} // namespace light -} // namespace esphome +} // namespace esphome::light #endif diff --git a/esphome/components/light/light_output.cpp b/esphome/components/light/light_output.cpp index e805a0b694..a86e8e5bf1 100644 --- a/esphome/components/light/light_output.cpp +++ b/esphome/components/light/light_output.cpp @@ -1,12 +1,10 @@ #include "light_output.h" #include "transformers.h" -namespace esphome { -namespace light { +namespace esphome::light { std::unique_ptr LightOutput::create_default_transition() { return make_unique(); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_output.h b/esphome/components/light/light_output.h index 73ba0371cd..c82d270be8 100644 --- a/esphome/components/light/light_output.h +++ b/esphome/components/light/light_output.h @@ -5,8 +5,7 @@ #include "light_state.h" #include "light_transformer.h" -namespace esphome { -namespace light { +namespace esphome::light { /// Interface to write LightStates to hardware. class LightOutput { @@ -29,5 +28,4 @@ class LightOutput { virtual void write_state(LightState *state) = 0; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 4c253ec5a8..36b2af03a5 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -5,8 +5,7 @@ #include "light_output.h" #include "transformers.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light"; @@ -304,5 +303,4 @@ void LightState::save_remote_values_() { this->rtc_.save(&saved); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index bf63c0ec27..06519cdc14 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -15,8 +15,7 @@ #include #include -namespace esphome { -namespace light { +namespace esphome::light { class LightOutput; @@ -298,5 +297,4 @@ class LightState : public EntityBase, public Component { LightRestoreMode restore_mode_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_transformer.h b/esphome/components/light/light_transformer.h index a84183c03c..079c2d2ae0 100644 --- a/esphome/components/light/light_transformer.h +++ b/esphome/components/light/light_transformer.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "light_color_values.h" -namespace esphome { -namespace light { +namespace esphome::light { /// Base class for all light color transformers, such as transitions or flashes. class LightTransformer { @@ -59,5 +58,4 @@ class LightTransformer { LightColorValues target_values_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 71d41a66d3..a26713b723 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -6,8 +6,7 @@ #include "light_state.h" #include "light_transformer.h" -namespace esphome { -namespace light { +namespace esphome::light { class LightTransitionTransformer : public LightTransformer { public: @@ -118,5 +117,4 @@ class LightFlashTransformer : public LightTransformer { bool begun_lightstate_restore_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light From b3ef05e5e137fd47ec4f781050af9a571f7689b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 22:00:39 -0600 Subject: [PATCH 059/320] [ld24xx] Modernize namespace declarations to C++17 syntax (#11988) --- esphome/components/ld2410/automation.h | 6 ++---- esphome/components/ld2410/button/factory_reset_button.cpp | 6 ++---- esphome/components/ld2410/button/factory_reset_button.h | 6 ++---- esphome/components/ld2410/button/query_button.cpp | 6 ++---- esphome/components/ld2410/button/query_button.h | 6 ++---- esphome/components/ld2410/button/restart_button.cpp | 6 ++---- esphome/components/ld2410/button/restart_button.h | 6 ++---- esphome/components/ld2410/ld2410.cpp | 6 ++---- esphome/components/ld2410/ld2410.h | 6 ++---- esphome/components/ld2410/number/gate_threshold_number.cpp | 6 ++---- esphome/components/ld2410/number/gate_threshold_number.h | 6 ++---- esphome/components/ld2410/number/light_threshold_number.cpp | 6 ++---- esphome/components/ld2410/number/light_threshold_number.h | 6 ++---- .../ld2410/number/max_distance_timeout_number.cpp | 6 ++---- .../components/ld2410/number/max_distance_timeout_number.h | 6 ++---- esphome/components/ld2410/select/baud_rate_select.cpp | 6 ++---- esphome/components/ld2410/select/baud_rate_select.h | 6 ++---- .../components/ld2410/select/distance_resolution_select.cpp | 6 ++---- .../components/ld2410/select/distance_resolution_select.h | 6 ++---- .../components/ld2410/select/light_out_control_select.cpp | 6 ++---- esphome/components/ld2410/select/light_out_control_select.h | 6 ++---- esphome/components/ld2410/switch/bluetooth_switch.cpp | 6 ++---- esphome/components/ld2410/switch/bluetooth_switch.h | 6 ++---- .../components/ld2410/switch/engineering_mode_switch.cpp | 6 ++---- esphome/components/ld2410/switch/engineering_mode_switch.h | 6 ++---- esphome/components/ld2412/button/factory_reset_button.cpp | 6 ++---- esphome/components/ld2412/button/factory_reset_button.h | 6 ++---- esphome/components/ld2412/button/query_button.cpp | 6 ++---- esphome/components/ld2412/button/query_button.h | 6 ++---- esphome/components/ld2412/button/restart_button.cpp | 6 ++---- esphome/components/ld2412/button/restart_button.h | 6 ++---- .../button/start_dynamic_background_correction_button.cpp | 6 ++---- .../button/start_dynamic_background_correction_button.h | 6 ++---- esphome/components/ld2412/ld2412.cpp | 6 ++---- esphome/components/ld2412/ld2412.h | 6 ++---- esphome/components/ld2412/number/gate_threshold_number.cpp | 6 ++---- esphome/components/ld2412/number/gate_threshold_number.h | 6 ++---- esphome/components/ld2412/number/light_threshold_number.cpp | 6 ++---- esphome/components/ld2412/number/light_threshold_number.h | 6 ++---- .../ld2412/number/max_distance_timeout_number.cpp | 6 ++---- .../components/ld2412/number/max_distance_timeout_number.h | 6 ++---- esphome/components/ld2412/select/baud_rate_select.cpp | 6 ++---- esphome/components/ld2412/select/baud_rate_select.h | 6 ++---- .../components/ld2412/select/distance_resolution_select.cpp | 6 ++---- .../components/ld2412/select/distance_resolution_select.h | 6 ++---- .../components/ld2412/select/light_out_control_select.cpp | 6 ++---- esphome/components/ld2412/select/light_out_control_select.h | 6 ++---- esphome/components/ld2412/switch/bluetooth_switch.cpp | 6 ++---- esphome/components/ld2412/switch/bluetooth_switch.h | 6 ++---- .../components/ld2412/switch/engineering_mode_switch.cpp | 6 ++---- esphome/components/ld2412/switch/engineering_mode_switch.h | 6 ++---- .../ld2420/binary_sensor/ld2420_binary_sensor.cpp | 6 ++---- .../components/ld2420/binary_sensor/ld2420_binary_sensor.h | 6 ++---- esphome/components/ld2420/button/reconfig_buttons.cpp | 6 ++---- esphome/components/ld2420/button/reconfig_buttons.h | 6 ++---- esphome/components/ld2420/ld2420.cpp | 6 ++---- esphome/components/ld2420/ld2420.h | 6 ++---- esphome/components/ld2420/number/gate_config_number.cpp | 6 ++---- esphome/components/ld2420/number/gate_config_number.h | 6 ++---- esphome/components/ld2420/select/operating_mode_select.cpp | 6 ++---- esphome/components/ld2420/select/operating_mode_select.h | 6 ++---- esphome/components/ld2420/sensor/ld2420_sensor.cpp | 6 ++---- esphome/components/ld2420/sensor/ld2420_sensor.h | 6 ++---- .../components/ld2420/text_sensor/ld2420_text_sensor.cpp | 6 ++---- esphome/components/ld2420/text_sensor/ld2420_text_sensor.h | 6 ++---- esphome/components/ld2450/button/factory_reset_button.cpp | 6 ++---- esphome/components/ld2450/button/factory_reset_button.h | 6 ++---- esphome/components/ld2450/button/restart_button.cpp | 6 ++---- esphome/components/ld2450/button/restart_button.h | 6 ++---- esphome/components/ld2450/ld2450.cpp | 6 ++---- esphome/components/ld2450/ld2450.h | 6 ++---- .../components/ld2450/number/presence_timeout_number.cpp | 6 ++---- esphome/components/ld2450/number/presence_timeout_number.h | 6 ++---- esphome/components/ld2450/number/zone_coordinate_number.cpp | 6 ++---- esphome/components/ld2450/number/zone_coordinate_number.h | 6 ++---- esphome/components/ld2450/select/baud_rate_select.cpp | 6 ++---- esphome/components/ld2450/select/baud_rate_select.h | 6 ++---- esphome/components/ld2450/select/zone_type_select.cpp | 6 ++---- esphome/components/ld2450/select/zone_type_select.h | 6 ++---- esphome/components/ld2450/switch/bluetooth_switch.cpp | 6 ++---- esphome/components/ld2450/switch/bluetooth_switch.h | 6 ++---- esphome/components/ld2450/switch/multi_target_switch.cpp | 6 ++---- esphome/components/ld2450/switch/multi_target_switch.h | 6 ++---- esphome/components/ld24xx/ld24xx.h | 6 ++---- 84 files changed, 168 insertions(+), 336 deletions(-) diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h index f4f1c197b2..614453b575 100644 --- a/esphome/components/ld2410/automation.h +++ b/esphome/components/ld2410/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { template class BluetoothPasswordSetAction : public Action { public: @@ -18,5 +17,4 @@ template class BluetoothPasswordSetAction : public Action LD2410Component *ld2410_comp_; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/factory_reset_button.cpp b/esphome/components/ld2410/button/factory_reset_button.cpp index a848b02a9d..0223df7086 100644 --- a/esphome/components/ld2410/button/factory_reset_button.cpp +++ b/esphome/components/ld2410/button/factory_reset_button.cpp @@ -1,9 +1,7 @@ #include "factory_reset_button.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void FactoryResetButton::press_action() { this->parent_->factory_reset(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/factory_reset_button.h b/esphome/components/ld2410/button/factory_reset_button.h index 45bf979033..715a8c4056 100644 --- a/esphome/components/ld2410/button/factory_reset_button.h +++ b/esphome/components/ld2410/button/factory_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class FactoryResetButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class FactoryResetButton : public button::Button, public Parentedparent_->read_all_info(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/query_button.h b/esphome/components/ld2410/button/query_button.h index c7a47e32d8..7a786901ae 100644 --- a/esphome/components/ld2410/button/query_button.h +++ b/esphome/components/ld2410/button/query_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class QueryButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class QueryButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/restart_button.cpp b/esphome/components/ld2410/button/restart_button.cpp index de0d36c1ef..0d5002d3c6 100644 --- a/esphome/components/ld2410/button/restart_button.cpp +++ b/esphome/components/ld2410/button/restart_button.cpp @@ -1,9 +1,7 @@ #include "restart_button.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void RestartButton::press_action() { this->parent_->restart_and_read_all_info(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/restart_button.h b/esphome/components/ld2410/button/restart_button.h index d00dc05a53..9bf8639a8c 100644 --- a/esphome/components/ld2410/button/restart_button.h +++ b/esphome/components/ld2410/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 391f2024cd..bb2e4e2f4c 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -9,8 +9,7 @@ #include "esphome/core/application.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { static const char *const TAG = "ld2410"; @@ -782,5 +781,4 @@ void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { } #endif -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 52cf76b5b6..efe585fb76 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -29,8 +29,7 @@ #include -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { using namespace ld24xx; @@ -133,5 +132,4 @@ class LD2410Component : public Component, public uart::UARTDevice { #endif }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/gate_threshold_number.cpp b/esphome/components/ld2410/number/gate_threshold_number.cpp index 5d040554d7..65e864a4d7 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.cpp +++ b/esphome/components/ld2410/number/gate_threshold_number.cpp @@ -1,7 +1,6 @@ #include "gate_threshold_number.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} @@ -10,5 +9,4 @@ void GateThresholdNumber::control(float value) { this->parent_->set_gate_threshold(this->gate_); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/gate_threshold_number.h b/esphome/components/ld2410/number/gate_threshold_number.h index 2806ecce63..63491f18d3 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.h +++ b/esphome/components/ld2410/number/gate_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class GateThresholdNumber : public number::Number, public Parented { public: @@ -15,5 +14,4 @@ class GateThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_light_out_control(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/light_threshold_number.h b/esphome/components/ld2410/number/light_threshold_number.h index 8f014373c0..3c5e433416 100644 --- a/esphome/components/ld2410/number/light_threshold_number.h +++ b/esphome/components/ld2410/number/light_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class LightThresholdNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class LightThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_max_distances_timeout(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/max_distance_timeout_number.h b/esphome/components/ld2410/number/max_distance_timeout_number.h index 7d91b4b5fe..35f4cbbfae 100644 --- a/esphome/components/ld2410/number/max_distance_timeout_number.h +++ b/esphome/components/ld2410/number/max_distance_timeout_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class MaxDistanceTimeoutNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MaxDistanceTimeoutNumber : public number::Number, public Parentedpublish_state(index); this->parent_->set_baud_rate(this->option_at(index)); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index 9385c8cf7e..fb1d016b1f 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class BaudRateSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class BaudRateSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/distance_resolution_select.cpp b/esphome/components/ld2410/select/distance_resolution_select.cpp index 4fc4c5af02..635bf206d3 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.cpp +++ b/esphome/components/ld2410/select/distance_resolution_select.cpp @@ -1,12 +1,10 @@ #include "distance_resolution_select.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void DistanceResolutionSelect::control(size_t index) { this->publish_state(index); this->parent_->set_distance_resolution(this->option_at(index)); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index 1a04f843a6..be2389d36e 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class DistanceResolutionSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class DistanceResolutionSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_light_out_control(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index e8cd8f1d6a..608c311af4 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class LightOutControlSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class LightOutControlSelect : public select::Select, public Parentedpublish_state(state); this->parent_->set_bluetooth(state); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/switch/bluetooth_switch.h b/esphome/components/ld2410/switch/bluetooth_switch.h index 35ae1ec0c9..07804e2292 100644 --- a/esphome/components/ld2410/switch/bluetooth_switch.h +++ b/esphome/components/ld2410/switch/bluetooth_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class BluetoothSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BluetoothSwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.cpp b/esphome/components/ld2410/switch/engineering_mode_switch.cpp index 967c87c887..4f2f08b03e 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.cpp +++ b/esphome/components/ld2410/switch/engineering_mode_switch.cpp @@ -1,12 +1,10 @@ #include "engineering_mode_switch.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void EngineeringModeSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_engineering_mode(state); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.h b/esphome/components/ld2410/switch/engineering_mode_switch.h index e521200cd6..4dd8e16653 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.h +++ b/esphome/components/ld2410/switch/engineering_mode_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class EngineeringModeSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class EngineeringModeSwitch : public switch_::Switch, public Parentedparent_->factory_reset(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h index 36a3fffcd5..1ef6b23b80 100644 --- a/esphome/components/ld2412/button/factory_reset_button.h +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class FactoryResetButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class FactoryResetButton : public button::Button, public Parentedparent_->read_all_info(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h index 595ef6d1e9..373e135802 100644 --- a/esphome/components/ld2412/button/query_button.h +++ b/esphome/components/ld2412/button/query_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class QueryButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class QueryButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/restart_button.cpp b/esphome/components/ld2412/button/restart_button.cpp index aca0d17841..430f6c998f 100644 --- a/esphome/components/ld2412/button/restart_button.cpp +++ b/esphome/components/ld2412/button/restart_button.cpp @@ -1,9 +1,7 @@ #include "restart_button.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void RestartButton::press_action() { this->parent_->restart_and_read_all_info(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h index 5cd582e2a3..80c79f5e7d 100644 --- a/esphome/components/ld2412/button/restart_button.h +++ b/esphome/components/ld2412/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp b/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp index 9b37243b82..8ba41a03fb 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp @@ -2,10 +2,8 @@ #include "restart_button.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void StartDynamicBackgroundCorrectionButton::press_action() { this->parent_->start_dynamic_background_correction(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h index 3af0a8a149..b1f2127896 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class StartDynamicBackgroundCorrectionButton : public button::Button, public Par void press_action() override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 4f2fd7c2bd..0f6fe62d30 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -10,8 +10,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { static const char *const TAG = "ld2412"; @@ -855,5 +854,4 @@ void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { } #endif -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 2bed34bdd8..5dd5e7bcde 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -29,8 +29,7 @@ #include -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { using namespace ld24xx; @@ -137,5 +136,4 @@ class LD2412Component : public Component, public uart::UARTDevice { #endif }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 47f8cd9107..8d12bad115 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -1,7 +1,6 @@ #include "gate_threshold_number.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} @@ -10,5 +9,4 @@ void GateThresholdNumber::control(float value) { this->parent_->set_gate_threshold(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 61d9945a0a..78c2e54d82 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class GateThresholdNumber : public number::Number, public Parented { public: @@ -15,5 +14,4 @@ class GateThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_light_out_control(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index d8727d3c98..81fd73111c 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class LightThresholdNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class LightThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_basic_config(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h index af0dcf68c5..c1e947fa19 100644 --- a/esphome/components/ld2412/number/max_distance_timeout_number.h +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class MaxDistanceTimeoutNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MaxDistanceTimeoutNumber : public number::Number, public Parentedpublish_state(index); this->parent_->set_baud_rate(this->option_at(index)); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index ffe0329341..4666dd2fa0 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class BaudRateSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class BaudRateSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/distance_resolution_select.cpp b/esphome/components/ld2412/select/distance_resolution_select.cpp index 5a6f46a071..95b80f87fb 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.cpp +++ b/esphome/components/ld2412/select/distance_resolution_select.cpp @@ -1,12 +1,10 @@ #include "distance_resolution_select.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void DistanceResolutionSelect::control(size_t index) { this->publish_state(index); this->parent_->set_distance_resolution(this->option_at(index)); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index 842f63b7b1..d3b7fad2f9 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class DistanceResolutionSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class DistanceResolutionSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_light_out_control(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index 7a50970d0d..9f86189878 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class LightOutControlSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class LightOutControlSelect : public select::Select, public Parentedpublish_state(state); this->parent_->set_bluetooth(state); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 730d338d87..0c0d1fa550 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class BluetoothSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BluetoothSwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.cpp b/esphome/components/ld2412/switch/engineering_mode_switch.cpp index 29ca0c22a8..28b4e5d9e6 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.cpp +++ b/esphome/components/ld2412/switch/engineering_mode_switch.cpp @@ -1,12 +1,10 @@ #include "engineering_mode_switch.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void EngineeringModeSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_engineering_mode(state); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index aaa404c673..4e75a8a185 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class EngineeringModeSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class EngineeringModeSwitch : public switch_::Switch, public Parentedpresence_bsensor_); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h index ee06439090..ec52312f92 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor { public: @@ -21,5 +20,4 @@ class LD2420BinarySensor : public LD2420Listener, public Component, binary_senso binary_sensor::BinarySensor *presence_bsensor_{nullptr}; }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/button/reconfig_buttons.cpp b/esphome/components/ld2420/button/reconfig_buttons.cpp index fb8ec2b5a6..1e748e59b8 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.cpp +++ b/esphome/components/ld2420/button/reconfig_buttons.cpp @@ -4,13 +4,11 @@ static const char *const TAG = "ld2420.button"; -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { void LD2420ApplyConfigButton::press_action() { this->parent_->apply_config_action(); } void LD2420RevertConfigButton::press_action() { this->parent_->revert_config_action(); } void LD2420RestartModuleButton::press_action() { this->parent_->restart_module_action(); } void LD2420FactoryResetButton::press_action() { this->parent_->factory_reset_action(); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h index 4e9e7a3692..72171ef386 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.h +++ b/esphome/components/ld2420/button/reconfig_buttons.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2420.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420ApplyConfigButton : public button::Button, public Parented { public: @@ -38,5 +37,4 @@ class LD2420FactoryResetButton : public button::Button, public Parented listeners_{}; }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/number/gate_config_number.cpp b/esphome/components/ld2420/number/gate_config_number.cpp index a373753770..998eed2188 100644 --- a/esphome/components/ld2420/number/gate_config_number.cpp +++ b/esphome/components/ld2420/number/gate_config_number.cpp @@ -4,8 +4,7 @@ static const char *const TAG = "ld2420.number"; -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { void LD2420TimeoutNumber::control(float timeout) { this->publish_state(timeout); @@ -69,5 +68,4 @@ void LD2420StillThresholdNumbers::control(float still_threshold) { } } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index 459a8026e3..8a8b9c61b1 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2420.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420TimeoutNumber : public number::Number, public Parented { public: @@ -74,5 +73,4 @@ class LD2420MoveThresholdNumbers : public number::Number, public Parentedparent_->set_operating_mode(this->option_at(index)); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index f59eb33432..c1b8e0b11b 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/select/select.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420Select : public Component, public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class LD2420Select : public Component, public select::Select, public Parenteddistance_sensor_); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h index 82730d60e3..4849cfa047 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.h +++ b/esphome/components/ld2420/sensor/ld2420_sensor.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { public: @@ -30,5 +29,4 @@ class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { std::vector energy_sensors_ = std::vector(TOTAL_GATES); }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp index f647a36936..f7b016c9d9 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { static const char *const TAG = "ld2420.text_sensor"; @@ -12,5 +11,4 @@ void LD2420TextSensor::dump_config() { LOG_TEXT_SENSOR(" ", "Firmware", this->fw_version_text_sensor_); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h index 073ddd5d0f..1932eaaf69 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor { public: @@ -20,5 +19,4 @@ class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::T text_sensor::TextSensor *fw_version_text_sensor_{nullptr}; }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2450/button/factory_reset_button.cpp b/esphome/components/ld2450/button/factory_reset_button.cpp index bcac7ada2f..7a8eb5b0dd 100644 --- a/esphome/components/ld2450/button/factory_reset_button.cpp +++ b/esphome/components/ld2450/button/factory_reset_button.cpp @@ -1,9 +1,7 @@ #include "factory_reset_button.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void FactoryResetButton::press_action() { this->parent_->factory_reset(); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 8e80347119..392fc67ffd 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class FactoryResetButton : public button::Button, public Parentedparent_->restart_and_read_all_info(); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index a44ae5a4d2..9219011f8b 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 8e5287aec7..e69ef31d4f 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -13,8 +13,7 @@ #include #include -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { static const char *const TAG = "ld2450"; @@ -939,5 +938,4 @@ float LD2450Component::restore_from_flash_() { } #endif -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 44b63be444..b94c3cac37 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -31,8 +31,7 @@ #include -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { using namespace ld24xx; @@ -193,5 +192,4 @@ class LD2450Component : public Component, public uart::UARTDevice { #endif }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/presence_timeout_number.cpp b/esphome/components/ld2450/number/presence_timeout_number.cpp index ecfe71f484..19a1ada0d7 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.cpp +++ b/esphome/components/ld2450/number/presence_timeout_number.cpp @@ -1,12 +1,10 @@ #include "presence_timeout_number.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void PresenceTimeoutNumber::control(float value) { this->publish_state(value); this->parent_->set_presence_timeout(); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index b18699792f..09c8afca55 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class PresenceTimeoutNumber : public number::Number, public Parentedparent_->set_zone_coordinate(this->zone_); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/zone_coordinate_number.h b/esphome/components/ld2450/number/zone_coordinate_number.h index 72b83889c4..f5a389d712 100644 --- a/esphome/components/ld2450/number/zone_coordinate_number.h +++ b/esphome/components/ld2450/number/zone_coordinate_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class ZoneCoordinateNumber : public number::Number, public Parented { public: @@ -15,5 +14,4 @@ class ZoneCoordinateNumber : public number::Number, public Parentedpublish_state(index); this->parent_->set_baud_rate(this->option_at(index)); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index 22810d5f13..cb53118170 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class BaudRateSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/select/zone_type_select.cpp b/esphome/components/ld2450/select/zone_type_select.cpp index 1111428c7c..39642b99ad 100644 --- a/esphome/components/ld2450/select/zone_type_select.cpp +++ b/esphome/components/ld2450/select/zone_type_select.cpp @@ -1,12 +1,10 @@ #include "zone_type_select.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void ZoneTypeSelect::control(size_t index) { this->publish_state(index); this->parent_->set_zone_type(this->option_at(index)); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index fc95ec1021..566346eb48 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class ZoneTypeSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/bluetooth_switch.cpp b/esphome/components/ld2450/switch/bluetooth_switch.cpp index fa0d4fb06a..0e19a3e6c6 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.cpp +++ b/esphome/components/ld2450/switch/bluetooth_switch.cpp @@ -1,12 +1,10 @@ #include "bluetooth_switch.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void BluetoothSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_bluetooth(state); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3c1c4f755c..3d48a89b57 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BluetoothSwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/multi_target_switch.cpp b/esphome/components/ld2450/switch/multi_target_switch.cpp index a163e29fc5..0b1cb04a68 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.cpp +++ b/esphome/components/ld2450/switch/multi_target_switch.cpp @@ -1,12 +1,10 @@ #include "multi_target_switch.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void MultiTargetSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_multi_target(state); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index ca6253588d..739f308cce 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class MultiTargetSwitch : public switch_::Switch, public Parented> 8) #define lowbyte(val) (uint8_t)((val) &0xff) -namespace esphome { -namespace ld24xx { +namespace esphome::ld24xx { static const char *const UNKNOWN_MAC = "unknown"; static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; @@ -83,5 +82,4 @@ template class SensorWithDedup { Deduplicator publish_dedup; }; #endif -} // namespace ld24xx -} // namespace esphome +} // namespace esphome::ld24xx From 100ea46f03eb45b10beed6f8d36133f9d2635e70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 23:19:54 -0600 Subject: [PATCH 060/320] [tests] Fix SNTP time ID conflicts in component tests for grouped testing (#11990) --- tests/components/lvgl/common.yaml | 4 ++-- tests/components/lvgl/lvgl-package.yaml | 6 +++--- tests/components/mqtt/common.yaml | 1 + tests/components/uptime/common.yaml | 1 + tests/components/wireguard/common.yaml | 2 ++ 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index c70dd7568d..652ae7e7a1 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -115,8 +115,8 @@ wifi: password: PASSWORD123 time: - platform: sntp - id: time_id + - platform: sntp + id: sntp_time text: - id: lvgl_text diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index eabceff9d9..5839643638 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -478,19 +478,19 @@ lvgl: id: hello_label text: time_format: "%c" - time: time_id + time: sntp_time - lvgl.label.update: id: hello_label text: time_format: "%c" - time: !lambda return id(time_id).now(); + time: !lambda return id(sntp_time).now(); - lvgl.label.update: id: hello_label text: time_format: "%c" time: !lambda |- ESP_LOGD("label", "multi-line lambda"); - return id(time_id).now(); + return id(sntp_time).now(); on_value: logger.log: format: "state now %d" diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 3f1b83bb01..284ac30337 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -4,6 +4,7 @@ wifi: time: - platform: sntp + id: sntp_time mqtt: broker: "192.168.178.84" diff --git a/tests/components/uptime/common.yaml b/tests/components/uptime/common.yaml index 86b764e7ff..279258c670 100644 --- a/tests/components/uptime/common.yaml +++ b/tests/components/uptime/common.yaml @@ -3,6 +3,7 @@ wifi: time: - platform: sntp + id: sntp_time sensor: - platform: uptime diff --git a/tests/components/wireguard/common.yaml b/tests/components/wireguard/common.yaml index cd7ab1075e..342ffa32f6 100644 --- a/tests/components/wireguard/common.yaml +++ b/tests/components/wireguard/common.yaml @@ -4,8 +4,10 @@ wifi: time: - platform: sntp + id: sntp_time wireguard: + time_id: sntp_time address: 172.16.34.100 netmask: 255.255.255.0 # NEVER use the following keys for your VPN -- they are now public! From f2b10ad132dcc4118cd6e0ecbead9da746863b77 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:12:34 -0500 Subject: [PATCH 061/320] [text_sensor] Fix infinite loop in substitute filter (#11989) Co-authored-by: J. Nick Koston --- esphome/components/text_sensor/filter.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index a242b43b1c..40a37febee 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -66,10 +66,14 @@ SubstituteFilter::SubstituteFilter(const std::initializer_list &su : substitutions_(substitutions) {} optional SubstituteFilter::new_value(std::string value) { - std::size_t pos; for (const auto &sub : this->substitutions_) { - while ((pos = value.find(sub.from)) != std::string::npos) + std::size_t pos = 0; + while ((pos = value.find(sub.from, pos)) != std::string::npos) { value.replace(pos, sub.from.size(), sub.to); + // Advance past the replacement to avoid infinite loop when + // the replacement contains the search pattern (e.g., f -> foo) + pos += sub.to.size(); + } } return value; } From 73bc5252a1467a7530b921eb9be16ab6819f3038 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:12:57 -0500 Subject: [PATCH 062/320] [wifi] Fix positive RSSI values on 8266 (#11994) --- esphome/components/wifi/wifi_component_esp8266.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index a543628e27..274a505db2 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -872,7 +872,13 @@ bssid_t WiFiComponent::wifi_bssid() { return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; } +int8_t WiFiComponent::wifi_rssi() { + if (WiFi.status() != WL_CONNECTED) + return WIFI_RSSI_DISCONNECTED; + int8_t rssi = WiFi.RSSI(); + // Values >= 31 are error codes per NONOS SDK API, not valid RSSI readings + return rssi >= 31 ? WIFI_RSSI_DISCONNECTED : rssi; +} 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()}; } From 61cef0a75c4b46df6e0d7761c78dbedfdb8aee72 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:58:47 -0500 Subject: [PATCH 063/320] [api] Fix format warnings in dump (#11999) --- esphome/components/api/api_pb2_dump.cpp | 2 +- script/api_protobuf/api_protobuf.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d9662483bf..127ef44cd8 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -66,7 +66,7 @@ static void dump_field(std::string &out, const char *field_name, float value, in static void dump_field(std::string &out, const char *field_name, uint64_t value, int indent = 2) { char buffer[64]; append_field_prefix(out, field_name, indent); - snprintf(buffer, 64, "%llu", value); + snprintf(buffer, 64, "%" PRIu64, value); append_with_newline(out, buffer); } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3b756095a1..b07a249c8d 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -462,7 +462,7 @@ class Int64Type(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%lld", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRId64, {name});\n' o += "out.append(buffer);" return o @@ -482,7 +482,7 @@ class UInt64Type(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%llu", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRIu64, {name});\n' o += "out.append(buffer);" return o @@ -522,7 +522,7 @@ class Fixed64Type(TypeInfo): wire_type = WireType.FIXED64 # Uses wire type 1 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%llu", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRIu64, {name});\n' o += "out.append(buffer);" return o @@ -1106,7 +1106,7 @@ class SFixed64Type(TypeInfo): wire_type = WireType.FIXED64 # Uses wire type 1 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%lld", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRId64, {name});\n' o += "out.append(buffer);" return o @@ -1150,7 +1150,7 @@ class SInt64Type(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%lld", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRId64, {name});\n' o += "out.append(buffer);" return o @@ -2546,7 +2546,7 @@ static void dump_field(std::string &out, const char *field_name, float value, in static void dump_field(std::string &out, const char *field_name, uint64_t value, int indent = 2) { char buffer[64]; append_field_prefix(out, field_name, indent); - snprintf(buffer, 64, "%llu", value); + snprintf(buffer, 64, "%" PRIu64, value); append_with_newline(out, buffer); } From 8804bc28154abb1601cd029971cc0abe12a2f49a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Nov 2025 12:58:33 -0600 Subject: [PATCH 064/320] [web_server_idf] Fix pbuf_free crash by moving shutdown before close (#11995) --- .../web_server_idf/web_server_idf.cpp | 41 ++++++++++++++----- .../web_server_idf/web_server_idf.h | 1 + 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index f5a66f6bd9..c910ed06c5 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -87,6 +87,29 @@ int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_ } } // namespace +void AsyncWebServer::safe_close_with_shutdown(httpd_handle_t hd, int sockfd) { + // CRITICAL: Shut down receive BEFORE closing to prevent lwIP race conditions + // + // The race condition occurs because close() initiates lwIP teardown while + // the TCP/IP thread can still receive packets, causing assertions when + // recv_tcp() sees partially-torn-down state. + // + // By shutting down receive first, we tell lwIP to stop accepting new data BEFORE + // the teardown begins, eliminating the race window. We only shutdown RD (not RDWR) + // to allow the FIN packet to be sent cleanly during close(). + // + // Note: This function may be called with an already-closed socket if the network + // stack closed it. In that case, shutdown() will fail but close() is safe to call. + // + // See: https://github.com/esphome/esphome-webserver/issues/163 + + // Attempt shutdown - ignore errors as socket may already be closed + shutdown(sockfd, SHUT_RD); + + // Always close - safe even if socket is already closed by network stack + close(sockfd); +} + void AsyncWebServer::end() { if (this->server_) { httpd_stop(this->server_); @@ -115,6 +138,8 @@ void AsyncWebServer::begin() { config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) config.lru_purge_enable = this->lru_purge_enable_; + // Use custom close function that shuts down before closing to prevent lwIP race conditions + config.close_fn = AsyncWebServer::safe_close_with_shutdown; if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", @@ -505,17 +530,11 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); int fd = rsp->fd_.exchange(0); // Atomically get and clear fd - - if (fd > 0) { - ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd); - // Immediately shut down the socket to prevent lwIP from delivering more data - // This prevents "recv_tcp: recv for wrong pcb!" assertions when the TCP stack - // tries to deliver queued data after the session is marked as dead - // See: https://github.com/esphome/esphome/issues/11936 - shutdown(fd, SHUT_RDWR); - // Note: We don't close() the socket - httpd owns it and will close it - } - // Session will be cleaned up in the main loop to avoid race conditions + ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd); + // Mark as dead - will be cleaned up in the main loop + // Note: We don't delete or remove from set here to avoid race conditions + // httpd will call our custom close_fn (safe_close_with_shutdown) which handles + // shutdown() before close() to prevent lwIP race conditions } // helper for allowing only unique entries in the queue diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index b9f690b462..a139e9e4df 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -209,6 +209,7 @@ class AsyncWebServer { static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; + static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd); #ifdef USE_WEBSERVER_OTA esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); #endif From b02b07ffafaf7dbf0529460638608f69d095a82b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 20 Nov 2025 08:11:45 +1300 Subject: [PATCH 065/320] [epaper_spi] Add basic `7.3in-Spectra-E6` model (#12001) --- esphome/components/epaper_spi/display.py | 2 +- esphome/components/epaper_spi/models/spectra_e6.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 9ff393b397..182c37ba40 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -102,7 +102,7 @@ def customise_schema(config): """ config = cv.Schema( { - cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), + cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True, space="-"), }, extra=cv.ALLOW_EXTRA, )(config) diff --git a/esphome/components/epaper_spi/models/spectra_e6.py b/esphome/components/epaper_spi/models/spectra_e6.py index 9f0b673d69..42a5a7da72 100644 --- a/esphome/components/epaper_spi/models/spectra_e6.py +++ b/esphome/components/epaper_spi/models/spectra_e6.py @@ -32,11 +32,15 @@ class SpectraE6(EpaperModel): spectra_e6 = SpectraE6("spectra-e6") -spectra_e6.extend( - "Seeed-reTerminal-E1002", +spectra_e6_7p3 = spectra_e6.extend( + "7.3in-Spectra-E6", width=800, height=480, data_rate="20MHz", +) + +spectra_e6_7p3.extend( + "Seeed-reTerminal-E1002", cs_pin=10, dc_pin=11, reset_pin=12, From 2c3417062ae82512f51b85847ef5fe4deba41ebc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:47:40 -0600 Subject: [PATCH 066/320] Bump pyupgrade from 3.21.1 to 3.21.2 (#12002) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e238faa77e..7f6d3f8e26 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.0.3 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.5 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.21.1 # also change in .pre-commit-config.yaml when updating +pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests From 4398fd84d2ba21a3a4099946d554a6d70cc0e7ad Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:09:22 -0500 Subject: [PATCH 067/320] [graph] Fix legend border (#12000) --- esphome/components/graph/graph.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 88bb306408..e3b9119108 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -337,7 +337,7 @@ void Graph::draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_of return; /// Plot border - if (this->border_) { + if (legend_->border_) { int w = legend_->width_; int h = legend_->height_; buff->horizontal_line(x_offset, y_offset, w, color); From da25951f6e714fe483f5c382a6188225b62509da Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 20 Nov 2025 03:01:32 +0000 Subject: [PATCH 068/320] [socket] Fix IPv6 address parsing for BSD sockets (#11996) --- esphome/components/socket/socket.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 1c8e72b8fd..cc9232d21a 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -61,9 +61,18 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri server->sin6_family = AF_INET6; server->sin6_port = htons(port); +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS + // Use standard inet_pton for BSD sockets + if (inet_pton(AF_INET6, ip_address.c_str(), &server->sin6_addr) != 1) { + errno = EINVAL; + return 0; + } +#else + // Use LWIP-specific functions ip6_addr_t ip6; inet6_aton(ip_address.c_str(), &ip6); memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif return sizeof(sockaddr_in6); } #endif /* USE_NETWORK_IPV6 */ From 83307684a3dcc3e183de98e060c9324206a7a4ab Mon Sep 17 00:00:00 2001 From: B48D81EFCC <111175947+B48D81EFCC@users.noreply.github.com> Date: Thu, 20 Nov 2025 04:58:39 +0100 Subject: [PATCH 069/320] [stts22h] Add support for STTS22H temperature sensor (#11778) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/stts22h/__init__.py | 1 + esphome/components/stts22h/sensor.py | 33 ++++++ esphome/components/stts22h/stts22h.cpp | 101 ++++++++++++++++++ esphome/components/stts22h/stts22h.h | 21 ++++ tests/components/stts22h/common.yaml | 4 + tests/components/stts22h/test.esp32-idf.yaml | 4 + .../components/stts22h/test.esp8266-ard.yaml | 4 + tests/components/stts22h/test.nrf52.yaml | 4 + tests/components/stts22h/test.rp2040-ard.yaml | 4 + 10 files changed, 177 insertions(+) create mode 100644 esphome/components/stts22h/__init__.py create mode 100644 esphome/components/stts22h/sensor.py create mode 100644 esphome/components/stts22h/stts22h.cpp create mode 100644 esphome/components/stts22h/stts22h.h create mode 100644 tests/components/stts22h/common.yaml create mode 100644 tests/components/stts22h/test.esp32-idf.yaml create mode 100644 tests/components/stts22h/test.esp8266-ard.yaml create mode 100644 tests/components/stts22h/test.nrf52.yaml create mode 100644 tests/components/stts22h/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index e6970af47c..250fbbd4d4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -460,6 +460,7 @@ esphome/components/st7735/* @SenexCrenshaw esphome/components/st7789v/* @kbx81 esphome/components/st7920/* @marsjan155 esphome/components/statsd/* @Links2004 +esphome/components/stts22h/* @B48D81EFCC esphome/components/substitutions/* @esphome/core esphome/components/sun/* @OttoWinter esphome/components/sun_gtil2/* @Mat931 diff --git a/esphome/components/stts22h/__init__.py b/esphome/components/stts22h/__init__.py new file mode 100644 index 0000000000..a33c0b554b --- /dev/null +++ b/esphome/components/stts22h/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@B48D81EFCC"] diff --git a/esphome/components/stts22h/sensor.py b/esphome/components/stts22h/sensor.py new file mode 100644 index 0000000000..094c233361 --- /dev/null +++ b/esphome/components/stts22h/sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) + +DEPENDENCIES = ["i2c"] + +sensor_ns = cg.esphome_ns.namespace("stts22h") +stts22h = sensor_ns.class_( + "STTS22HComponent", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + stts22h, + accuracy_decimals=2, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x3C)) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp new file mode 100644 index 0000000000..614dc1da8b --- /dev/null +++ b/esphome/components/stts22h/stts22h.cpp @@ -0,0 +1,101 @@ +#include "esphome/core/log.h" +#include "stts22h.h" + +namespace esphome::stts22h { + +static const char *const TAG = "stts22h"; + +static const uint8_t WHOAMI_REG = 0x01; +static const uint8_t CTRL_REG = 0x04; +static const uint8_t TEMPERATURE_REG = 0x06; + +// CTRL_REG flags +static const uint8_t LOW_ODR_CTRL_ENABLE_FLAG = 0x80; // Flag to enable low ODR mode in CTRL_REG +static const uint8_t FREERUN_CTRL_ENABLE_FLAG = 0x04; // Flag to enable FREERUN mode in CTRL_REG +static const uint8_t ADD_INC_ENABLE_FLAG = 0x08; // Flag to enable ADD_INC (IF_ADD_INC) mode in CTRL_REG + +static const uint8_t WHOAMI_STTS22H_IDENTIFICATION = 0xA0; // ID value of STTS22H in WHOAMI_REG + +static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsius + +void STTS22HComponent::setup() { + // Check if device is a STTS22H + if (!this->is_stts22h_sensor_()) { + this->mark_failed("Device is not a STTS22H sensor"); + return; + } + + this->initialize_sensor_(); +} + +void STTS22HComponent::update() { + if (this->is_failed()) { + return; + } + + this->publish_state(this->read_temperature_()); +} + +void STTS22HComponent::dump_config() { + LOG_SENSOR("", "STTS22H", this); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +float STTS22HComponent::read_temperature_() { + uint8_t temp_reg_value[2]; + if (this->read_register(TEMPERATURE_REG, temp_reg_value, 2) != i2c::NO_ERROR) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return NAN; + } + + // Combine the two bytes into a single 16-bit signed integer + // The STTS22H temperature data is in two's complement format + int16_t temp_raw_value = static_cast(encode_uint16(temp_reg_value[1], temp_reg_value[0])); + return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution +} + +bool STTS22HComponent::is_stts22h_sensor_() { + uint8_t whoami_value; + if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return false; + } + + if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { + this->mark_failed("Unexpected WHOAMI identifier. Sensor is not a STTS22H"); + return false; + } + + return true; +} + +void STTS22HComponent::initialize_sensor_() { + // Read current CTRL_REG configuration + uint8_t ctrl_value; + if (this->read_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; + } + + // Enable low ODR mode and enable ADD_INC + // Before low ODR mode can be used, + // FREERUN bit must be cleared (see sensor documentation) + ctrl_value &= ~FREERUN_CTRL_ENABLE_FLAG; // Clear FREERUN bit + if (this->write_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; + } + + // Enable LOW ODR mode and ADD_INC + ctrl_value |= LOW_ODR_CTRL_ENABLE_FLAG | ADD_INC_ENABLE_FLAG; // Set LOW ODR bit and ADD_INC bit + if (this->write_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +} // namespace esphome::stts22h diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h new file mode 100644 index 0000000000..442a263e49 --- /dev/null +++ b/esphome/components/stts22h/stts22h.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::stts22h { + +class STTS22HComponent : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + void initialize_sensor_(); + bool is_stts22h_sensor_(); + float read_temperature_(); +}; + +} // namespace esphome::stts22h diff --git a/tests/components/stts22h/common.yaml b/tests/components/stts22h/common.yaml new file mode 100644 index 0000000000..802afe2065 --- /dev/null +++ b/tests/components/stts22h/common.yaml @@ -0,0 +1,4 @@ +sensor: + - platform: stts22h + name: Temperature + update_interval: 15s diff --git a/tests/components/stts22h/test.esp32-idf.yaml b/tests/components/stts22h/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/stts22h/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/test.esp8266-ard.yaml b/tests/components/stts22h/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/stts22h/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/test.nrf52.yaml b/tests/components/stts22h/test.nrf52.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/stts22h/test.nrf52.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/test.rp2040-ard.yaml b/tests/components/stts22h/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/stts22h/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From b346666a5264294afea8ce002cb5cf955b99339b Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Thu, 20 Nov 2025 10:05:22 +0100 Subject: [PATCH 070/320] [st7701s] Add explanatory comment (#12014) --- esphome/components/mipi_rgb/models/st7701s.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index bfd1c9aa3f..0b0a9548ca 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -24,6 +24,8 @@ class ST7701S(DriverChip): sdir = 0 if transform.get(CONF_MIRROR_X): sdir |= 0x04 + # XFLIP doesn't do anything in the ST7701S, + # it's set in the madctl byte just so it can be reported at runtime by logconfig madctl |= MADCTL_XFLIP sequence.append((SDIR_CMD, sdir)) return madctl From 4825da8e9ca27c1b7d9c88bd094da5a620b53a23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Nov 2025 07:57:04 -0600 Subject: [PATCH 071/320] [select] Modernize namespace declarations to C++17 syntax (#12007) --- esphome/components/select/automation.h | 6 ++---- esphome/components/select/select.cpp | 6 ++---- esphome/components/select/select.h | 6 ++---- esphome/components/select/select_call.cpp | 6 ++---- esphome/components/select/select_call.h | 6 ++---- esphome/components/select/select_traits.cpp | 6 ++---- esphome/components/select/select_traits.h | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 3e42eaf98a..768f2621f7 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "select.h" -namespace esphome { -namespace select { +namespace esphome::select { class SelectStateTrigger : public Trigger { public: @@ -63,5 +62,4 @@ template class SelectOperationAction : public Action { Select *select_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 9fe7a52422..3ec413f167 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace select { +namespace esphome::select { static const char *const TAG = "select"; @@ -86,5 +85,4 @@ optional Select::at(size_t index) const { const char *Select::option_at(size_t index) const { return traits.get_options().at(index); } -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 7459c9d146..c4d7412d50 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -6,8 +6,7 @@ #include "select_call.h" #include "select_traits.h" -namespace esphome { -namespace select { +namespace esphome::select { #define LOG_SELECT(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -114,5 +113,4 @@ class Select : public EntityBase { CallbackManager state_callback_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index aa7559e24e..aecfed0d64 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -2,8 +2,7 @@ #include "select.h" #include "esphome/core/log.h" -namespace esphome { -namespace select { +namespace esphome::select { static const char *const TAG = "select"; @@ -125,5 +124,4 @@ void SelectCall::perform() { parent->control(idx); } -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index eae7d3de1d..b31d890ef6 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" -namespace esphome { -namespace select { +namespace esphome::select { class Select; @@ -45,5 +44,4 @@ class SelectCall { bool cycle_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index e5e12bdc7a..ff52c0d85b 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -1,7 +1,6 @@ #include "select_traits.h" -namespace esphome { -namespace select { +namespace esphome::select { void SelectTraits::set_options(const std::initializer_list &options) { this->options_ = options; } @@ -14,5 +13,4 @@ void SelectTraits::set_options(const FixedVector &options) { const FixedVector &SelectTraits::get_options() const { return this->options_; } -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index ee59a030ad..78a83e5944 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include -namespace esphome { -namespace select { +namespace esphome::select { class SelectTraits { public: @@ -16,5 +15,4 @@ class SelectTraits { FixedVector options_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select From 507147376710e8d5e214d78ab51ece81f7a33339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Nov 2025 07:57:33 -0600 Subject: [PATCH 072/320] [mdns] Modernize to C++17 nested namespace syntax (#11983) --- esphome/components/mdns/mdns_component.cpp | 6 ++---- esphome/components/mdns/mdns_component.h | 6 ++---- esphome/components/mdns/mdns_esp32.cpp | 6 ++---- esphome/components/mdns/mdns_esp8266.cpp | 6 ++---- esphome/components/mdns/mdns_host.cpp | 6 ++---- esphome/components/mdns/mdns_libretiny.cpp | 6 ++---- esphome/components/mdns/mdns_rp2040.cpp | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index b66129404e..c81defd19f 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -21,8 +21,7 @@ #include "esphome/components/dashboard_import/dashboard_import.h" #endif -namespace esphome { -namespace mdns { +namespace esphome::mdns { static const char *const TAG = "mdns"; @@ -189,6 +188,5 @@ void MDNSComponent::dump_config() { #endif } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f4237d5a69..691c45b7df 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) struct MDNSString; @@ -79,6 +78,5 @@ class MDNSComponent : public Component { void compile_records_(StaticVector &services); }; -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index ecdc926cc9..5547a2524b 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -7,8 +7,7 @@ #include "esphome/core/log.h" #include "mdns_component.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { static const char *const TAG = "mdns"; @@ -56,7 +55,6 @@ void MDNSComponent::on_shutdown() { delay(40); // Allow the mdns packets announcing service removal to be sent } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif // USE_ESP32 diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 9bbb406070..06f905884c 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -9,8 +9,7 @@ #include "esphome/core/log.h" #include "mdns_component.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES @@ -52,7 +51,6 @@ void MDNSComponent::on_shutdown() { delay(10); } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index f645d8d068..64b8c8f54b 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "mdns_component.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { // Host platform doesn't have actual mDNS implementation @@ -15,7 +14,6 @@ void MDNSComponent::setup() { void MDNSComponent::on_shutdown() {} -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index fb2088f719..a049fe2109 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES @@ -46,7 +45,6 @@ void MDNSComponent::setup() { void MDNSComponent::on_shutdown() {} -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a9f5349f14..a102e0b6c3 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES @@ -51,7 +50,6 @@ void MDNSComponent::on_shutdown() { delay(40); } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif From 24a6ad148c6a6fe130e1ca74859032a2b787df3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Nov 2025 07:57:49 -0600 Subject: [PATCH 073/320] [lock] Modernize to C++17 nested namespaces (#11982) --- esphome/components/lock/automation.h | 6 ++---- esphome/components/lock/lock.cpp | 6 ++---- esphome/components/lock/lock.h | 6 ++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 0f596ef5e6..cba2c3fdda 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace lock { +namespace esphome::lock { template class LockAction : public Action { public: @@ -72,5 +71,4 @@ class LockUnlockTrigger : public Trigger<> { } }; -} // namespace lock -} // namespace esphome +} // namespace esphome::lock diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 54fefe8745..b8f0fbe011 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" -namespace esphome { -namespace lock { +namespace esphome::lock { static const char *const TAG = "lock"; @@ -108,5 +107,4 @@ LockCall &LockCall::set_state(const std::string &state) { } const optional &LockCall::get_state() const { return this->state_; } -} // namespace lock -} // namespace esphome +} // namespace esphome::lock diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 9737569921..8a906ef9fc 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -7,8 +7,7 @@ #include "esphome/core/preferences.h" #include -namespace esphome { -namespace lock { +namespace esphome::lock { class Lock; @@ -177,5 +176,4 @@ class Lock : public EntityBase { ESPPreferenceObject rtc_; }; -} // namespace lock -} // namespace esphome +} // namespace esphome::lock From a2321edf3c7dcb7b38adbc77816bcf23dcd44595 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Nov 2025 07:59:16 -0600 Subject: [PATCH 074/320] [network] Fix IPAddress constructor causing comparison failures and garbage output (#12005) --- esphome/components/network/ip_address.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 5ec6450cce..3d8b062d0b 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -81,7 +81,12 @@ struct IPAddress { ip_addr_.type = IPADDR_TYPE_V6; } #endif /* LWIP_IPV6 */ - IPAddress(esp_ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t)); } + IPAddress(esp_ip4_addr_t *other_ip) { + memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t)); +#if LWIP_IPV6 + ip_addr_.type = IPADDR_TYPE_V4; +#endif + } IPAddress(esp_ip_addr_t *other_ip) { #if LWIP_IPV6 memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip_addr_)); From b62053812b79f9fb9acc6965e7060c05951fc681 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Nov 2025 08:06:28 -0600 Subject: [PATCH 075/320] [core] Document threading model rationale in ThreadModel enum (#11979) --- esphome/components/libretiny/__init__.py | 4 ++++ esphome/const.py | 25 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c63d6d7faa..93b66888da 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -261,6 +261,10 @@ async def component_to_code(config): cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", FAMILY_FRIENDLY[config[CONF_FAMILY]]) + # LibreTiny uses MULTI_NO_ATOMICS because platforms like BK7231N (ARM968E-S) lack + # exclusive load/store (no LDREX/STREX). std::atomic RMW operations require libatomic, + # which is not linked to save flash (4-8KB). Even if linked, libatomic would use locks + # (ATOMIC_INT_LOCK_FREE=1), so explicit FreeRTOS mutexes are simpler and equivalent. cg.add_define(ThreadModel.MULTI_NO_ATOMICS) # force using arduino framework diff --git a/esphome/const.py b/esphome/const.py index b4cd3cfd1c..2b6b60d395 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -36,7 +36,30 @@ class Framework(StrEnum): class ThreadModel(StrEnum): - """Threading model identifiers for ESPHome scheduler.""" + """Threading model identifiers for ESPHome scheduler. + + ESPHome currently uses three threading models based on platform capabilities: + + SINGLE: + - Single-threaded platforms (ESP8266, RP2040) + - No RTOS task switching + - No concurrent access to scheduler data structures + - No atomics or locks required + - Minimal overhead + + MULTI_NO_ATOMICS: + - Multi-threaded platforms without hardware atomic RMW support (e.g. LibreTiny BK7231N) + - Uses FreeRTOS or another RTOS with multiple tasks + - CPU lacks exclusive load/store instructions (ARM968E-S has no LDREX/STREX) + - std::atomic cannot provide lock-free RMW; libatomic is avoided to save flash (4–8 KB) + - Scheduler uses explicit FreeRTOS mutexes for synchronization + + MULTI_ATOMICS: + - Multi-threaded platforms with hardware atomic RMW support (ESP32, Cortex-M, Host) + - CPU provides native atomic instructions (ESP32 S32C1I, ARM LDREX/STREX) + - std::atomic is used for lock-free synchronization + - Reduced contention and better performance + """ SINGLE = "ESPHOME_THREAD_SINGLE" MULTI_NO_ATOMICS = "ESPHOME_THREAD_MULTI_NO_ATOMICS" From 5d883c6e06d649651db138807ffd6cfd880c44ac Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Thu, 20 Nov 2025 15:06:40 +0100 Subject: [PATCH 076/320] [nrf52,i2c] fix review comment (#11931) --- esphome/components/i2c/i2c_bus_zephyr.cpp | 31 ++++++++++++----------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 658dcee35c..1eb9944dcb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -8,6 +8,22 @@ namespace esphome::i2c { static const char *const TAG = "i2c.zephyr"; +static const char *get_speed(uint32_t dev_config) { + switch (I2C_SPEED_GET(dev_config)) { + case I2C_SPEED_STANDARD: + return "100 kHz"; + case I2C_SPEED_FAST: + return "400 kHz"; + case I2C_SPEED_FAST_PLUS: + return "1 MHz"; + case I2C_SPEED_HIGH: + return "3.4 MHz"; + case I2C_SPEED_ULTRA: + return "5 MHz"; + } + return "unknown"; +} + void ZephyrI2CBus::setup() { if (!device_is_ready(this->i2c_dev_)) { ESP_LOGE(TAG, "I2C dev is not ready."); @@ -31,21 +47,6 @@ void ZephyrI2CBus::setup() { } void ZephyrI2CBus::dump_config() { - auto get_speed = [](uint32_t dev_config) { - switch (I2C_SPEED_GET(dev_config)) { - case I2C_SPEED_STANDARD: - return "100 kHz"; - case I2C_SPEED_FAST: - return "400 kHz"; - case I2C_SPEED_FAST_PLUS: - return "1 MHz"; - case I2C_SPEED_HIGH: - return "3.4 MHz"; - case I2C_SPEED_ULTRA: - return "5 MHz"; - } - return "unknown"; - }; ESP_LOGCONFIG(TAG, "I2C Bus:\n" " SDA Pin: GPIO%u\n" From 06bef148f432c5521c8d91743a0134f672ae4be5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Nov 2025 08:06:52 -0600 Subject: [PATCH 077/320] [core] Optimize DelayAction for no-argument case using if constexpr (#11913) --- esphome/core/base_automation.h | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index a5e6139182..c2519da839 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -178,7 +178,6 @@ template class DelayAction : public Action, public Compon TEMPLATABLE_VALUE(uint32_t, delay) void play_complex(const Ts &...x) override { - auto f = std::bind(&DelayAction::play_next_, this, x...); this->num_running_++; // If num_running_ > 1, we have multiple instances running in parallel @@ -187,9 +186,22 @@ template class DelayAction : public Action, public Compon // 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); + + // Optimization: For no-argument delays (most common case), use direct lambda + // instead of std::bind to avoid bind overhead (~16 bytes heap + faster execution) + if constexpr (sizeof...(Ts) == 0) { + App.scheduler.set_timer_common_( + this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(), [this]() { this->play_next_(); }, + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + } else { + // For delays with arguments, use std::bind to preserve argument values + // Arguments must be copied because original references may be invalid after delay + auto f = std::bind(&DelayAction::play_next_, this, x...); + 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; } From 3c86f3894b9f331f9df9d49b50725e9e7337df28 Mon Sep 17 00:00:00 2001 From: omartijn <44672243+omartijn@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:24:45 +0100 Subject: [PATCH 078/320] [hc8] Add support for HC8 CO2 sensor (#11872) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/hc8/__init__.py | 1 + esphome/components/hc8/hc8.cpp | 99 ++++++++++++++++++++++ esphome/components/hc8/hc8.h | 37 ++++++++ esphome/components/hc8/sensor.py | 79 +++++++++++++++++ tests/components/hc8/common.yaml | 13 +++ tests/components/hc8/test.esp32-idf.yaml | 4 + tests/components/hc8/test.esp8266-ard.yaml | 4 + tests/components/hc8/test.rp2040-ard.yaml | 4 + 9 files changed, 242 insertions(+) create mode 100644 esphome/components/hc8/__init__.py create mode 100644 esphome/components/hc8/hc8.cpp create mode 100644 esphome/components/hc8/hc8.h create mode 100644 esphome/components/hc8/sensor.py create mode 100644 tests/components/hc8/common.yaml create mode 100644 tests/components/hc8/test.esp32-idf.yaml create mode 100644 tests/components/hc8/test.esp8266-ard.yaml create mode 100644 tests/components/hc8/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 250fbbd4d4..c3d8f4350f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,6 +202,7 @@ esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann esphome/components/hbridge/switch/* @dwmw2 +esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch diff --git a/esphome/components/hc8/__init__.py b/esphome/components/hc8/__init__.py new file mode 100644 index 0000000000..e1028456b0 --- /dev/null +++ b/esphome/components/hc8/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@omartijn"] diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp new file mode 100644 index 0000000000..5b649c2735 --- /dev/null +++ b/esphome/components/hc8/hc8.cpp @@ -0,0 +1,99 @@ +#include "hc8.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::hc8 { + +static const char *const TAG = "hc8"; +static const std::array HC8_COMMAND_GET_PPM{0x64, 0x69, 0x03, 0x5E, 0x4E}; +static const std::array HC8_COMMAND_CALIBRATE_PREAMBLE{0x11, 0x03, 0x03}; + +void HC8Component::setup() { + // send an initial query to the device, this will + // get it out of "active output mode", where it + // generates data every second + this->write_array(HC8_COMMAND_GET_PPM); + this->flush(); + + // ensure the buffer is empty + while (this->available()) + this->read(); +} + +void HC8Component::update() { + uint32_t now_ms = App.get_loop_component_start_time(); + uint32_t warmup_ms = this->warmup_seconds_ * 1000; + if (now_ms < warmup_ms) { + ESP_LOGW(TAG, "HC8 warming up, %" PRIu32 " s left", (warmup_ms - now_ms) / 1000); + this->status_set_warning(); + return; + } + + while (this->available()) + this->read(); + + this->write_array(HC8_COMMAND_GET_PPM); + this->flush(); + + // the sensor is a bit slow in responding, so trying to + // read immediately after sending a query will timeout + this->set_timeout(50, [this]() { + std::array response; + if (!this->read_array(response.data(), response.size())) { + ESP_LOGW(TAG, "Reading data from HC8 failed!"); + this->status_set_warning(); + return; + } + + if (response[0] != 0x64 || response[1] != 0x69) { + ESP_LOGW(TAG, "Invalid preamble from HC8!"); + this->status_set_warning(); + return; + } + + if (crc16(response.data(), 12) != encode_uint16(response[13], response[12])) { + ESP_LOGW(TAG, "HC8 Checksum mismatch"); + this->status_set_warning(); + return; + } + + this->status_clear_warning(); + + const uint16_t ppm = encode_uint16(response[5], response[4]); + ESP_LOGD(TAG, "HC8 Received CO₂=%uppm", ppm); + if (this->co2_sensor_ != nullptr) + this->co2_sensor_->publish_state(ppm); + }); +} + +void HC8Component::calibrate(uint16_t baseline) { + ESP_LOGD(TAG, "HC8 Calibrating baseline to %uppm", baseline); + + std::array command{}; + std::copy(begin(HC8_COMMAND_CALIBRATE_PREAMBLE), end(HC8_COMMAND_CALIBRATE_PREAMBLE), begin(command)); + command[3] = baseline >> 8; + command[4] = baseline; + command[5] = 0; + + // the last byte is a checksum over the data + for (uint8_t i = 0; i < 5; ++i) + command[5] -= command[i]; + + this->write_array(command); + this->flush(); +} + +float HC8Component::get_setup_priority() const { return setup_priority::DATA; } + +void HC8Component::dump_config() { + ESP_LOGCONFIG(TAG, "HC8:"); + LOG_SENSOR(" ", "CO2", this->co2_sensor_); + this->check_uart_settings(9600); + + ESP_LOGCONFIG(TAG, " Warmup time: %" PRIu32 " s", this->warmup_seconds_); +} + +} // namespace esphome::hc8 diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h new file mode 100644 index 0000000000..7711fb8c97 --- /dev/null +++ b/esphome/components/hc8/hc8.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::hc8 { + +class HC8Component : public PollingComponent, public uart::UARTDevice { + public: + float get_setup_priority() const override; + + void setup() override; + void update() override; + void dump_config() override; + + void calibrate(uint16_t baseline); + + void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } + void set_warmup_seconds(uint32_t seconds) { warmup_seconds_ = seconds; } + + protected: + sensor::Sensor *co2_sensor_{nullptr}; + uint32_t warmup_seconds_{0}; +}; + +template class HC8CalibrateAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint16_t, baseline) + + void play(const Ts &...x) override { this->parent_->calibrate(this->baseline_.value(x...)); } +}; + +} // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py new file mode 100644 index 0000000000..90698b2661 --- /dev/null +++ b/esphome/components/hc8/sensor.py @@ -0,0 +1,79 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_BASELINE, + CONF_CO2, + CONF_ID, + DEVICE_CLASS_CARBON_DIOXIDE, + ICON_MOLECULE_CO2, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_MILLION, +) + +DEPENDENCIES = ["uart"] + +CONF_WARMUP_TIME = "warmup_time" + +hc8_ns = cg.esphome_ns.namespace("hc8") +HC8Component = hc8_ns.class_("HC8Component", cg.PollingComponent, uart.UARTDevice) +HC8CalibrateAction = hc8_ns.class_("HC8CalibrateAction", automation.Action) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HC8Component), + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional( + CONF_WARMUP_TIME, default="75s" + ): cv.positive_time_period_seconds, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hc8", + baud_rate=9600, + require_rx=True, + require_tx=True, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if co2 := config.get(CONF_CO2): + sens = await sensor.new_sensor(co2) + cg.add(var.set_co2_sensor(sens)) + + cg.add(var.set_warmup_seconds(config[CONF_WARMUP_TIME])) + + +CALIBRATION_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(HC8Component), + cv.Required(CONF_BASELINE): cv.templatable(cv.uint16_t), + } +) + + +@automation.register_action( + "hc8.calibrate", HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA +) +async def hc8_calibration_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) + cg.add(var.set_baseline(template_)) + return var diff --git a/tests/components/hc8/common.yaml b/tests/components/hc8/common.yaml new file mode 100644 index 0000000000..ac3b454315 --- /dev/null +++ b/tests/components/hc8/common.yaml @@ -0,0 +1,13 @@ +esphome: + on_boot: + then: + - hc8.calibrate: + id: hc8_sensor + baseline: 420 + +sensor: + - platform: hc8 + id: hc8_sensor + co2: + name: HC8 CO2 Value + update_interval: 15s diff --git a/tests/components/hc8/test.esp32-idf.yaml b/tests/components/hc8/test.esp32-idf.yaml new file mode 100644 index 0000000000..2d29656c94 --- /dev/null +++ b/tests/components/hc8/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hc8/test.esp8266-ard.yaml b/tests/components/hc8/test.esp8266-ard.yaml new file mode 100644 index 0000000000..5a05efa259 --- /dev/null +++ b/tests/components/hc8/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hc8/test.rp2040-ard.yaml b/tests/components/hc8/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/hc8/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml From 59cd6dbf70235fa89c41eeedffa610a9c823ac76 Mon Sep 17 00:00:00 2001 From: damib Date: Thu, 20 Nov 2025 15:28:14 +0100 Subject: [PATCH 079/320] [climate_ir] Add optional humidity sensor (#9805) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/climate_ir/__init__.py | 11 ++++++++++- esphome/components/climate_ir/climate_ir.cpp | 15 ++++++++++++--- esphome/components/climate_ir/climate_ir.h | 2 ++ tests/components/climate_ir_lg/common.yaml | 12 ++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 6d66abf4cd..5315be3db6 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -3,7 +3,12 @@ import logging import esphome.codegen as cg from esphome.components import climate, remote_base, sensor import esphome.config_validation as cv -from esphome.const import CONF_SENSOR, CONF_SUPPORTS_COOL, CONF_SUPPORTS_HEAT +from esphome.const import ( + CONF_HUMIDITY_SENSOR, + CONF_SENSOR, + CONF_SUPPORTS_COOL, + CONF_SUPPORTS_HEAT, +) from esphome.cpp_generator import MockObjClass _LOGGER = logging.getLogger(__name__) @@ -32,6 +37,7 @@ def climate_ir_schema( cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), } ) .extend(cv.COMPONENT_SCHEMA) @@ -61,6 +67,9 @@ async def register_climate_ir(var, config): if sensor_id := config.get(CONF_SENSOR): sens = await cg.get_variable(sensor_id) cg.add(var.set_sensor(sens)) + if sensor_id := config.get(CONF_HUMIDITY_SENSOR): + sens = await cg.get_variable(sensor_id) + cg.add(var.set_humidity_sensor(sens)) async def new_climate_ir(config, *args): diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index 2b95792a6c..50c8d459b0 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -11,7 +11,9 @@ climate::ClimateTraits ClimateIR::traits() { if (this->sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); } - + if (this->humidity_sensor_ != nullptr) { + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); + } traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); if (this->supports_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_COOL); @@ -39,9 +41,16 @@ void ClimateIR::setup() { this->publish_state(); }); this->current_temperature = this->sensor_->state; - } else { - this->current_temperature = NAN; } + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->add_on_state_callback([this](float state) { + this->current_humidity = state; + // current humidity changed, publish state + this->publish_state(); + }); + this->current_humidity = this->humidity_sensor_->state; + } + // restore set points auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 62a43f0b2d..ac76d33853 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -43,6 +43,7 @@ class ClimateIR : public Component, void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } protected: float minimum_temperature_, maximum_temperature_, temperature_step_; @@ -67,6 +68,7 @@ class ClimateIR : public Component, climate::ClimatePresetMask presets_{}; sensor::Sensor *sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace climate_ir diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index da0d656b21..37011b16ee 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,4 +1,16 @@ +sensor: + - platform: template + id: temp_sensor + lambda: return 22.0; + update_interval: 60s + - platform: template + id: humidity_sensor + lambda: return 50.0; + update_interval: 60s + climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr + sensor: temp_sensor + humidity_sensor: humidity_sensor From 1accb4ff3488c1763da426faea38f73408714332 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:58:21 -0500 Subject: [PATCH 080/320] [ltr501][ltr_als_ps] Rename enum to avoid collision with lwip defines (#12017) --- esphome/components/ltr501/ltr501.cpp | 10 +++++----- esphome/components/ltr501/ltr501.h | 4 ++-- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 12 ++++++------ esphome/components/ltr_als_ps/ltr_als_ps.h | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index be5a4ddccf..04de91e362 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -174,7 +174,7 @@ void LTRAlsPs501Component::loop() { break; case State::WAITING_FOR_DATA: - if (this->is_als_data_ready_(this->als_readings_) == DataAvail::DATA_OK) { + if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { tries = 0; ESP_LOGV(TAG, "Reading sensor data assuming gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); @@ -379,18 +379,18 @@ void LTRAlsPs501Component::configure_integration_time_(IntegrationTime501 time) } } -DataAvail LTRAlsPs501Component::is_als_data_ready_(AlsReadings &data) { +LtrDataAvail LTRAlsPs501Component::is_als_data_ready_(AlsReadings &data) { AlsPsStatusRegister als_status{0}; als_status.raw = this->reg((uint8_t) CommandRegisters::ALS_PS_STATUS).get(); if (!als_status.als_new_data) - return DataAvail::NO_DATA; + return LtrDataAvail::LTR_NO_DATA; ESP_LOGV(TAG, "Data ready, reported gain is %.0fx", get_gain_coeff(als_status.gain)); if (data.gain != als_status.gain) { ESP_LOGW(TAG, "Actual gain differs from requested (%.0f)", get_gain_coeff(data.gain)); - return DataAvail::BAD_DATA; + return LtrDataAvail::LTR_BAD_DATA; } data.gain = als_status.gain; - return DataAvail::DATA_OK; + return LtrDataAvail::LTR_DATA_OK; } void LTRAlsPs501Component::read_sensor_data_(AlsReadings &data) { diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index 849ff6bc23..02c025da30 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -11,7 +11,7 @@ namespace esphome { namespace ltr501 { -enum DataAvail : uint8_t { NO_DATA, BAD_DATA, DATA_OK }; +enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK }; enum LtrType : uint8_t { LTR_TYPE_UNKNOWN = 0, @@ -106,7 +106,7 @@ class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { void configure_als_(); void configure_integration_time_(IntegrationTime501 time); void configure_gain_(AlsGain501 gain); - DataAvail is_als_data_ready_(AlsReadings &data); + LtrDataAvail is_als_data_ready_(AlsReadings &data); void read_sensor_data_(AlsReadings &data); bool are_adjustments_required_(AlsReadings &data); void apply_lux_calculation_(AlsReadings &data); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index c3ea5848c8..f9c1474c85 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -165,7 +165,7 @@ void LTRAlsPsComponent::loop() { break; case State::WAITING_FOR_DATA: - if (this->is_als_data_ready_(this->als_readings_) == DataAvail::DATA_OK) { + if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { tries = 0; ESP_LOGV(TAG, "Reading sensor data having gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); @@ -376,23 +376,23 @@ void LTRAlsPsComponent::configure_integration_time_(IntegrationTime time) { } } -DataAvail LTRAlsPsComponent::is_als_data_ready_(AlsReadings &data) { +LtrDataAvail LTRAlsPsComponent::is_als_data_ready_(AlsReadings &data) { AlsPsStatusRegister als_status{0}; als_status.raw = this->reg((uint8_t) CommandRegisters::ALS_PS_STATUS).get(); if (!als_status.als_new_data) - return DataAvail::NO_DATA; + return LtrDataAvail::LTR_NO_DATA; if (als_status.data_invalid) { ESP_LOGW(TAG, "Data available but not valid"); - return DataAvail::BAD_DATA; + return LtrDataAvail::LTR_BAD_DATA; } ESP_LOGV(TAG, "Data ready, reported gain is %.0f", get_gain_coeff(als_status.gain)); if (data.gain != als_status.gain) { ESP_LOGW(TAG, "Actual gain differs from requested (%.0f)", get_gain_coeff(data.gain)); - return DataAvail::BAD_DATA; + return LtrDataAvail::LTR_BAD_DATA; } - return DataAvail::DATA_OK; + return LtrDataAvail::LTR_DATA_OK; } void LTRAlsPsComponent::read_sensor_data_(AlsReadings &data) { diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index 2c768009ab..c6052300de 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -11,7 +11,7 @@ namespace esphome { namespace ltr_als_ps { -enum DataAvail : uint8_t { NO_DATA, BAD_DATA, DATA_OK }; +enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK }; enum LtrType : uint8_t { LTR_TYPE_UNKNOWN = 0, @@ -106,7 +106,7 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { void configure_als_(); void configure_integration_time_(IntegrationTime time); void configure_gain_(AlsGain gain); - DataAvail is_als_data_ready_(AlsReadings &data); + LtrDataAvail is_als_data_ready_(AlsReadings &data); void read_sensor_data_(AlsReadings &data); bool are_adjustments_required_(AlsReadings &data); void apply_lux_calculation_(AlsReadings &data); From a1e507baf817f99d81593130796dff92b25b948d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:10:28 -0500 Subject: [PATCH 081/320] [cst816][packet_transport][udp][wake_on_lan] Fix error messages (#12019) --- .../cst816/touchscreen/cst816_touchscreen.cpp | 2 +- .../components/packet_transport/packet_transport.cpp | 2 +- esphome/components/udp/udp_component.cpp | 10 +++++----- esphome/components/wake_on_lan/wake_on_lan.cpp | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index 0ba2d9df94..8ed9fa3f87 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -19,8 +19,8 @@ void CST816Touchscreen::continue_setup_() { case CST816T_CHIP_ID: break; default: - this->mark_failed(); this->status_set_error(str_sprintf("Unknown chip ID 0x%02X", this->chip_id_).c_str()); + this->mark_failed(); return; } this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 8bde4ee505..857b40ca0e 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -195,8 +195,8 @@ static void add(std::vector &vec, const char *str) { void PacketTransport::setup() { this->name_ = App.get_name().c_str(); if (strlen(this->name_) > 255) { - this->mark_failed(); this->status_set_error("Device name exceeds 255 chars"); + this->mark_failed(); return; } this->resend_ping_key_ = this->ping_pong_enable_; diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 8a9ce612b4..7714793e1c 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -21,8 +21,8 @@ void UDPComponent::setup() { if (this->should_broadcast_) { this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { - this->mark_failed(); this->status_set_error("Could not create socket"); + this->mark_failed(); return; } int enable = 1; @@ -41,15 +41,15 @@ void UDPComponent::setup() { if (this->should_listen_) { this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->listen_socket_ == nullptr) { - this->mark_failed(); this->status_set_error("Could not create socket"); + this->mark_failed(); return; } auto err = this->listen_socket_->setblocking(false); if (err < 0) { ESP_LOGE(TAG, "Unable to set nonblocking: errno %d", errno); - this->mark_failed(); this->status_set_error("Unable to set nonblocking"); + this->mark_failed(); return; } int enable = 1; @@ -73,8 +73,8 @@ void UDPComponent::setup() { err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); if (err < 0) { ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - this->mark_failed(); this->status_set_error("Failed to set IP_ADD_MEMBERSHIP"); + this->mark_failed(); return; } } @@ -82,8 +82,8 @@ void UDPComponent::setup() { err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); - this->mark_failed(); this->status_set_error("Unable to bind socket"); + this->mark_failed(); return; } } diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index adf5a080e5..7993abd7e7 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -67,8 +67,8 @@ void WakeOnLanButton::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { - this->mark_failed(); this->status_set_error("Could not create socket"); + this->mark_failed(); return; } int enable = 1; From 01addeae080b0cce517ed296d6ea4a998d454ae6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:11:41 -0600 Subject: [PATCH 082/320] Bump actions/checkout from 5.0.1 to 6.0.0 (#12022) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 30 +++++++++---------- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 8 ++--- .github/workflows/sync-device-classes.yml | 4 +-- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index fb284c9d8c..8d8e08a5fc 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,7 +22,7 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index be5af1aff1..b377ca76d8 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index aebf07949d..9556b99015 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 9bb983b993..5287d92b10 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index c82ae30f55..6ca58e252e 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5293c62d34..9c2fab0912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -132,7 +132,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -183,7 +183,7 @@ jobs: component-test-batches: ${{ steps.determine.outputs.component-test-batches }} steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -237,7 +237,7 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python 3.13 id: python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 @@ -273,7 +273,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python @@ -321,7 +321,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -400,7 +400,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -489,7 +489,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -577,7 +577,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -662,7 +662,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') steps: - name: Check out code from GitHub - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -688,7 +688,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ github.base_ref }} @@ -840,7 +840,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -908,7 +908,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 21fff10c95..80fab8819a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a064f6ef3a..497ecd29e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Download digests uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 4fc287b067..2e36dc517d 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Checkout Home Assistant - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: repository: home-assistant/core path: lib/home-assistant From 0dea7a23e3d0a035cdee7ccfeea303eb083da072 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 21 Nov 2025 07:39:59 -0500 Subject: [PATCH 083/320] [jsn_sr04t] Fix model AJ_SR04M (#11992) --- esphome/components/jsn_sr04t/jsn_sr04t.cpp | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index 077d4e58ea..84181dac48 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -10,7 +10,7 @@ namespace jsn_sr04t { static const char *const TAG = "jsn_sr04t.sensor"; void Jsnsr04tComponent::update() { - this->write_byte(0x55); + this->write_byte((this->model_ == AJ_SR04M) ? 0x01 : 0x55); ESP_LOGV(TAG, "Request read out from sensor"); } @@ -31,19 +31,10 @@ void Jsnsr04tComponent::loop() { } void Jsnsr04tComponent::check_buffer_() { - uint8_t checksum = 0; - switch (this->model_) { - case JSN_SR04T: - checksum = this->buffer_[0] + this->buffer_[1] + this->buffer_[2]; - break; - case AJ_SR04M: - checksum = this->buffer_[1] + this->buffer_[2]; - break; - } - + uint8_t checksum = this->buffer_[0] + this->buffer_[1] + this->buffer_[2]; if (this->buffer_[3] == checksum) { uint16_t distance = encode_uint16(this->buffer_[1], this->buffer_[2]); - if (distance > 250) { + if (distance > ((this->model_ == AJ_SR04M) ? 200 : 250)) { float meters = distance / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %umm, %.3fm", distance, meters); this->publish_state(meters); From 150e26dc2bfbf2155459c39c5f05873301254968 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Nov 2025 06:41:48 -0600 Subject: [PATCH 084/320] [cst816][http_request] Fix status_set_error() dangling pointer bugs (#12033) --- .../cst816/touchscreen/cst816_touchscreen.cpp | 3 ++- .../http_request/update/http_request_update.cpp | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index 8ed9fa3f87..0560f1b475 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -19,7 +19,8 @@ void CST816Touchscreen::continue_setup_() { case CST816T_CHIP_ID: break; default: - this->status_set_error(str_sprintf("Unknown chip ID 0x%02X", this->chip_id_).c_str()); + ESP_LOGE(TAG, "Unknown chip ID: 0x%02X", this->chip_id_); + this->status_set_error("Unknown chip ID"); this->mark_failed(); return; } diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 06aa6da6a4..9dbf8d181a 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -49,18 +49,18 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - std::string msg = str_sprintf("Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); }); + this_update->defer([this_update]() { this_update->status_set_error("Failed to fetch manifest"); }); UPDATE_RETURN; } RAMAllocator allocator; uint8_t *data = allocator.allocate(container->content_length); if (data == nullptr) { - std::string msg = str_sprintf("Failed to allocate %zu bytes for manifest", container->content_length); + ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); }); + this_update->defer([this_update]() { this_update->status_set_error("Failed to allocate memory for manifest"); }); container->end(); UPDATE_RETURN; } @@ -121,9 +121,9 @@ void HttpRequestUpdate::update_task(void *params) { } if (!valid) { - std::string msg = str_sprintf("Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); }); + this_update->defer([this_update]() { this_update->status_set_error("Failed to parse manifest JSON"); }); UPDATE_RETURN; } From 972b7e84fe800ea9dbd6b69d6f4bcc023b89e026 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Fri, 21 Nov 2025 14:38:44 +0100 Subject: [PATCH 085/320] [tests] Fix mipi_spi test board (#12031) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- tests/component_tests/mipi_spi/test_init.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index e68f6fbfba..56a52df2ab 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -220,7 +220,7 @@ def test_esp32s3_specific_errors( set_core_config( PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32S3}, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) with pytest.raises(cv.Invalid, match=error_match): @@ -250,7 +250,7 @@ def test_custom_model_with_all_options( """Test custom model configuration with all available options.""" set_core_config( PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32S3}, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) run_schema_validation( @@ -293,7 +293,7 @@ def test_all_predefined_models( """Test all predefined display models validate successfully with appropriate defaults.""" set_core_config( PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32S3}, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) # Enable PSRAM which is required for some models From 782aee92a77a2a3d991c97f671c259b1b961883e Mon Sep 17 00:00:00 2001 From: Marko Draca Date: Fri, 21 Nov 2025 20:50:07 +0100 Subject: [PATCH 086/320] [mcp3204] differential mode support (#7436) Co-authored-by: marko Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mcp3204/mcp3204.cpp | 21 ++++++++++++------- esphome/components/mcp3204/mcp3204.h | 2 +- esphome/components/mcp3204/sensor/__init__.py | 3 +++ .../mcp3204/sensor/mcp3204_sensor.cpp | 5 ++--- .../mcp3204/sensor/mcp3204_sensor.h | 3 ++- tests/components/mcp3204/common.yaml | 16 +++++++++++++- 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 4bb0cbed76..f0dd171a14 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -16,16 +16,21 @@ void MCP3204::dump_config() { ESP_LOGCONFIG(TAG, " Reference Voltage: %.2fV", this->reference_voltage_); } -float MCP3204::read_data(uint8_t pin) { - uint8_t adc_primary_config = 0b00000110 | (pin >> 2); - uint8_t adc_secondary_config = pin << 6; +float MCP3204::read_data(uint8_t pin, bool differential) { + uint8_t command, b0, b1; + + command = (1 << 6) | // start bit + ((differential ? 0 : 1) << 5) | // single or differential bit + ((pin & 0x07) << 2); // pin + this->enable(); - this->transfer_byte(adc_primary_config); - uint8_t adc_primary_byte = this->transfer_byte(adc_secondary_config); - uint8_t adc_secondary_byte = this->transfer_byte(0x00); + this->transfer_byte(command); + b0 = this->transfer_byte(0x00); + b1 = this->transfer_byte(0x00); this->disable(); - uint16_t digital_value = (adc_primary_byte << 8 | adc_secondary_byte) & 0b111111111111; - return float(digital_value) / 4096.000 * this->reference_voltage_; + + uint16_t digital_value = encode_uint16(b0, b1) >> 4; + return float(digital_value) / 4096.000 * this->reference_voltage_; // in V } } // namespace mcp3204 diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 27261aa373..6287263a2a 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -18,7 +18,7 @@ class MCP3204 : public Component, void setup() override; void dump_config() override; float get_setup_priority() const override; - float read_data(uint8_t pin); + float read_data(uint8_t pin, bool differential); protected: float reference_voltage_; diff --git a/esphome/components/mcp3204/sensor/__init__.py b/esphome/components/mcp3204/sensor/__init__.py index a4b177cbcf..5f9aa9fdb6 100644 --- a/esphome/components/mcp3204/sensor/__init__.py +++ b/esphome/components/mcp3204/sensor/__init__.py @@ -13,6 +13,7 @@ MCP3204Sensor = mcp3204_ns.class_( "MCP3204Sensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) CONF_MCP3204_ID = "mcp3204_id" +CONF_DIFF_MODE = "diff_mode" CONFIG_SCHEMA = ( sensor.sensor_schema(MCP3204Sensor) @@ -20,6 +21,7 @@ CONFIG_SCHEMA = ( { cv.GenerateID(CONF_MCP3204_ID): cv.use_id(MCP3204), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=7), + cv.Optional(CONF_DIFF_MODE, default=False): cv.boolean, } ) .extend(cv.polling_component_schema("60s")) @@ -30,6 +32,7 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], config[CONF_NUMBER], + config[CONF_DIFF_MODE], ) await cg.register_parented(var, config[CONF_MCP3204_ID]) await cg.register_component(var, config) diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp index ce0fd25462..4c4abef4a7 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp @@ -7,16 +7,15 @@ namespace mcp3204 { static const char *const TAG = "mcp3204.sensor"; -MCP3204Sensor::MCP3204Sensor(uint8_t pin) : pin_(pin) {} - float MCP3204Sensor::get_setup_priority() const { return setup_priority::DATA; } void MCP3204Sensor::dump_config() { LOG_SENSOR("", "MCP3204 Sensor", this); ESP_LOGCONFIG(TAG, " Pin: %u", this->pin_); + ESP_LOGCONFIG(TAG, " Differential Mode: %s", YESNO(this->differential_mode_)); LOG_UPDATE_INTERVAL(this); } -float MCP3204Sensor::sample() { return this->parent_->read_data(this->pin_); } +float MCP3204Sensor::sample() { return this->parent_->read_data(this->pin_, this->differential_mode_); } void MCP3204Sensor::update() { this->publish_state(this->sample()); } } // namespace mcp3204 diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 21c45590ab..5665b80b98 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -15,7 +15,7 @@ class MCP3204Sensor : public PollingComponent, public sensor::Sensor, public voltage_sampler::VoltageSampler { public: - MCP3204Sensor(uint8_t pin); + MCP3204Sensor(uint8_t pin, bool differential_mode) : pin_(pin), differential_mode_(differential_mode) {} void update() override; void dump_config() override; @@ -24,6 +24,7 @@ class MCP3204Sensor : public PollingComponent, protected: uint8_t pin_; + bool differential_mode_; }; } // namespace mcp3204 diff --git a/tests/components/mcp3204/common.yaml b/tests/components/mcp3204/common.yaml index eca6ec44f4..9750f0af8e 100644 --- a/tests/components/mcp3204/common.yaml +++ b/tests/components/mcp3204/common.yaml @@ -4,7 +4,21 @@ mcp3204: sensor: - platform: mcp3204 - id: mcp3204_sensor + id: mcp3204_default_single_0 mcp3204_id: mcp3204_hub number: 0 update_interval: 5s + + - platform: mcp3204 + id: mcp3204_single_0 + mcp3204_id: mcp3204_hub + number: 0 + diff_mode: false + update_interval: 5s + + - platform: mcp3204 + id: mcp3204_diff_0_1 + mcp3204_id: mcp3204_hub + number: 0 + diff_mode: true + update_interval: 5s From 3f6f2d7d650feee79896b13903c7a26e98559d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 21 Nov 2025 21:28:42 +0100 Subject: [PATCH 087/320] [bm8563] Add bm8563 component (#11616) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/bm8563/__init__.py | 1 + esphome/components/bm8563/bm8563.cpp | 198 ++++++++++++++++++ esphome/components/bm8563/bm8563.h | 57 +++++ esphome/components/bm8563/time.py | 80 +++++++ tests/components/bm8563/common.yaml | 10 + tests/components/bm8563/test.esp32-ard.yaml | 4 + tests/components/bm8563/test.esp32-idf.yaml | 4 + tests/components/bm8563/test.esp8266-ard.yaml | 4 + tests/components/bm8563/test.rp2040-ard.yaml | 4 + 10 files changed, 363 insertions(+) create mode 100644 esphome/components/bm8563/__init__.py create mode 100644 esphome/components/bm8563/bm8563.cpp create mode 100644 esphome/components/bm8563/bm8563.h create mode 100644 esphome/components/bm8563/time.py create mode 100644 tests/components/bm8563/common.yaml create mode 100644 tests/components/bm8563/test.esp32-ard.yaml create mode 100644 tests/components/bm8563/test.esp32-idf.yaml create mode 100644 tests/components/bm8563/test.esp8266-ard.yaml create mode 100644 tests/components/bm8563/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c3d8f4350f..d6ec7b882e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,6 +72,7 @@ esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/ble_nus/* @tomaszduda23 esphome/components/bluetooth_proxy/* @bdraco @jesserockz +esphome/components/bm8563/* @abmantis esphome/components/bme280_base/* @esphome/core esphome/components/bme280_spi/* @apbodrov esphome/components/bme680_bsec/* @trvrnrth diff --git a/esphome/components/bm8563/__init__.py b/esphome/components/bm8563/__init__.py new file mode 100644 index 0000000000..20254a8b00 --- /dev/null +++ b/esphome/components/bm8563/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@abmantis"] diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp new file mode 100644 index 0000000000..07831485c1 --- /dev/null +++ b/esphome/components/bm8563/bm8563.cpp @@ -0,0 +1,198 @@ +#include "bm8563.h" +#include "esphome/core/log.h" + +namespace esphome::bm8563 { + +static const char *const TAG = "bm8563"; + +static constexpr uint8_t CONTROL_STATUS_1_REG = 0x00; +static constexpr uint8_t CONTROL_STATUS_2_REG = 0x01; +static constexpr uint8_t TIME_FIRST_REG = 0x02; // Time uses reg 2, 3, 4 +static constexpr uint8_t DATE_FIRST_REG = 0x05; // Date uses reg 5, 6, 7, 8 +static constexpr uint8_t TIMER_CONTROL_REG = 0x0E; +static constexpr uint8_t TIMER_VALUE_REG = 0x0F; +static constexpr uint8_t CLOCK_1_HZ = 0x82; +static constexpr uint8_t CLOCK_1_60_HZ = 0x83; +// Maximum duration: 255 minutes (at 1/60 Hz) = 15300 seconds +static constexpr uint32_t MAX_TIMER_DURATION_S = 255 * 60; + +void BM8563::setup() { + if (!this->write_byte_16(CONTROL_STATUS_1_REG, 0)) { + this->mark_failed(); + return; + } +} + +void BM8563::update() { this->read_time(); } + +void BM8563::dump_config() { + ESP_LOGCONFIG(TAG, "BM8563:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void BM8563::start_timer(uint32_t duration_s) { + this->clear_irq_(); + this->set_timer_irq_(duration_s); +} + +void BM8563::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + + ESP_LOGD(TAG, "Writing time: %i-%i-%i %i, %i:%i:%i", now.year, now.month, now.day_of_month, now.day_of_week, now.hour, + now.minute, now.second); + + this->set_time_(now); + this->set_date_(now); +} + +void BM8563::read_time() { + ESPTime rtc_time; + this->get_time_(rtc_time); + this->get_date_(rtc_time); + rtc_time.day_of_year = 1; // unused by recalc_timestamp_utc, but needs to be valid + ESP_LOGD(TAG, "Read time: %i-%i-%i %i, %i:%i:%i", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second); + + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid()) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +uint8_t BM8563::bcd2_to_byte_(uint8_t value) { + const uint8_t tmp = ((value & 0xF0) >> 0x4) * 10; + return tmp + (value & 0x0F); +} + +uint8_t BM8563::byte_to_bcd2_(uint8_t value) { + const uint8_t bcdhigh = value / 10; + value -= bcdhigh * 10; + return (bcdhigh << 4) | value; +} + +void BM8563::get_time_(ESPTime &time) { + uint8_t buf[3] = {0}; + this->read_register(TIME_FIRST_REG, buf, 3); + + time.second = this->bcd2_to_byte_(buf[0] & 0x7f); + time.minute = this->bcd2_to_byte_(buf[1] & 0x7f); + time.hour = this->bcd2_to_byte_(buf[2] & 0x3f); +} + +void BM8563::set_time_(const ESPTime &time) { + uint8_t buf[3] = {this->byte_to_bcd2_(time.second), this->byte_to_bcd2_(time.minute), this->byte_to_bcd2_(time.hour)}; + this->write_register_(TIME_FIRST_REG, buf, 3); +} + +void BM8563::get_date_(ESPTime &time) { + uint8_t buf[4] = {0}; + this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); + + time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); + time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07); + time.month = this->bcd2_to_byte_(buf[2] & 0x1f); + + uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); + + if (buf[2] & 0x80) { + time.year = 1900 + year_byte; + } else { + time.year = 2000 + year_byte; + } +} + +void BM8563::set_date_(const ESPTime &time) { + uint8_t buf[4] = { + this->byte_to_bcd2_(time.day_of_month), + this->byte_to_bcd2_(time.day_of_week), + this->byte_to_bcd2_(time.month), + this->byte_to_bcd2_(time.year % 100), + }; + + if (time.year < 2000) { + buf[2] = buf[2] | 0x80; + } + + this->write_register_(DATE_FIRST_REG, buf, 4); +} + +void BM8563::write_byte_(uint8_t reg, uint8_t value) { + if (!this->write_byte(reg, value)) { + ESP_LOGE(TAG, "Failed to write byte 0x%02X with value 0x%02X", reg, value); + } +} + +void BM8563::write_register_(uint8_t reg, const uint8_t *data, size_t len) { + if (auto error = this->write_register(reg, data, len); error != i2c::ErrorCode::NO_ERROR) { + ESP_LOGE(TAG, "Failed to write register 0x%02X with %zu bytes", reg, len); + } +} + +optional BM8563::read_register_(uint8_t reg) { + uint8_t data; + if (auto error = this->read_register(reg, &data, 1); error != i2c::ErrorCode::NO_ERROR) { + ESP_LOGE(TAG, "Failed to read register 0x%02X", reg); + return {}; + } + return data; +} + +void BM8563::set_timer_irq_(uint32_t duration_s) { + ESP_LOGI(TAG, "Timer Duration: %u s", duration_s); + + if (duration_s > MAX_TIMER_DURATION_S) { + ESP_LOGW(TAG, "Timer duration %u s exceeds maximum %u s", duration_s, MAX_TIMER_DURATION_S); + return; + } + + if (duration_s > 255) { + uint8_t duration_minutes = duration_s / 60; + this->write_byte_(TIMER_VALUE_REG, duration_minutes); + this->write_byte_(TIMER_CONTROL_REG, CLOCK_1_60_HZ); + } else { + this->write_byte_(TIMER_VALUE_REG, duration_s); + this->write_byte_(TIMER_CONTROL_REG, CLOCK_1_HZ); + } + + auto maybe_ctrl_status_2 = this->read_register_(CONTROL_STATUS_2_REG); + if (!maybe_ctrl_status_2.has_value()) { + ESP_LOGE(TAG, "Failed to read CONTROL_STATUS_2_REG"); + return; + } + uint8_t ctrl_status_2_reg_value = maybe_ctrl_status_2.value(); + ctrl_status_2_reg_value |= (1 << 0); + ctrl_status_2_reg_value &= ~(1 << 7); + this->write_byte_(CONTROL_STATUS_2_REG, ctrl_status_2_reg_value); +} + +void BM8563::clear_irq_() { + auto maybe_data = this->read_register_(CONTROL_STATUS_2_REG); + if (!maybe_data.has_value()) { + ESP_LOGE(TAG, "Failed to read CONTROL_STATUS_2_REG"); + return; + } + uint8_t data = maybe_data.value(); + this->write_byte_(CONTROL_STATUS_2_REG, data & 0xf3); +} + +void BM8563::disable_irq_() { + this->clear_irq_(); + auto maybe_data = this->read_register_(CONTROL_STATUS_2_REG); + if (!maybe_data.has_value()) { + ESP_LOGE(TAG, "Failed to read CONTROL_STATUS_2_REG"); + return; + } + uint8_t data = maybe_data.value(); + this->write_byte_(CONTROL_STATUS_2_REG, data & 0xfc); +} + +} // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h new file mode 100644 index 0000000000..eda2d1b3c0 --- /dev/null +++ b/esphome/components/bm8563/bm8563.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::bm8563 { + +class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void write_time(); + void read_time(); + void start_timer(uint32_t duration_s); + + private: + void get_time_(ESPTime &time); + void get_date_(ESPTime &time); + + void set_time_(const ESPTime &time); + void set_date_(const ESPTime &time); + + void set_timer_irq_(uint32_t duration_s); + void clear_irq_(); + void disable_irq_(); + + void write_byte_(uint8_t reg, uint8_t value); + void write_register_(uint8_t reg, const uint8_t *data, size_t len); + optional read_register_(uint8_t reg); + + uint8_t bcd2_to_byte_(uint8_t value); + uint8_t byte_to_bcd2_(uint8_t value); +}; + +template class WriteAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->write_time(); } +}; + +template class ReadAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->read_time(); } +}; + +template class TimerAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint32_t, duration) + + void play(const Ts &...x) override { + auto duration = this->duration_.value(x...); + this->parent_->start_timer(duration); + } +}; + +} // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py new file mode 100644 index 0000000000..2785315af2 --- /dev/null +++ b/esphome/components/bm8563/time.py @@ -0,0 +1,80 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_ID + +DEPENDENCIES = ["i2c"] + +I2C_ADDR = 0x51 + +bm8563_ns = cg.esphome_ns.namespace("bm8563") +BM8563 = bm8563_ns.class_("BM8563", time.RealTimeClock, i2c.I2CDevice) +WriteAction = bm8563_ns.class_("WriteAction", automation.Action) +ReadAction = bm8563_ns.class_("ReadAction", automation.Action) +TimerAction = bm8563_ns.class_("TimerAction", automation.Action) + +CONFIG_SCHEMA = ( + time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BM8563), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(I2C_ADDR)) +) + + +@automation.register_action( + "bm8563.write_time", + WriteAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } + ), +) +async def bm8563_write_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "bm8563.start_timer", + TimerAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(BM8563), + cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), + } + ), +) +async def bm8563_start_timer_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) + cg.add(var.set_duration(template_)) + return var + + +@automation.register_action( + "bm8563.read_time", + ReadAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } + ), +) +async def bm8563_read_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/tests/components/bm8563/common.yaml b/tests/components/bm8563/common.yaml new file mode 100644 index 0000000000..ec3fdd1518 --- /dev/null +++ b/tests/components/bm8563/common.yaml @@ -0,0 +1,10 @@ +esphome: + on_boot: + - bm8563.read_time + - bm8563.write_time + - bm8563.start_timer: + duration: 300s + +time: + - platform: bm8563 + i2c_id: i2c_bus diff --git a/tests/components/bm8563/test.esp32-ard.yaml b/tests/components/bm8563/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/bm8563/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bm8563/test.esp32-idf.yaml b/tests/components/bm8563/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/bm8563/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bm8563/test.esp8266-ard.yaml b/tests/components/bm8563/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/bm8563/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bm8563/test.rp2040-ard.yaml b/tests/components/bm8563/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/bm8563/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From a5751b294f06448375878dcb620ac6c4ec054893 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:13:23 +1300 Subject: [PATCH 088/320] [api] Rename `USE_API_SERVICES` to `USE_API_USER_DEFINED_ACTIONS` (#12029) --- esphome/components/api/__init__.py | 4 ++-- esphome/components/api/api.proto | 8 ++++---- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2.cpp | 2 +- esphome/components/api/api_pb2.h | 4 ++-- esphome/components/api/api_pb2_dump.cpp | 4 ++-- esphome/components/api/api_pb2_service.cpp | 4 ++-- esphome/components/api/api_pb2_service.h | 6 +++--- esphome/components/api/api_server.h | 8 ++++---- esphome/components/api/custom_api_device.h | 10 +++++----- esphome/components/api/list_entities.cpp | 2 +- esphome/components/api/list_entities.h | 2 +- esphome/components/api/user_services.h | 4 ++-- esphome/core/component_iterator.cpp | 6 +++--- esphome/core/component_iterator.h | 6 +++--- esphome/core/defines.h | 2 +- 17 files changed, 38 insertions(+), 38 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index a9286c531f..7f84f2f247 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -260,9 +260,9 @@ async def to_code(config): cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) - # Set USE_API_SERVICES if any services are enabled + # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: - cg.add_define("USE_API_SERVICES") + cg.add_define("USE_API_USER_DEFINED_ACTIONS") # Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration if config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e115e4630d..26d1fa6876 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -855,21 +855,21 @@ enum ServiceArgType { SERVICE_ARG_TYPE_STRING_ARRAY = 7; } message ListEntitiesServicesArgument { - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; ServiceArgType type = 2; } message ListEntitiesServicesResponse { option (id) = 41; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; fixed32 key = 2; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; } message ExecuteServiceArgument { - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; bool bool_ = 1; int32 legacy_int = 2; float float_ = 3; @@ -885,7 +885,7 @@ message ExecuteServiceRequest { option (id) = 42; option (source) = SOURCE_CLIENT; option (no_delay) = true; - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; fixed32 key = 1; repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 4acd2fc15c..c60680ae43 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1541,7 +1541,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } } #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void APIConnection::execute_service(const ExecuteServiceRequest &msg) { bool found = false; for (auto *service : this->parent_->get_user_services()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6cfd108927..af3a19909f 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -221,7 +221,7 @@ class APIConnection final : public APIServerConnection { #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void execute_service(const ExecuteServiceRequest &msg) override; #endif #ifdef USE_API_NOISE diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 0a073fb662..d52135a566 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -995,7 +995,7 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_uint32(2, static_cast(this->type)); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 358049026e..b19e92d4ff 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -63,7 +63,7 @@ enum LogLevel : uint32_t { LOG_LEVEL_VERBOSE = 6, LOG_LEVEL_VERY_VERBOSE = 7, }; -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS enum ServiceArgType : uint32_t { SERVICE_ARG_TYPE_BOOL = 0, SERVICE_ARG_TYPE_INT = 1, @@ -1239,7 +1239,7 @@ class GetTimeResponse final : public ProtoDecodableMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name_ref_{}; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 127ef44cd8..ea752ba3ba 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -206,7 +206,7 @@ template<> const char *proto_enum_to_string(enums::LogLevel val return "UNKNOWN"; } } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template<> const char *proto_enum_to_string(enums::ServiceArgType value) { switch (value) { case enums::SERVICE_ARG_TYPE_BOOL: @@ -1177,7 +1177,7 @@ void GetTimeResponse::dump_to(std::string &out) const { out.append(format_hex_pretty(this->timezone, this->timezone_len)); out.append("\n"); } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void ListEntitiesServicesArgument::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ListEntitiesServicesArgument"); dump_field(out, "name", this->name_ref_); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 9d227af0a3..3d28a137c8 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -193,7 +193,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS case ExecuteServiceRequest::MESSAGE_TYPE: { ExecuteServiceRequest msg; msg.decode(msg_data, msg_size); @@ -670,7 +670,7 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc this->subscribe_home_assistant_states(msg); } #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } #endif #ifdef USE_API_NOISE diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 549b00ee6a..827b89e23c 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -79,7 +79,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_get_time_response(const GetTimeResponse &value){}; -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS virtual void on_execute_service_request(const ExecuteServiceRequest &value){}; #endif @@ -239,7 +239,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS virtual void execute_service(const ExecuteServiceRequest &msg) = 0; #endif #ifdef USE_API_NOISE @@ -368,7 +368,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg) override; #endif #ifdef USE_API_NOISE diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 2d58063d6c..a3a082e165 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -12,7 +12,7 @@ #include "esphome/core/log.h" #include "list_entities.h" #include "subscribe_state.h" -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS #include "user_services.h" #endif @@ -124,7 +124,7 @@ class APIServer : public Component, public Controller { #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES #endif // USE_API_HOMEASSISTANT_SERVICES -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void initialize_user_services(std::initializer_list services) { this->user_services_.assign(services); } @@ -166,7 +166,7 @@ class APIServer : public Component, public Controller { std::function f); const std::vector &get_state_subs() const; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS const std::vector &get_user_services() const { return this->user_services_; } #endif @@ -206,7 +206,7 @@ class APIServer : public Component, public Controller { #ifdef USE_API_HOMEASSISTANT_STATES std::vector state_subs_; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS std::vector user_services_; #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 43ea644f0c..1006d07533 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -3,12 +3,12 @@ #include #include "api_server.h" #ifdef USE_API -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS #include "user_services.h" #endif namespace esphome::api { -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template class CustomAPIDeviceService : public UserServiceDynamic { public: CustomAPIDeviceService(const std::string &name, const std::array &arg_names, T *obj, @@ -21,7 +21,7 @@ template class CustomAPIDeviceService : public UserS T *obj_; void (T::*callback_)(Ts...); }; -#endif // USE_API_SERVICES +#endif // USE_API_USER_DEFINED_ACTIONS class CustomAPIDevice { public: @@ -49,7 +49,7 @@ class CustomAPIDevice { * @param name The name of the service to register. * @param arg_names The name of the arguments for the service, must match the arguments of the function. */ -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template void register_service(void (T::*callback)(Ts...), const std::string &name, const std::array &arg_names) { @@ -90,7 +90,7 @@ class CustomAPIDevice { * @param callback The member function to call when the service is triggered. * @param name The name of the arguments for the service, must match the arguments of the function. */ -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template void register_service(void (T::*callback)(), const std::string &name) { #ifdef USE_API_CUSTOM_SERVICES auto *service = new CustomAPIDeviceService(name, {}, (T *) this, callback); // NOLINT diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index da4800a45e..e18fc17801 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -82,7 +82,7 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done( ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {} -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { auto resp = service->encode_list_service_response(); return this->client_->send_message(resp, ListEntitiesServicesResponse::MESSAGE_TYPE); diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 769d7b9b6e..4c90dbbad8 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -43,7 +43,7 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_TEXT_SENSOR bool on_text_sensor(text_sensor::TextSensor *entity) override; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS bool on_service(UserServiceDescriptor *service) override; #endif #ifdef USE_CAMERA diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 2a887fc52d..501b702e6b 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -7,7 +7,7 @@ #include "esphome/core/automation.h" #include "api_pb2.h" -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS namespace esphome::api { class UserServiceDescriptor { @@ -122,4 +122,4 @@ template class UserServiceTrigger : public UserServiceBaseprocess_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service); break; @@ -185,7 +185,7 @@ void ComponentIterator::advance() { bool ComponentIterator::on_end() { return true; } bool ComponentIterator::on_begin() { return true; } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS bool ComponentIterator::on_service(api::UserServiceDescriptor *service) { return true; } #endif #ifdef USE_CAMERA diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 641d42898a..1b1bd80ac5 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -10,7 +10,7 @@ namespace esphome { -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS namespace api { class UserServiceDescriptor; } // namespace api @@ -45,7 +45,7 @@ class ComponentIterator { #ifdef USE_TEXT_SENSOR virtual bool on_text_sensor(text_sensor::TextSensor *text_sensor) = 0; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS virtual bool on_service(api::UserServiceDescriptor *service); #endif #ifdef USE_CAMERA @@ -122,7 +122,7 @@ class ComponentIterator { #ifdef USE_TEXT_SENSOR TEXT_SENSOR, #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS SERVICE, #endif #ifdef USE_CAMERA diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 41f4b28cd5..03362ce07a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -124,7 +124,7 @@ #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE #define USE_API_PLAINTEXT -#define USE_API_SERVICES +#define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES #define API_MAX_SEND_QUEUE 8 #define USE_MD5 From f42b806889f51f81f31db8810443b5462c58e8e6 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 23 Nov 2025 22:03:13 +0100 Subject: [PATCH 089/320] [core] Fix error on invalid id extend/remove (#12064) --- esphome/config.py | 2 ++ .../fixtures/substitutions/05-extend-remove.approved.yaml | 6 ++++++ .../fixtures/substitutions/05-extend-remove.input.yaml | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/esphome/config.py b/esphome/config.py index 4c8019de75..1c4cdd93c6 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -355,6 +355,8 @@ def _get_item_id(item: Any) -> str | Extend | Remove | None: if isinstance(item_id, Extend): # Remove instances of Extend so they don't overwrite the original item when merging: del item[CONF_ID] + elif not isinstance(item_id, (str, Remove)): + return None return item_id diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml index 35e3e6258f..773a124f25 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml @@ -31,3 +31,9 @@ lvgl: id: object5 x: 10 y: 11 + - obj: + id: + - Invalid ID + - obj: + id: + invalid: id diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml index 617f09c31c..e6d46d6dc4 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml @@ -37,6 +37,10 @@ packages: id: object5 x: 10 y: 11 + - obj: + id: ["Invalid ID"] + - obj: + id: {"invalid": "id"} some_component: - id: !extend ${A} From c91a9495e611d4d1b94cffc6e88bd40f1d1843fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 23 Nov 2025 16:19:26 -0500 Subject: [PATCH 090/320] [ci] Fix filename (#12065) --- .../stts22h/{test.nrf52.yaml => test.nrf52-adafruit.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/components/stts22h/{test.nrf52.yaml => test.nrf52-adafruit.yaml} (100%) diff --git a/tests/components/stts22h/test.nrf52.yaml b/tests/components/stts22h/test.nrf52-adafruit.yaml similarity index 100% rename from tests/components/stts22h/test.nrf52.yaml rename to tests/components/stts22h/test.nrf52-adafruit.yaml From 5750f7fccbcf22c1df62d363cd0adb7d173be55d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 23 Nov 2025 22:25:24 -0500 Subject: [PATCH 091/320] [ci] Fix test grouping (#12067) --- tests/components/stts22h/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/stts22h/common.yaml b/tests/components/stts22h/common.yaml index 802afe2065..2e332f9276 100644 --- a/tests/components/stts22h/common.yaml +++ b/tests/components/stts22h/common.yaml @@ -1,4 +1,5 @@ sensor: - platform: stts22h + i2c_id: i2c_bus name: Temperature update_interval: 15s From 60d687c2c6c9bb8961763958d1b0ad78fee2b772 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 23 Nov 2025 23:31:14 -0500 Subject: [PATCH 092/320] [esp32] Fix C2 builds (#12050) --- esphome/components/esp32/__init__.py | 6 ++++++ esphome/components/esp32/pre_build.py.script | 9 +++++++++ 2 files changed, 15 insertions(+) create mode 100644 esphome/components/esp32/pre_build.py.script diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6f577d2926..59c6029334 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -883,6 +883,12 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + add_extra_script( + "pre", + "pre_build.py", + Path(__file__).parent / "pre_build.py.script", + ) + add_extra_script( "post", "post_build.py", diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script new file mode 100644 index 0000000000..af12275a0b --- /dev/null +++ b/esphome/components/esp32/pre_build.py.script @@ -0,0 +1,9 @@ +Import("env") # noqa: F821 + +# Remove custom_sdkconfig from the board config as it causes +# pioarduino to enable some strange hybrid build mode that breaks IDF +board = env.BoardConfig() +if "espidf.custom_sdkconfig" in board: + del board._manifest["espidf"]["custom_sdkconfig"] + if not board._manifest["espidf"]: + del board._manifest["espidf"] From b4b98505baed1fea37c1e2e5da11c2c8ea7d2e26 Mon Sep 17 00:00:00 2001 From: James <23900@qq.com> Date: Mon, 24 Nov 2025 23:05:02 +1300 Subject: [PATCH 093/320] [mipi_dsi] add guition JC4880P443 display (#12068) --- esphome/components/mipi_dsi/models/guition.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 5f7db4ebda..cd566633f9 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -35,3 +35,70 @@ DriverChip( (0x10, 0x0C), (0x11, 0x0C), (0x12, 0x0C), (0x13, 0x0C), (0x30, 0x00), ], ) + + +# JC4880P443 Driver Configuration (ST7701) +# Using parameters from esp_lcd_st7701.h and the working full init sequence +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 480x800 +# * PCLK Frequency: 34 MHz +# * DSI Lane Bit Rate: 500 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) +# ---------------------------------------------------------------------------------------------------------------------- +DriverChip( + "JC4880P443", + width=480, + height=800, + hsync_back_porch=42, + hsync_pulse_width=12, + hsync_front_porch=42, + vsync_back_porch=8, + vsync_pulse_width=2, + vsync_front_porch=166, + pclk_frequency="34MHz", + lane_bit_rate="500Mbps", + swap_xy=cv.UNDEFINED, + color_order="RGB", + reset_pin=5, + initsequence=[ + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0x63, 0x00), + (0xC1, 0x0D, 0x02), + (0xC2, 0x10, 0x08), + (0xCC, 0x10), + (0xB0, 0x80, 0x09, 0x53, 0x0C, 0xD0, 0x07, 0x0C, 0x09, 0x09, 0x28, 0x06, 0xD4, 0x13, 0x69, 0x2B, 0x71), + (0xB1, 0x80, 0x94, 0x5A, 0x10, 0xD3, 0x06, 0x0A, 0x08, 0x08, 0x25, 0x03, 0xD3, 0x12, 0x66, 0x6A, 0x0D), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x5D), + (0xB1, 0x58), + (0xB2, 0x87), + (0xB3, 0x80), + (0xB5, 0x4E), + (0xB7, 0x85), + (0xB8, 0x21), + (0xB9, 0x10, 0x1F), + (0xBB, 0x03), + (0xBC, 0x00), + (0xC1, 0x78), + (0xC2, 0x78), + (0xD0, 0x88), + (0xE0, 0x00, 0x3A, 0x02), + (0xE1, 0x04, 0xA0, 0x00, 0xA0, 0x05, 0xA0, 0x00, 0xA0, 0x00, 0x40, 0x40), + (0xE2, 0x30, 0x00, 0x40, 0x40, 0x32, 0xA0, 0x00, 0xA0, 0x00, 0xA0, 0x00, 0xA0, 0x00), + (0xE3, 0x00, 0x00, 0x33, 0x33), + (0xE4, 0x44, 0x44), + (0xE5, 0x09, 0x2E, 0xA0, 0xA0, 0x0B, 0x30, 0xA0, 0xA0, 0x05, 0x2A, 0xA0, 0xA0, 0x07, 0x2C, 0xA0, 0xA0), + (0xE6, 0x00, 0x00, 0x33, 0x33), + (0xE7, 0x44, 0x44), + (0xE8, 0x08, 0x2D, 0xA0, 0xA0, 0x0A, 0x2F, 0xA0, 0xA0, 0x04, 0x29, 0xA0, 0xA0, 0x06, 0x2B, 0xA0, 0xA0), + (0xEB, 0x00, 0x00, 0x4E, 0x4E, 0x00, 0x00, 0x00), + (0xEC, 0x08, 0x01), + (0xED, 0xB0, 0x2B, 0x98, 0xA4, 0x56, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0x65, 0x4A, 0x89, 0xB2, 0x0B), + (0xEF, 0x08, 0x08, 0x08, 0x45, 0x3F, 0x54), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00), + ] +) +# fmt: on From 8607a0881d4f3d3b6fe064287710f6af3f3a16b6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:10:24 -0500 Subject: [PATCH 094/320] [core] Add support for passing yaml files to clean-all (#12039) --- esphome/__main__.py | 2 +- esphome/writer.py | 8 +++++++- tests/unit_tests/test_writer.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index b0c081a34f..f8fb678cb2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1319,7 +1319,7 @@ def parse_args(argv): "clean-all", help="Clean all build and platform files." ) parser_clean_all.add_argument( - "configuration", help="Your YAML configuration directory.", nargs="*" + "configuration", help="Your YAML file or configuration directory.", nargs="*" ) parser_dashboard = subparsers.add_parser( diff --git a/esphome/writer.py b/esphome/writer.py index b866a804b3..3124e9e12c 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -343,7 +343,13 @@ def clean_build(clear_pio_cache: bool = True): def clean_all(configuration: list[str]): import shutil - data_dirs = [Path(dir) / ".esphome" for dir in configuration] + data_dirs = [] + for config in configuration: + item = Path(config) + if item.is_file() and item.suffix in (".yaml", ".yml"): + data_dirs.append(item.parent / ".esphome") + else: + data_dirs.append(item / ".esphome") if is_ha_addon(): data_dirs.append(Path("/data")) if "ESPHOME_DATA_DIR" in os.environ: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a4490fbbc0..a2a358f4d3 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -737,6 +737,37 @@ def test_write_cpp_with_duplicate_markers( write_cpp("// New code") +@patch("esphome.writer.CORE") +def test_clean_all_with_yaml_file( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with a .yaml file uses parent directory.""" + # Create config directory with yaml file + config_dir = tmp_path / "config" + config_dir.mkdir() + yaml_file = config_dir / "test.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + (build_dir / "dummy.txt").write_text("x") + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(yaml_file)]) + + # Verify .esphome directory still exists but contents cleaned + assert build_dir.exists() + assert not (build_dir / "dummy.txt").exists() + + # Verify logging mentions the build dir + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all( mock_core: MagicMock, From 1f0a5e1eeab2d86031934a3f9c9e182458d60a5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:21:32 -0600 Subject: [PATCH 095/320] [logger] Reduce UART overhead on ESP32/ESP8266 and fix buffer truncation (#11927) --- esphome/components/logger/__init__.py | 2 + esphome/components/logger/logger.cpp | 21 ++++---- esphome/components/logger/logger.h | 53 +++++++++++++++++-- esphome/components/logger/logger_esp32.cpp | 32 ++++++----- esphome/components/logger/logger_esp8266.cpp | 5 +- esphome/components/logger/logger_host.cpp | 2 +- .../components/logger/logger_libretiny.cpp | 2 +- esphome/components/logger/logger_rp2040.cpp | 2 +- esphome/components/logger/logger_zephyr.cpp | 2 +- 9 files changed, 84 insertions(+), 37 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index cf78e6ae63..39877030e9 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -365,8 +365,10 @@ async def to_code(config): if CORE.is_esp32: if config[CONF_HARDWARE_UART] == USB_CDC: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True) + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", True) + cg.add_define("USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG") try: uart_selection(USB_SERIAL_JTAG) cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 9a9bf89fe3..9803bf528c 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -65,7 +65,9 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch uint16_t buffer_at = 0; // Initialize buffer position this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, console_buffer, &buffer_at, MAX_CONSOLE_LOG_MSG_SIZE); - this->write_msg_(console_buffer); + // Add newline if platform needs it (ESP32 doesn't add via write_msg_) + this->add_newline_to_buffer_if_needed_(console_buffer, &buffer_at, MAX_CONSOLE_LOG_MSG_SIZE); + this->write_msg_(console_buffer, buffer_at); } // Reset the recursion guard for this task @@ -131,18 +133,19 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas // Save the offset before calling format_log_to_buffer_with_terminator_ // since it will increment tx_buffer_at_ to the end of the formatted string - uint32_t msg_start = this->tx_buffer_at_; + uint16_t msg_start = this->tx_buffer_at_; this->format_log_to_buffer_with_terminator_(level, tag, line, this->tx_buffer_, args, this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - // Write to console and send callback starting at the msg_start - if (this->baud_rate_ > 0) { - this->write_msg_(this->tx_buffer_ + msg_start); - } - size_t msg_length = + uint16_t msg_length = this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position + + // Callbacks get message first (before console write) this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start, msg_length); + // Write to console starting at the msg_start + this->write_tx_buffer_to_console_(msg_start, &msg_length); + global_recursion_guard_ = false; } #endif // USE_STORE_LOG_STR_IN_FLASH @@ -209,9 +212,7 @@ void Logger::process_messages_() { // This ensures all log messages appear on the console in a clean, serialized manner // Note: Messages may appear slightly out of order due to async processing, but // this is preferred over corrupted/interleaved console output - if (this->baud_rate_ > 0) { - this->write_msg_(this->tx_buffer_); - } + this->write_tx_buffer_to_console_(); } } else { // No messages to process, disable loop if appropriate diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index dc8e06e0c9..8ba3dacacb 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -71,6 +71,17 @@ static constexpr uint16_t MAX_HEADER_SIZE = 128; // "0x" + 2 hex digits per byte + '\0' static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1; +// Platform-specific: does write_msg_ add its own newline? +// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266) +// Allows single write call with newline included for efficiency +// true: write_msg_ adds newline itself via puts()/println() (other platforms) +// Newline should NOT be added to buffer +#if defined(USE_ESP32) || defined(USE_ESP8266) +static constexpr bool WRITE_MSG_ADDS_NEWLINE = false; +#else +static constexpr bool WRITE_MSG_ADDS_NEWLINE = true; +#endif + #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * @@ -173,7 +184,7 @@ class Logger : public Component { protected: void process_messages_(); - void write_msg_(const char *msg); + void write_msg_(const char *msg, size_t len); // Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator // It's the caller's responsibility to initialize buffer_at (typically to 0) @@ -200,6 +211,35 @@ class Logger : public Component { } } + // Helper to add newline to buffer for platforms that need it + // Modifies buffer_at to include the newline + inline void HOT add_newline_to_buffer_if_needed_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { + if constexpr (!WRITE_MSG_ADDS_NEWLINE) { + // Add newline - don't need to maintain null termination + // write_msg_ now always receives explicit length, so we can safely overwrite the null terminator + // This is safe because: + // 1. Callbacks already received the message (before we add newline) + // 2. write_msg_ receives the length explicitly (doesn't need null terminator) + if (*buffer_at < buffer_size) { + buffer[(*buffer_at)++] = '\n'; + } else if (buffer_size > 0) { + // Buffer was full - replace last char with newline to ensure it's visible + buffer[buffer_size - 1] = '\n'; + *buffer_at = buffer_size; + } + } + } + + // Helper to write tx_buffer_ to console if logging is enabled + // INTERNAL USE ONLY - offset > 0 requires length parameter to be non-null + inline void HOT write_tx_buffer_to_console_(uint16_t offset = 0, uint16_t *length = nullptr) { + if (this->baud_rate_ > 0) { + uint16_t *len_ptr = length ? length : &this->tx_buffer_at_; + this->add_newline_to_buffer_if_needed_(this->tx_buffer_ + offset, len_ptr, this->tx_buffer_size_ - offset); + this->write_msg_(this->tx_buffer_ + offset, *len_ptr); + } + } + // Helper to format and send a log message to both console and callbacks inline void HOT log_message_to_buffer_and_send_(uint8_t level, const char *tag, int line, const char *format, va_list args) { @@ -208,10 +248,11 @@ class Logger : public Component { this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - if (this->baud_rate_ > 0) { - this->write_msg_(this->tx_buffer_); // If logging is enabled, write to console - } + // Callbacks get message WITHOUT newline (for API/MQTT/syslog) this->log_callback_.call(level, tag, this->tx_buffer_, this->tx_buffer_at_); + + // Console gets message WITH newline (if platform needs it) + this->write_tx_buffer_to_console_(); } // Write the body of the log message to the buffer @@ -425,7 +466,9 @@ class Logger : public Component { } // Update buffer_at with the formatted length (handle truncation) - uint16_t formatted_len = (ret >= remaining) ? remaining : ret; + // When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator + // When it doesn't truncate (ret < remaining), it writes ret chars + null terminator + uint16_t formatted_len = (ret >= remaining) ? (remaining - 1) : ret; *buffer_at += formatted_len; // Remove all trailing newlines right after formatting diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 7fc79e6f54..32ef752462 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -121,25 +121,23 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { - if ( -#if defined(USE_LOGGER_USB_CDC) && !defined(USE_LOGGER_USB_SERIAL_JTAG) - this->uart_ == UART_SELECTION_USB_CDC -#elif defined(USE_LOGGER_USB_SERIAL_JTAG) && !defined(USE_LOGGER_USB_CDC) - this->uart_ == UART_SELECTION_USB_SERIAL_JTAG -#elif defined(USE_LOGGER_USB_CDC) && defined(USE_LOGGER_USB_SERIAL_JTAG) - this->uart_ == UART_SELECTION_USB_CDC || this->uart_ == UART_SELECTION_USB_SERIAL_JTAG +void HOT Logger::write_msg_(const char *msg, size_t len) { + // Length is now always passed explicitly - no strlen() fallback needed + +#if defined(USE_LOGGER_UART_SELECTION_USB_CDC) || defined(USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG) + // USB CDC/JTAG - single write including newline (already in buffer) + // Use fwrite to stdout which goes through VFS to USB console + // + // Note: These defines indicate the user's YAML configuration choice (hardware_uart: USB_CDC/USB_SERIAL_JTAG). + // They are ONLY defined when the user explicitly selects USB as the logger output in their config. + // This is compile-time selection, not runtime detection - if USB is configured, it's always used. + // There is no fallback to regular UART if "USB isn't connected" - that's the user's responsibility + // to configure correctly for their hardware. This approach eliminates runtime overhead. + fwrite(msg, 1, len, stdout); #else - /* DISABLES CODE */ (false) // NOLINT + // Regular UART - single write including newline (already in buffer) + uart_write_bytes(this->uart_num_, msg, len); #endif - ) { - puts(msg); - } else { - // Use tx_buffer_at_ if msg points to tx_buffer_, otherwise fall back to strlen - size_t len = (msg == this->tx_buffer_) ? this->tx_buffer_at_ : strlen(msg); - uart_write_bytes(this->uart_num_, msg, len); - uart_write_bytes(this->uart_num_, "\n", 1); - } } const LogString *Logger::get_uart_selection_() { diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5063d88b92..0fc73b747a 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -33,7 +33,10 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t len) { + // Single write with newline already in buffer (added by caller) + this->hw_serial_->write(msg, len); +} const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index 4abe92286a..c5e1e6f865 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -3,7 +3,7 @@ namespace esphome::logger { -void HOT Logger::write_msg_(const char *msg) { +void HOT Logger::write_msg_(const char *msg, size_t) { time_t rawtime; struct tm *timeinfo; char buffer[80]; diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index 3edfa74480..b8017b841d 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -49,7 +49,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); } const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index 63727c2cda..4a8535c8e4 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -27,7 +27,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); } const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index fb0c7dcca3..ec2ff3013c 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -62,7 +62,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { +void HOT Logger::write_msg_(const char *msg, size_t) { #ifdef CONFIG_PRINTK printk("%s\n", msg); #endif From 056b4375ebe238250675081613dcb00389e3254e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:21:47 -0600 Subject: [PATCH 096/320] [api] Reduce heap allocations in DeviceInfoResponse (#11952) --- esphome/components/api/api_connection.cpp | 12 ++++++++---- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 ++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c60680ae43..04221a237b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1451,8 +1451,11 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_AREAS resp.set_suggested_area(StringRef(App.get_area())); #endif - // mac_address must store temporary string - will be valid during send_message call - std::string mac_address = get_mac_address_pretty(); + // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) + char mac_address[18]; + uint8_t mac[6]; + get_mac_address_raw(mac); + format_mac_addr_upper(mac, mac_address); resp.set_mac_address(StringRef(mac_address)); resp.set_esphome_version(ESPHOME_VERSION_REF); @@ -1493,8 +1496,9 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // bt_mac must store temporary string - will be valid during send_message call - std::string bluetooth_mac = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(); + // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) + char bluetooth_mac[18]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.set_bluetooth_mac_address(StringRef(bluetooth_mac)); #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index a5f0fbe32f..4de541fac2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -130,11 +130,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ return flags; } - std::string get_bluetooth_mac_address_pretty() { + void get_bluetooth_mac_address_pretty(std::span output) { const uint8_t *mac = esp_bt_dev_get_address(); - char buf[18]; - format_mac_addr_upper(mac, buf); - return std::string(buf); + format_mac_addr_upper(mac, output.data()); } protected: From 426734beef724112b37037641c7ea7c20f044082 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:22:01 -0600 Subject: [PATCH 097/320] [web_server_base] Replace shared_ptr with unique_ptr for AsyncWebServer (#11984) --- esphome/components/web_server_base/web_server_base.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 039a452d64..fbf0d00c06 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -111,7 +111,7 @@ class WebServerBase : public Component { this->initialized_++; return; } - this->server_ = std::make_shared(this->port_); + this->server_ = std::make_unique(this->port_); // All content is controlled and created by user - so allowing all origins is fine here. DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); this->server_->begin(); @@ -127,7 +127,7 @@ class WebServerBase : public Component { this->server_ = nullptr; } } - std::shared_ptr get_server() const { return server_; } + AsyncWebServer *get_server() const { return this->server_.get(); } float get_setup_priority() const override; #ifdef USE_WEBSERVER_AUTH @@ -143,7 +143,7 @@ class WebServerBase : public Component { protected: int initialized_{0}; uint16_t port_{80}; - std::shared_ptr server_{nullptr}; + std::unique_ptr server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; From 3c48e13c9f5cf391e775174748c7da77928e9b8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:22:13 -0600 Subject: [PATCH 098/320] [ethernet] Conditionally compile manual_ip to save 24 bytes RAM (#11832) --- esphome/components/ethernet/__init__.py | 1 + esphome/components/ethernet/ethernet_component.cpp | 12 ++++++++++-- esphome/components/ethernet/ethernet_component.h | 4 ++++ esphome/core/defines.h | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 2f02d227d7..b4d67635c1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -383,6 +383,7 @@ async def to_code(config): cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) if CONF_MANUAL_IP in config: + cg.add_define("USE_ETHERNET_MANUAL_IP") cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) # Add compile-time define for PHY types with specific code diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index cad963b299..9a46aa2687 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -553,11 +553,14 @@ void EthernetComponent::start_connect_() { } esp_netif_ip_info_t info; +#ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { info.ip = this->manual_ip_->static_ip; info.gw = this->manual_ip_->gateway; info.netmask = this->manual_ip_->subnet; - } else { + } else +#endif + { info.ip.addr = 0; info.gw.addr = 0; info.netmask.addr = 0; @@ -578,6 +581,7 @@ void EthernetComponent::start_connect_() { err = esp_netif_set_ip_info(this->eth_netif_, &info); ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); +#ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { LwIPLock lock; if (this->manual_ip_->dns1.is_set()) { @@ -590,7 +594,9 @@ void EthernetComponent::start_connect_() { d = this->manual_ip_->dns2; dns_setserver(1, &d); } - } else { + } else +#endif + { err = esp_netif_dhcpc_start(this->eth_netif_); if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { ESPHL_ERROR_CHECK(err, "DHCPC start error"); @@ -688,7 +694,9 @@ void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->cl void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } +#ifdef USE_ETHERNET_MANUAL_IP void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } +#endif // set_use_address() is guaranteed to be called during component setup by Python code generation, // so use_address_ will always be valid when get_use_address() is called - no fallback needed. diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index f1f0ac9cb8..bffed4dc4a 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -82,7 +82,9 @@ class EthernetComponent : public Component { void add_phy_register(PHYRegister register_value); #endif void set_type(EthernetType type); +#ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); +#endif void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); @@ -137,7 +139,9 @@ class EthernetComponent : public Component { uint8_t mdc_pin_{23}; uint8_t mdio_pin_{18}; #endif +#ifdef USE_ETHERNET_MANUAL_IP optional manual_ip_{}; +#endif uint32_t connect_begin_; // Group all uint8_t types together (enums and bools) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 03362ce07a..5e7f51e04c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -216,6 +216,7 @@ #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 2) #define USE_ETHERNET #define USE_ETHERNET_KSZ8081 +#define USE_ETHERNET_MANUAL_IP #endif #ifdef USE_ESP_IDF From 737f23a0bdb8a1c09bb0dee9709d09cb4c5403c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:23:11 -0600 Subject: [PATCH 099/320] [light] Dynamically disable loop when idle to reduce CPU overhead (#11881) --- esphome/components/light/light_state.cpp | 24 ++++++++++++++++++++++++ esphome/components/light/light_state.h | 3 +++ 2 files changed, 27 insertions(+) diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 36b2af03a5..9cde9077da 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -23,6 +23,9 @@ void LightState::setup() { effect->init_internal(this); } + // Start with loop disabled if idle - respects any effects/transitions set up during initialization + this->disable_loop_if_idle_(); + // When supported color temperature range is known, initialize color temperature setting within bounds. auto traits = this->get_traits(); float min_mireds = traits.get_min_mireds(); @@ -125,6 +128,9 @@ void LightState::loop() { this->is_transformer_active_ = false; this->transformer_ = nullptr; this->target_state_reached_callback_.call(); + + // Disable loop if idle (no transformer and no effect) + this->disable_loop_if_idle_(); } } @@ -132,6 +138,8 @@ void LightState::loop() { if (this->next_write_) { this->next_write_ = false; this->output_->write_state(this); + // Disable loop if idle (no transformer and no effect) + this->disable_loop_if_idle_(); } } @@ -227,6 +235,8 @@ void LightState::start_effect_(uint32_t effect_index) { this->active_effect_index_ = effect_index; auto *effect = this->get_active_effect_(); effect->start_internal(); + // Enable loop while effect is active + this->enable_loop(); } LightEffect *LightState::get_active_effect_() { if (this->active_effect_index_ == 0) { @@ -241,6 +251,8 @@ void LightState::stop_effect_() { effect->stop(); } this->active_effect_index_ = 0; + // Disable loop if idle (no effect and no transformer) + this->disable_loop_if_idle_(); } void LightState::start_transition_(const LightColorValues &target, uint32_t length, bool set_remote_values) { @@ -250,6 +262,8 @@ void LightState::start_transition_(const LightColorValues &target, uint32_t leng if (set_remote_values) { this->remote_values = target; } + // Enable loop while transition is active + this->enable_loop(); } void LightState::start_flash_(const LightColorValues &target, uint32_t length, bool set_remote_values) { @@ -265,6 +279,8 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b if (set_remote_values) { this->remote_values = target; }; + // Enable loop while flash is active + this->enable_loop(); } void LightState::set_immediately_(const LightColorValues &target, bool set_remote_values) { @@ -276,6 +292,14 @@ void LightState::set_immediately_(const LightColorValues &target, bool set_remot } this->output_->update_state(this); this->next_write_ = true; + this->enable_loop(); +} + +void LightState::disable_loop_if_idle_() { + // Only disable loop if both transformer and effect are inactive, and no pending writes + if (this->transformer_ == nullptr && this->get_active_effect_() == nullptr && !this->next_write_) { + this->disable_loop(); + } } void LightState::save_remote_values_() { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 06519cdc14..ad8922b46f 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -255,6 +255,9 @@ class LightState : public EntityBase, public Component { /// Internal method to save the current remote_values to the preferences void save_remote_values_(); + /// Disable loop if neither transformer nor effect is active + void disable_loop_if_idle_(); + /// Store the output to allow effects to have more access. LightOutput *output_; /// The currently active transformer for this light (transition/flash). From 04ec6a69995adb0e6277e7abf2e49c05486a0e37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:23:31 -0600 Subject: [PATCH 100/320] [api] Use stack buffer for MAC address in Noise handshake (#12072) --- esphome/components/api/api_frame_helper_noise.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 633b07a7fa..8bcec0f9f3 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -239,12 +239,13 @@ APIError APINoiseFrameHelper::state_action_() { } if (state_ == State::SERVER_HELLO) { // send server hello + constexpr size_t mac_len = 13; // 12 hex chars + null terminator const std::string &name = App.get_name(); - const std::string &mac = get_mac_address(); + char mac[mac_len]; + get_mac_address_into_buffer(mac); // Calculate positions and sizes size_t name_len = name.size() + 1; // including null terminator - size_t mac_len = mac.size() + 1; // including null terminator size_t name_offset = 1; size_t mac_offset = name_offset + name_len; size_t total_size = 1 + name_len + mac_len; @@ -257,7 +258,7 @@ APIError APINoiseFrameHelper::state_action_() { // node name, terminated by null byte std::memcpy(msg.get() + name_offset, name.c_str(), name_len); // node mac, terminated by null byte - std::memcpy(msg.get() + mac_offset, mac.c_str(), mac_len); + std::memcpy(msg.get() + mac_offset, mac, mac_len); aerr = write_frame_(msg.get(), total_size); if (aerr != APIError::OK) From 06815fe177e5980ef049ec859c3fc8d26a28290d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 10:41:24 -0600 Subject: [PATCH 101/320] [script][wait_until] Fix FIFO ordering and reentrancy bugs (#12049) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/script/script.h | 20 +-- esphome/core/base_automation.h | 9 +- .../fixtures/script_delay_with_params.yaml | 131 ++++++++++++++++++ .../fixtures/wait_until_fifo_ordering.yaml | 82 +++++++++++ tests/integration/test_script_delay_params.py | 121 ++++++++++++++++ tests/integration/test_wait_until_ordering.py | 90 ++++++++++++ 6 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/script_delay_with_params.yaml create mode 100644 tests/integration/fixtures/wait_until_fifo_ordering.yaml create mode 100644 tests/integration/test_script_delay_params.py create mode 100644 tests/integration/test_wait_until_ordering.py diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 51cece01e4..d60ed657f7 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -1,8 +1,8 @@ #pragma once +#include #include #include -#include #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -290,10 +290,10 @@ template class ScriptWaitAction : public Action, } // Store parameters for later execution - this->param_queue_.emplace_front(x...); - // Enable loop now that we have work to do + this->param_queue_.emplace_back(x...); + // Enable loop now that we have work to do - don't call loop() synchronously! + // Let the event loop call it to avoid reentrancy issues this->enable_loop(); - this->loop(); } void loop() override { @@ -303,13 +303,17 @@ template class ScriptWaitAction : public Action, if (this->script_->is_running()) return; - while (!this->param_queue_.empty()) { + // Only process ONE queued item per loop iteration + // Processing all items in a while loop causes infinite loops because + // play_next_() can trigger more items to be queued + if (!this->param_queue_.empty()) { auto ¶ms = this->param_queue_.front(); this->play_next_tuple_(params, typename gens::type()); this->param_queue_.pop_front(); + } else { + // Queue is now empty - disable loop until next play_complex + this->disable_loop(); } - // Queue is now empty - disable loop until next play_complex - this->disable_loop(); } void play(const Ts &...x) override { /* ignore - see play_complex */ @@ -326,7 +330,7 @@ template class ScriptWaitAction : public Action, } C *script_; - std::forward_list> param_queue_; + std::list> param_queue_; }; } // namespace script diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index c2519da839..e8878ac251 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -9,8 +9,8 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include #include -#include namespace esphome { @@ -445,9 +445,10 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); - // Do immediate check with fresh timestamp + // Do immediate check with fresh timestamp - don't call loop() synchronously! + // Let the event loop call it to avoid reentrancy issues if (this->process_queue_(now)) { // Only enable loop if we still have pending items this->enable_loop(); @@ -499,7 +500,7 @@ template class WaitUntilAction : public Action, public Co } Condition *condition_; - std::forward_list, std::tuple>> var_queue_{}; + std::list, std::tuple>> var_queue_{}; }; template class UpdateComponentAction : public Action { diff --git a/tests/integration/fixtures/script_delay_with_params.yaml b/tests/integration/fixtures/script_delay_with_params.yaml new file mode 100644 index 0000000000..2a0f16d9fe --- /dev/null +++ b/tests/integration/fixtures/script_delay_with_params.yaml @@ -0,0 +1,131 @@ +esphome: + name: test-script-delay-params + +host: + +api: + actions: + # Test case from issue #12044: parent script with repeat calling child with delay + - action: test_repeat_with_delay + then: + - logger.log: "=== TEST: Repeat loop calling script with delay and parameters ===" + - script.execute: father_script + + # Test case from issue #12043: script.wait with delayed child script + - action: test_script_wait + then: + - logger.log: "=== TEST: script.wait with delayed child script ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "After wait: script completed successfully" + + # Test: Delay with different parameter types + - action: test_delay_param_types + then: + - logger.log: "=== TEST: Delay with various parameter types ===" + - script.execute: + id: delay_with_int + val: 42 + - delay: 50ms + - script.execute: + id: delay_with_string + msg: "test message" + - delay: 50ms + - script.execute: + id: delay_with_float + num: 3.14 + +logger: + level: DEBUG + +script: + # Reproduces issue #12044: child script with conditional delay + - id: son_script + mode: single + parameters: + iteration: int + then: + - logger.log: + format: "Son script started with iteration %d" + args: ['iteration'] + - if: + condition: + lambda: 'return iteration >= 5;' + then: + - logger.log: + format: "Son script delaying for iteration %d" + args: ['iteration'] + - delay: 100ms + - logger.log: + format: "Son script finished with iteration %d" + args: ['iteration'] + + # Reproduces issue #12044: parent script with repeat loop + - id: father_script + mode: single + then: + - repeat: + count: 10 + then: + - logger.log: + format: "Father iteration %d: calling son" + args: ['iteration'] + - script.execute: + id: son_script + iteration: !lambda 'return iteration;' + - script.wait: son_script + - logger.log: + format: "Father iteration %d: son finished, wait returned" + args: ['iteration'] + + # Reproduces issue #12043: script.wait hangs + - id: show_start_page + mode: single + then: + - logger.log: "Start page: beginning" + - delay: 100ms + - logger.log: "Start page: after delay" + - delay: 100ms + - logger.log: "Start page: completed" + + # Test delay with int parameter + - id: delay_with_int + mode: single + parameters: + val: int + then: + - logger.log: + format: "Int test: before delay, val=%d" + args: ['val'] + - delay: 50ms + - logger.log: + format: "Int test: after delay, val=%d" + args: ['val'] + + # Test delay with string parameter + - id: delay_with_string + mode: single + parameters: + msg: string + then: + - logger.log: + format: "String test: before delay, msg=%s" + args: ['msg.c_str()'] + - delay: 50ms + - logger.log: + format: "String test: after delay, msg=%s" + args: ['msg.c_str()'] + + # Test delay with float parameter + - id: delay_with_float + mode: single + parameters: + num: float + then: + - logger.log: + format: "Float test: before delay, num=%.2f" + args: ['num'] + - delay: 50ms + - logger.log: + format: "Float test: after delay, num=%.2f" + args: ['num'] diff --git a/tests/integration/fixtures/wait_until_fifo_ordering.yaml b/tests/integration/fixtures/wait_until_fifo_ordering.yaml new file mode 100644 index 0000000000..5dd60c8755 --- /dev/null +++ b/tests/integration/fixtures/wait_until_fifo_ordering.yaml @@ -0,0 +1,82 @@ +esphome: + name: test-wait-until-ordering + +host: + +api: + actions: + - action: test_wait_until_fifo + then: + - logger.log: "=== TEST: wait_until should execute in FIFO order ===" + - globals.set: + id: gate_open + value: 'false' + - delay: 100ms + # Start multiple parallel executions of coordinator script + # Each will call the shared waiter script, queueing in same wait_until + - script.execute: coordinator_0 + - script.execute: coordinator_1 + - script.execute: coordinator_2 + - script.execute: coordinator_3 + - script.execute: coordinator_4 + # Give scripts time to reach wait_until and queue + - delay: 200ms + - logger.log: "Opening gate - all wait_until should complete now" + - globals.set: + id: gate_open + value: 'true' + - delay: 500ms + - logger.log: "Test complete" + +globals: + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Shared waiter with single wait_until action (all coordinators call this) + - id: waiter + mode: parallel + parameters: + iter: int + then: + - lambda: 'ESP_LOGD("main", "Queueing iteration %d", iter);' + - wait_until: + condition: + lambda: 'return id(gate_open);' + timeout: 5s + - lambda: 'ESP_LOGD("main", "Completed iteration %d", iter);' + + # Coordinator scripts - each calls shared waiter with different iteration number + - id: coordinator_0 + then: + - script.execute: + id: waiter + iter: 0 + + - id: coordinator_1 + then: + - script.execute: + id: waiter + iter: 1 + + - id: coordinator_2 + then: + - script.execute: + id: waiter + iter: 2 + + - id: coordinator_3 + then: + - script.execute: + id: waiter + iter: 3 + + - id: coordinator_4 + then: + - script.execute: + id: waiter + iter: 4 + +logger: + level: DEBUG diff --git a/tests/integration/test_script_delay_params.py b/tests/integration/test_script_delay_params.py new file mode 100644 index 0000000000..1b5d70863b --- /dev/null +++ b/tests/integration/test_script_delay_params.py @@ -0,0 +1,121 @@ +"""Integration test for script.wait FIFO ordering (issues #12043, #12044). + +This test verifies that ScriptWaitAction processes queued items in FIFO order. + +PR #7972 introduced bugs in ScriptWaitAction: +- Used emplace_front() causing LIFO ordering instead of FIFO +- Called loop() synchronously causing reentrancy issues +- Used while loop processing entire queue causing infinite loops + +These bugs manifested as: +- Scripts becoming "zombies" (stuck in running state) +- script.wait hanging forever +- Incorrect execution order +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_delay_with_params( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that script.wait processes queued items in FIFO order. + + This reproduces issues #12043 and #12044 where scripts would hang or become + zombies due to LIFO ordering bugs in ScriptWaitAction from PR #7972. + """ + test_complete = asyncio.Event() + + # Patterns to match in logs + father_calling_pattern = re.compile(r"Father iteration (\d+): calling son") + son_started_pattern = re.compile(r"Son script started with iteration (\d+)") + son_delaying_pattern = re.compile(r"Son script delaying for iteration (\d+)") + son_finished_pattern = re.compile(r"Son script finished with iteration (\d+)") + father_wait_returned_pattern = re.compile( + r"Father iteration (\d+): son finished, wait returned" + ) + + # Track which iterations completed + father_calling = set() + son_started = set() + son_delaying = set() + son_finished = set() + wait_returned = set() + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if test_complete.is_set(): + return + + if mo := father_calling_pattern.search(line): + father_calling.add(int(mo.group(1))) + elif mo := son_started_pattern.search(line): + son_started.add(int(mo.group(1))) + elif mo := son_delaying_pattern.search(line): + son_delaying.add(int(mo.group(1))) + elif mo := son_finished_pattern.search(line): + son_finished.add(int(mo.group(1))) + elif mo := father_wait_returned_pattern.search(line): + iteration = int(mo.group(1)) + wait_returned.add(iteration) + # Test completes when iteration 9 finishes + if iteration == 9: + test_complete.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-script-delay-params" + + # Get services + _, services = await client.list_entities_services() + test_service = next( + (s for s in services if s.name == "test_repeat_with_delay"), None + ) + assert test_service is not None, "test_repeat_with_delay service not found" + + # Execute the test + client.execute_service(test_service, {}) + + # Wait for test to complete (10 iterations * ~100ms each + margin) + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail( + f"Test timed out. Completed iterations: {sorted(wait_returned)}. " + f"This likely indicates the script became a zombie (issue #12044)." + ) + + # Verify all 10 iterations completed successfully + expected_iterations = set(range(10)) + assert father_calling == expected_iterations, "Not all iterations started" + assert son_started == expected_iterations, ( + "Son script not started for all iterations" + ) + assert son_finished == expected_iterations, ( + "Son script not finished for all iterations" + ) + assert wait_returned == expected_iterations, ( + "script.wait did not return for all iterations" + ) + + # Verify delays were triggered for iterations >= 5 + expected_delays = set(range(5, 10)) + assert son_delaying == expected_delays, ( + "Delays not triggered for iterations >= 5" + ) diff --git a/tests/integration/test_wait_until_ordering.py b/tests/integration/test_wait_until_ordering.py new file mode 100644 index 0000000000..7c39913e5a --- /dev/null +++ b/tests/integration/test_wait_until_ordering.py @@ -0,0 +1,90 @@ +"""Integration test for wait_until FIFO ordering. + +This test verifies that when multiple wait_until actions are queued, +they execute in FIFO (First In First Out) order, not LIFO. + +PR #7972 introduced a bug where emplace_front() was used, causing +LIFO ordering which is incorrect. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_fifo_ordering( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that wait_until executes queued items in FIFO order. + + With the bug (using emplace_front), the order would be 4,3,2,1,0 (LIFO). + With the fix (using emplace_back), the order should be 0,1,2,3,4 (FIFO). + """ + test_complete = asyncio.Event() + + # Track completion order + completed_order = [] + + # Patterns to match + queuing_pattern = re.compile(r"Queueing iteration (\d+)") + completed_pattern = re.compile(r"Completed iteration (\d+)") + + def check_output(line: str) -> None: + """Check log output for completion order.""" + if test_complete.is_set(): + return + + if mo := queuing_pattern.search(line): + iteration = int(mo.group(1)) + + elif mo := completed_pattern.search(line): + iteration = int(mo.group(1)) + completed_order.append(iteration) + + # Test completes when all 5 have completed + if len(completed_order) == 5: + test_complete.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-wait-until-ordering" + + # Get services + _, services = await client.list_entities_services() + test_service = next( + (s for s in services if s.name == "test_wait_until_fifo"), None + ) + assert test_service is not None, "test_wait_until_fifo service not found" + + # Execute the test + client.execute_service(test_service, {}) + + # Wait for test to complete + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail( + f"Test timed out. Completed order: {completed_order}. " + f"Expected 5 completions but got {len(completed_order)}." + ) + + # Verify FIFO order + expected_order = [0, 1, 2, 3, 4] + assert completed_order == expected_order, ( + f"Unexpected order: {completed_order}. " + f"Expected FIFO order: {expected_order}" + ) From 0764f4da86dee9a325237ca1fa2e5f3d71d0f6c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 11:02:24 -0600 Subject: [PATCH 102/320] [esp_ldo,mipi_dsi,mipi_rgb] Fix dangling pointer bugs in mark_failed() (#12077) --- esphome/components/esp_ldo/esp_ldo.cpp | 4 ++-- esphome/components/mipi_dsi/mipi_dsi.cpp | 6 ++++++ esphome/components/mipi_dsi/mipi_dsi.h | 5 +---- esphome/components/mipi_rgb/mipi_rgb.cpp | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp_ldo/esp_ldo.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index eb04670d7e..9ea7000b70 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -14,8 +14,8 @@ void EspLdo::setup() { config.flags.adjustable = this->adjustable_; auto err = esp_ldo_acquire_channel(&config, &this->handle_); if (err != ESP_OK) { - auto msg = str_sprintf("Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_); - this->mark_failed(msg.c_str()); + ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_); + this->mark_failed("Failed to acquire LDO channel"); } else { ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_); } diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index fbe251de41..7305435e4b 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -11,6 +11,12 @@ static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); } + +void MIPI_DSI::smark_failed(const char *message, esp_err_t err) { + ESP_LOGE(TAG, "%s: %s", message, esp_err_to_name(err)); + this->mark_failed(message); +} + void MIPI_DSI::setup() { ESP_LOGCONFIG(TAG, "Running Setup"); diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index ce8a2a2236..98ee092ed1 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -62,10 +62,7 @@ class MIPI_DSI : public display::Display { void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } - void smark_failed(const char *message, esp_err_t err) { - auto str = str_sprintf("Setup failed: %s: %s", message, esp_err_to_name(err)); - this->mark_failed(str.c_str()); - } + void smark_failed(const char *message, esp_err_t err); void update() override; diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 080fb08c09..4c687724cf 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -164,8 +164,8 @@ void MipiRgb::common_setup_() { if (err == ESP_OK) err = esp_lcd_panel_init(this->handle_); if (err != ESP_OK) { - auto msg = str_sprintf("lcd setup failed: %s", esp_err_to_name(err)); - this->mark_failed(msg.c_str()); + ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err)); + this->mark_failed("lcd setup failed"); } ESP_LOGCONFIG(TAG, "MipiRgb setup complete"); } From 66cda0466469531a7a9428db33251c6ad985c9bd Mon Sep 17 00:00:00 2001 From: Flo Date: Mon, 24 Nov 2025 18:19:38 +0100 Subject: [PATCH 103/320] [wifi] ap_active condition (#11852) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/wifi/__init__.py | 6 ++++++ esphome/components/wifi/wifi_component.cpp | 1 + esphome/components/wifi/wifi_component.h | 6 ++++++ tests/components/wifi/common.yaml | 4 ++++ 4 files changed, 17 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 2b21478f30..b9c0fa28a7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -97,6 +97,7 @@ WIFI_MIN_AUTH_MODES = { VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) +WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) WiFiConfigureAction = wifi_ns.class_( @@ -590,6 +591,11 @@ async def wifi_enabled_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg) +@automation.register_condition("wifi.ap_active", WiFiAPActiveCondition, cv.Schema({})) +async def wifi_ap_active_to_code(config, condition_id, template_arg, args): + return cg.new_Pvariable(condition_id, template_arg) + + @automation.register_action("wifi.enable", WiFiEnableAction, cv.Schema({})) async def wifi_enable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 6f698bc2a8..23a4020453 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -530,6 +530,7 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } bool WiFiComponent::has_ap() const { return this->has_ap_; } +bool WiFiComponent::is_ap_active() const { return this->state_ == WIFI_COMPONENT_STATE_AP; } bool WiFiComponent::has_sta() const { return !this->sta_.empty(); } #ifdef USE_WIFI_11KV_SUPPORT void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index b3548078bc..441606a2c1 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -308,6 +308,7 @@ class WiFiComponent : public Component { bool has_sta() const; bool has_ap() const; + bool is_ap_active() const; #ifdef USE_WIFI_11KV_SUPPORT void set_btm(bool btm); @@ -557,6 +558,11 @@ template class WiFiEnabledCondition : public Condition { bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } }; +template class WiFiAPActiveCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } +}; + template class WiFiEnableAction : public Action { public: void play(const Ts &...x) override { global_wifi_component->enable(); } diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index 5d9973cbc8..7ce74ab00d 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -10,6 +10,10 @@ esphome: - logger.log: "Connected to WiFi!" on_error: - logger.log: "Failed to connect to WiFi!" + - if: + condition: wifi.ap_active + then: + - logger.log: "WiFi AP is active!" wifi: networks: From d7a197b3a3444d996dcdd2b249ccaa7e88aa1421 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:27:09 -0500 Subject: [PATCH 104/320] [esp32] Use the IDF I2C implementation on Arduino (#12076) --- esphome/components/i2c/__init__.py | 26 +++++++++++++--------- esphome/components/i2c/i2c_bus_arduino.cpp | 24 +++++--------------- esphome/components/i2c/i2c_bus_arduino.h | 7 +++--- esphome/components/i2c/i2c_bus_esp_idf.cpp | 4 ++-- esphome/components/i2c/i2c_bus_esp_idf.h | 4 ++-- 5 files changed, 27 insertions(+), 38 deletions(-) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 6308923759..738568cd3c 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -47,18 +47,20 @@ MULTI_CONF = True def _bus_declare_type(value): + if CORE.is_esp32: + return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: return cv.declare_id(ArduinoI2CBus)(value) - if CORE.using_esp_idf: - return cv.declare_id(IDFI2CBus)(value) if CORE.using_zephyr: return cv.declare_id(ZephyrI2CBus)(value) raise NotImplementedError def validate_config(config): - if CORE.using_esp_idf: - return cv.require_framework_version(esp_idf=cv.Version(5, 4, 2))(config) + if CORE.is_esp32: + return cv.require_framework_version( + esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) + )(config) return config @@ -67,12 +69,12 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): _bus_declare_type, cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, - cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean + cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( + cv.only_on_esp32, cv.boolean ), cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number, - cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean + cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( + cv.only_on_esp32, cv.boolean ), cv.SplitDefault( CONF_FREQUENCY, @@ -151,7 +153,7 @@ async def to_code(config): cg.add(var.set_scan(config[CONF_SCAN])) if CONF_TIMEOUT in config: cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) - if CORE.using_arduino: + if CORE.using_arduino and not CORE.is_esp32: cg.add_library("Wire", None) @@ -248,14 +250,16 @@ def final_validate_device_schema( FILTER_SOURCE_FILES = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { - PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "i2c_bus_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "i2c_bus_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, "i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 221423418b..1579020c9b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) #include "i2c_bus_arduino.h" #include @@ -15,16 +15,7 @@ static const char *const TAG = "i2c.arduino"; void ArduinoI2CBus::setup() { recover_(); -#if defined(USE_ESP32) - static uint8_t next_bus_num = 0; - if (next_bus_num == 0) { - wire_ = &Wire; - } else { - wire_ = new TwoWire(next_bus_num); // NOLINT(cppcoreguidelines-owning-memory) - } - this->port_ = next_bus_num; - next_bus_num++; -#elif defined(USE_ESP8266) +#if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) #elif defined(USE_RP2040) static bool first = true; @@ -54,10 +45,7 @@ void ArduinoI2CBus::set_pins_and_clock_() { wire_->begin(static_cast(sda_pin_), static_cast(scl_pin_)); #endif if (timeout_ > 0) { // if timeout specified in yaml -#if defined(USE_ESP32) - // https://github.com/espressif/arduino-esp32/blob/master/libraries/Wire/src/Wire.cpp - wire_->setTimeOut(timeout_ / 1000); // unit: ms -#elif defined(USE_ESP8266) +#if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h wire_->setClockStretchLimit(timeout_); // unit: us #elif defined(USE_RP2040) @@ -76,9 +64,7 @@ void ArduinoI2CBus::dump_config() { " Frequency: %u Hz", this->sda_pin_, this->scl_pin_, this->frequency_); if (timeout_ > 0) { -#if defined(USE_ESP32) - ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); -#elif defined(USE_ESP8266) +#if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); #elif defined(USE_RP2040) ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); @@ -275,4 +261,4 @@ void ArduinoI2CBus::recover_() { } // namespace i2c } // namespace esphome -#endif // USE_ESP_IDF +#endif // defined(USE_ARDUINO) && !defined(USE_ESP32) diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index b441828353..2d69e7684c 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) #include #include "esphome/core/component.h" @@ -29,7 +29,7 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { void set_frequency(uint32_t frequency) { frequency_ = frequency; } void set_timeout(uint32_t timeout) { timeout_ = timeout; } - int get_port() const override { return this->port_; } + int get_port() const override { return 0; } private: void recover_(); @@ -37,7 +37,6 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { RecoveryCode recovery_result_; protected: - int8_t port_{-1}; TwoWire *wire_; uint8_t sda_pin_; uint8_t scl_pin_; @@ -49,4 +48,4 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { } // namespace i2c } // namespace esphome -#endif // USE_ARDUINO +#endif // defined(USE_ARDUINO) && !defined(USE_ESP32) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index bf50ea0586..c22db51c68 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "i2c_bus_esp_idf.h" @@ -299,4 +299,4 @@ void IDFI2CBus::recover_() { } // namespace i2c } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index f565be4535..63fe8b701c 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "esphome/core/component.h" #include "i2c_bus.h" @@ -53,4 +53,4 @@ class IDFI2CBus : public InternalI2CBus, public Component { } // namespace i2c } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 From d7da55988552ca5a044e57b84a5c284763efb66c Mon Sep 17 00:00:00 2001 From: Sascha Ittner Date: Mon, 24 Nov 2025 18:31:26 +0100 Subject: [PATCH 105/320] [thermopro_ble] Add thermopro ble support (#11835) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/thermopro_ble/__init__.py | 0 esphome/components/thermopro_ble/sensor.py | 97 +++++++++ .../thermopro_ble/thermopro_ble.cpp | 204 ++++++++++++++++++ .../components/thermopro_ble/thermopro_ble.h | 49 +++++ tests/components/thermopro_ble/common.yaml | 13 ++ .../thermopro_ble/test.esp32-idf.yaml | 4 + 7 files changed, 368 insertions(+) create mode 100644 esphome/components/thermopro_ble/__init__.py create mode 100644 esphome/components/thermopro_ble/sensor.py create mode 100644 esphome/components/thermopro_ble/thermopro_ble.cpp create mode 100644 esphome/components/thermopro_ble/thermopro_ble.h create mode 100644 tests/components/thermopro_ble/common.yaml create mode 100644 tests/components/thermopro_ble/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d6ec7b882e..c6332e3933 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -484,6 +484,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core esphome/components/tinyusb/* @kbx81 diff --git a/esphome/components/thermopro_ble/__init__.py b/esphome/components/thermopro_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py new file mode 100644 index 0000000000..de63229621 --- /dev/null +++ b/esphome/components/thermopro_ble/sensor.py @@ -0,0 +1,97 @@ +import esphome.codegen as cg +from esphome.components import esp32_ble_tracker, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_EXTERNAL_TEMPERATURE, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_PERCENT, +) + +CODEOWNERS = ["@sittner"] + +DEPENDENCIES = ["esp32_ble_tracker"] + +thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble") +ThermoProBLE = thermopro_ble_ns.class_( + "ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ThermoProBLE), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_EXTERNAL_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await esp32_ble_tracker.register_ble_device(var, config) + + cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature(sens)) + if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): + sens = await sensor.new_sensor(external_temperature_config) + cg.add(var.set_external_temperature(sens)) + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity(sens)) + if battery_level_config := config.get(CONF_BATTERY_LEVEL): + sens = await sensor.new_sensor(battery_level_config) + cg.add(var.set_battery_level(sens)) + if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): + sens = await sensor.new_sensor(signal_strength_config) + cg.add(var.set_signal_strength(sens)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp new file mode 100644 index 0000000000..4b43c9b39e --- /dev/null +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -0,0 +1,204 @@ +#include "thermopro_ble.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome::thermopro_ble { + +// this size must be large enough to hold the largest data frame +// of all supported devices +static constexpr std::size_t MAX_DATA_SIZE = 24; + +struct DeviceParserMapping { + const char *prefix; + DeviceParser parser; +}; + +static float tp96_battery(uint16_t voltage); + +static optional parse_tp972(const uint8_t *data, std::size_t data_size); +static optional parse_tp96(const uint8_t *data, std::size_t data_size); +static optional parse_tp3(const uint8_t *data, std::size_t data_size); + +static const char *const TAG = "thermopro_ble"; + +static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { + {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; + +void ThermoProBLE::dump_config() { + ESP_LOGCONFIG(TAG, "ThermoPro BLE"); + LOG_SENSOR(" ", "Temperature", this->temperature_); + LOG_SENSOR(" ", "External temperature", this->external_temperature_); + LOG_SENSOR(" ", "Humidity", this->humidity_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +} + +bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + // check for matching mac address + if (device.address_uint64() != this->address_) { + ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + return false; + } + + // check for valid device type + update_device_type_(device.get_name()); + if (this->device_parser_ == nullptr) { + ESP_LOGVV(TAG, "parse_device(): invalid device type."); + return false; + } + + ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str()); + + // publish signal strength + float signal_strength = float(device.get_rssi()); + if (this->signal_strength_ != nullptr) + this->signal_strength_->publish_state(signal_strength); + + bool success = false; + for (auto &service_data : device.get_manufacturer_datas()) { + // check maximum data size + std::size_t data_size = service_data.data.size() + 2; + if (data_size > MAX_DATA_SIZE) { + ESP_LOGVV(TAG, "parse_device(): maximum data size exceeded!"); + continue; + } + + // reconstruct whole record from 2 byte uuid and data + esp_bt_uuid_t uuid = service_data.uuid.get_uuid(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(uuid.uuid.uuid16), static_cast(uuid.uuid.uuid16 >> 8)}; + std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); + + // dispatch data to parser + optional result = this->device_parser_(data, data_size); + if (!result.has_value()) { + continue; + } + + // publish sensor values + if (result->temperature.has_value() && this->temperature_ != nullptr) + this->temperature_->publish_state(*result->temperature); + if (result->external_temperature.has_value() && this->external_temperature_ != nullptr) + this->external_temperature_->publish_state(*result->external_temperature); + if (result->humidity.has_value() && this->humidity_ != nullptr) + this->humidity_->publish_state(*result->humidity); + if (result->battery_level.has_value() && this->battery_level_ != nullptr) + this->battery_level_->publish_state(*result->battery_level); + + success = true; + } + + return success; +} + +void ThermoProBLE::update_device_type_(const std::string &device_name) { + // check for changed device name (should only happen on initial call) + if (this->device_name_ == device_name) { + return; + } + + // remember device name + this->device_name_ = device_name; + + // try to find device parser + for (const auto &mapping : DEVICE_PARSER_MAP) { + if (device_name.starts_with(mapping.prefix)) { + this->device_parser_ = mapping.parser; + return; + } + } + + // device type unknown + this->device_parser_ = nullptr; + ESP_LOGVV(TAG, "update_device_type_(): unknown device type %s.", device_name.c_str()); +} + +static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { + return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8); +} + +static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { + return static_cast(read_uint16(data, offset)); +} + +static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { + return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); +} + +// Battery calculation used with permission from: +// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py +// +// TP96x battery values appear to be a voltage reading, probably in millivolts. +// This means that calculating battery life from it is a non-linear function. +// Examining the curve, it looked fairly close to a curve from the tanh function. +// So, I created a script to use Tensorflow to optimize an equation in the format +// A*tanh(B*x+C)+D +// Where A,B,C,D are the variables to optimize for. This yielded the below function +static float tp96_battery(uint16_t voltage) { + float level = 52.317286f * tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + return std::max(0.0f, std::min(level, 100.0f)); +} + +static optional parse_tp972(const uint8_t *data, std::size_t data_size) { + if (data_size != 23) { + ESP_LOGVV(TAG, "parse_tp972(): payload has wrong size of %d (!= 23)!", data_size); + return {}; + } + + ParseResult result; + + // ambient temperature, 2 bytes, 16-bit unsigned integer, -54 °C offset + result.external_temperature = static_cast(read_uint16(data, 1)) - 54.0f; + + // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) + result.battery_level = tp96_battery(read_uint16(data, 3)); + + // internal temperature, 4 bytes, float, -54 °C offset + result.temperature = static_cast(read_uint32(data, 9)) - 54.0f; + + return result; +} + +static optional parse_tp96(const uint8_t *data, std::size_t data_size) { + if (data_size != 7) { + ESP_LOGVV(TAG, "parse_tp96(): payload has wrong size of %d (!= 7)!", data_size); + return {}; + } + + ParseResult result; + + // internal temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset + result.temperature = static_cast(read_uint16(data, 1)) - 30.0f; + + // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) + result.battery_level = tp96_battery(read_uint16(data, 3)); + + // ambient temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset + result.external_temperature = static_cast(read_uint16(data, 5)) - 30.0f; + + return result; +} + +static optional parse_tp3(const uint8_t *data, std::size_t data_size) { + if (data_size < 6) { + ESP_LOGVV(TAG, "parse_tp3(): payload has wrong size of %d (< 6)!", data_size); + return {}; + } + + ParseResult result; + + // temperature, 2 bytes, 16-bit signed integer, 0.1 °C + result.temperature = static_cast(read_int16(data, 1)) * 0.1f; + + // humidity, 1 byte, 8-bit unsigned integer, 1.0 % + result.humidity = static_cast(data[3]); + + // battery level, 2 bits (0-2) + result.battery_level = static_cast(data[4] & 0x3) * 50.0; + + return result; +} + +} // namespace esphome::thermopro_ble + +#endif diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h new file mode 100644 index 0000000000..38bed82102 --- /dev/null +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" + +#ifdef USE_ESP32 + +namespace esphome::thermopro_ble { + +struct ParseResult { + optional temperature; + optional external_temperature; + optional humidity; + optional battery_level; +}; + +using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); + +class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; }; + + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + void dump_config() override; + void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } + void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } + void set_external_temperature(sensor::Sensor *external_temperature) { + this->external_temperature_ = external_temperature; + } + void set_humidity(sensor::Sensor *humidity) { this->humidity_ = humidity; } + void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; } + + protected: + uint64_t address_; + std::string device_name_; + DeviceParser device_parser_{nullptr}; + sensor::Sensor *signal_strength_{nullptr}; + sensor::Sensor *temperature_{nullptr}; + sensor::Sensor *external_temperature_{nullptr}; + sensor::Sensor *humidity_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; + + void update_device_type_(const std::string &device_name); +}; + +} // namespace esphome::thermopro_ble + +#endif diff --git a/tests/components/thermopro_ble/common.yaml b/tests/components/thermopro_ble/common.yaml new file mode 100644 index 0000000000..297725e1c3 --- /dev/null +++ b/tests/components/thermopro_ble/common.yaml @@ -0,0 +1,13 @@ +esp32_ble_tracker: + +sensor: + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: "ThermoPro Temperature" + humidity: + name: "ThermoPro Humidity" + battery_level: + name: "ThermoPro Battery Level" + signal_strength: + name: "ThermoPro Signal Strength" diff --git a/tests/components/thermopro_ble/test.esp32-idf.yaml b/tests/components/thermopro_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..7a6541ae76 --- /dev/null +++ b/tests/components/thermopro_ble/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + +<<: !include common.yaml From b820e676161295d7d135128ff38a99016ecb0e5e Mon Sep 17 00:00:00 2001 From: Jordan Zucker Date: Mon, 24 Nov 2025 09:42:07 -0800 Subject: [PATCH 106/320] [prometheus] Add event and text base components metrics (#10240) Co-authored-by: Jordan Zucker Co-authored-by: J. Nick Koston --- .../prometheus/prometheus_handler.cpp | 106 ++++++++++++++++++ .../prometheus/prometheus_handler.h | 16 +++ tests/components/prometheus/common.yaml | 28 +++++ 3 files changed, 150 insertions(+) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 5cfcacf0cb..6b57a3f718 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -53,6 +53,18 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) { this->lock_row_(stream, obj, area, node, friendly_name); #endif +#ifdef USE_EVENT + this->event_type_(stream); + for (auto *obj : App.get_events()) + this->event_row_(stream, obj, area, node, friendly_name); +#endif + +#ifdef USE_TEXT + this->text_type_(stream); + for (auto *obj : App.get_texts()) + this->text_row_(stream, obj, area, node, friendly_name); +#endif + #ifdef USE_TEXT_SENSOR this->text_sensor_type_(stream); for (auto *obj : App.get_text_sensors()) @@ -547,6 +559,100 @@ void PrometheusHandler::text_sensor_row_(AsyncResponseStream *stream, text_senso } #endif +// Type-specific implementation +#ifdef USE_TEXT +void PrometheusHandler::text_type_(AsyncResponseStream *stream) { + stream->print(ESPHOME_F("#TYPE esphome_text_value gauge\n")); + stream->print(ESPHOME_F("#TYPE esphome_text_failed gauge\n")); +} +void PrometheusHandler::text_row_(AsyncResponseStream *stream, text::Text *obj, std::string &area, std::string &node, + std::string &friendly_name) { + if (obj->is_internal() && !this->include_internal_) + return; + if (obj->has_state()) { + // We have a valid value, output this value + stream->print(ESPHOME_F("esphome_text_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 0\n")); + // Data itself + stream->print(ESPHOME_F("esphome_text_value{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\",value=\"")); + stream->print(obj->state.c_str()); + stream->print(ESPHOME_F("\"} ")); + stream->print(ESPHOME_F("1.0")); + stream->print(ESPHOME_F("\n")); + } else { + // Invalid state + stream->print(ESPHOME_F("esphome_text_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 1\n")); + } +} +#endif + +// Type-specific implementation +#ifdef USE_EVENT +void PrometheusHandler::event_type_(AsyncResponseStream *stream) { + stream->print(ESPHOME_F("#TYPE esphome_event_value gauge\n")); + stream->print(ESPHOME_F("#TYPE esphome_event_failed gauge\n")); +} +void PrometheusHandler::event_row_(AsyncResponseStream *stream, event::Event *obj, std::string &area, std::string &node, + std::string &friendly_name) { + if (obj->is_internal() && !this->include_internal_) + return; + if (obj->get_last_event_type() != nullptr) { + // We have a valid event type, output this value + stream->print(ESPHOME_F("esphome_event_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 0\n")); + // Data itself + stream->print(ESPHOME_F("esphome_event_value{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\",last_event_type=\"")); + stream->print(obj->get_last_event_type()); + stream->print(ESPHOME_F("\"} ")); + stream->print(ESPHOME_F("1.0")); + stream->print(ESPHOME_F("\n")); + } else { + // No event triggered yet + stream->print(ESPHOME_F("esphome_event_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 1\n")); + } +} +#endif + // Type-specific implementation #ifdef USE_NUMBER void PrometheusHandler::number_type_(AsyncResponseStream *stream) { diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index c4598f44b0..45cc81b899 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -123,6 +123,22 @@ class PrometheusHandler : public AsyncWebHandler, public Component { std::string &friendly_name); #endif +#ifdef USE_EVENT + /// Return the type for prometheus + void event_type_(AsyncResponseStream *stream); + /// Return the event values state as prometheus data point + void event_row_(AsyncResponseStream *stream, event::Event *obj, std::string &area, std::string &node, + std::string &friendly_name); +#endif + +#ifdef USE_TEXT + /// Return the type for prometheus + void text_type_(AsyncResponseStream *stream); + /// Return the text values state as prometheus data point + void text_row_(AsyncResponseStream *stream, text::Text *obj, std::string &area, std::string &node, + std::string &friendly_name); +#endif + #ifdef USE_TEXT_SENSOR /// Return the type for prometheus void text_sensor_type_(AsyncResponseStream *stream); diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index cf46e882a7..0b90d614dd 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -39,6 +39,15 @@ sensor: return 0.0; update_interval: 60s +text: + - platform: template + name: "Template text" + optimistic: true + min_length: 0 + max_length: 100 + mode: text + initial_value: "Hello World" + text_sensor: - platform: version name: "ESPHome Version" @@ -52,6 +61,25 @@ text_sensor: return {"Goodbye (cruel) World"}; update_interval: 60s +event: + - platform: template + name: "Template Event" + id: template_event1 + event_types: + - "custom_event_1" + - "custom_event_2" + +button: + - platform: template + name: "Template Event Button" + on_press: + - logger.log: "Template Event Button pressed" + - lambda: |- + ESP_LOGD("template_event_button", "Template Event Button pressed"); + - event.trigger: + id: template_event1 + event_type: custom_event_1 + binary_sensor: - platform: template id: template_binary_sensor1 From 09f3f6219493ec28ed12fe3495e7c85c608620e1 Mon Sep 17 00:00:00 2001 From: Flo Date: Mon, 24 Nov 2025 18:49:16 +0100 Subject: [PATCH 107/320] [api] Connected Condition - state_subscription_only flag (#11906) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/api/__init__.py | 20 ++++++++++++++++++-- esphome/components/api/api_server.cpp | 13 ++++++++++++- esphome/components/api/api_server.h | 7 +++++-- tests/components/api/common-base.yaml | 4 ++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 7f84f2f247..2910643dfb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -85,6 +85,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" +CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" def validate_encryption_key(value): @@ -537,9 +538,24 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg return var -@automation.register_condition("api.connected", APIConnectedCondition, {}) +API_CONNECTED_CONDITION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(APIServer), + cv.Optional(CONF_STATE_SUBSCRIPTION_ONLY, default=False): cv.templatable( + cv.boolean + ), + } +) + + +@automation.register_condition( + "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA +) async def api_connected_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) + var = cg.new_Pvariable(condition_id, template_arg) + templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) + cg.add(var.set_state_subscription_only(templ)) + return var def FILTER_SOURCE_FILES() -> list[str]: diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 18601d74ff..d33c98abc9 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -528,7 +528,18 @@ void APIServer::request_time() { } #endif -bool APIServer::is_connected() const { return !this->clients_.empty(); } +bool APIServer::is_connected(bool state_subscription_only) const { + if (!state_subscription_only) { + return !this->clients_.empty(); + } + + for (const auto &client : this->clients_) { + if (client->flags_.state_subscription) { + return true; + } + } + return false; +} void APIServer::on_shutdown() { this->shutting_down_ = true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index a3a082e165..786cd63f44 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -150,7 +150,7 @@ class APIServer : public Component, public Controller { void on_zwave_proxy_request(const esphome::api::ProtoMessage &msg); #endif - bool is_connected() const; + bool is_connected(bool state_subscription_only = false) const; #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { @@ -236,8 +236,11 @@ class APIServer : public Component, public Controller { extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) template class APIConnectedCondition : public Condition { + TEMPLATABLE_VALUE(bool, state_subscription_only) public: - bool check(const Ts &...x) override { return global_api_server->is_connected(); } + bool check(const Ts &...x) override { + return global_api_server->is_connected(this->state_subscription_only_.value(x...)); + } }; } // namespace esphome::api diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index fc53b8ac7e..0416cebf9b 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -1,6 +1,10 @@ esphome: on_boot: then: + - wait_until: + condition: + api.connected: + state_subscription_only: true - homeassistant.event: event: esphome.button_pressed data: From c888becfa7369396c96fd1d4e8f807ebde57cc7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 11:52:15 -0600 Subject: [PATCH 108/320] [api] Optimize APINoiseContext memory usage by removing shared_ptr overhead (#11981) --- esphome/components/api/api_connection.cpp | 4 ++-- esphome/components/api/api_frame_helper_noise.cpp | 2 +- esphome/components/api/api_frame_helper_noise.h | 9 ++++----- esphome/components/api/api_server.cpp | 6 +++--- esphome/components/api/api_server.h | 6 +++--- esphome/components/mdns/mdns_component.cpp | 2 +- esphome/components/mqtt/mqtt_client.cpp | 2 +- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 04221a237b..ebfc641537 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -90,8 +90,8 @@ static const int CAMERA_STOP_STREAM = 5000; APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { #if defined(USE_API_PLAINTEXT) && defined(USE_API_NOISE) - auto noise_ctx = parent->get_noise_ctx(); - if (noise_ctx->has_psk()) { + auto &noise_ctx = parent->get_noise_ctx(); + if (noise_ctx.has_psk()) { this->helper_ = std::unique_ptr{new APINoiseFrameHelper(std::move(sock), noise_ctx, &this->client_info_)}; } else { diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 8bcec0f9f3..f1028fa299 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -528,7 +528,7 @@ APIError APINoiseFrameHelper::init_handshake_() { if (aerr != APIError::OK) return aerr; - const auto &psk = ctx_->get_psk(); + const auto &psk = this->ctx_.get_psk(); err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"), APIError::HANDSHAKESTATE_SETUP_FAILED); diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index e3243e4fa5..7eb01058db 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -9,9 +9,8 @@ namespace esphome::api { class APINoiseFrameHelper final : public APIFrameHelper { public: - APINoiseFrameHelper(std::unique_ptr socket, std::shared_ptr ctx, - const ClientInfo *client_info) - : APIFrameHelper(std::move(socket), client_info), ctx_(std::move(ctx)) { + APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx, const ClientInfo *client_info) + : APIFrameHelper(std::move(socket), client_info), ctx_(ctx) { // Noise header structure: // Pos 0: indicator (0x01) // Pos 1-2: encrypted payload size (16-bit big-endian) @@ -41,8 +40,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { NoiseCipherState *send_cipher_{nullptr}; NoiseCipherState *recv_cipher_{nullptr}; - // Shared pointer (8 bytes on 32-bit = 4 bytes control block pointer + 4 bytes object pointer) - std::shared_ptr ctx_; + // Reference to noise context (4 bytes on 32-bit) + APINoiseContext &ctx_; // Vector (12 bytes on 32-bit) std::vector prologue_; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d33c98abc9..64f8751c35 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -227,8 +227,8 @@ void APIServer::dump_config() { " Max connections: %u", network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_); #ifdef USE_API_NOISE - ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); - if (!this->noise_ctx_->has_psk()) { + ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); + if (!this->noise_ctx_.has_psk()) { ESP_LOGCONFIG(TAG, " Supports encryption: YES"); } #else @@ -493,7 +493,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { ESP_LOGW(TAG, "Key set in YAML"); return false; #else - auto &old_psk = this->noise_ctx_->get_psk(); + auto &old_psk = this->noise_ctx_.get_psk(); if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { ESP_LOGW(TAG, "New PSK matches old"); return true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 786cd63f44..428429418a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -54,8 +54,8 @@ class APIServer : public Component, public Controller { #ifdef USE_API_NOISE bool save_noise_psk(psk_t psk, bool make_active = true); bool clear_noise_psk(bool make_active = true); - void set_noise_psk(psk_t psk) { noise_ctx_->set_psk(psk); } - std::shared_ptr get_noise_ctx() { return noise_ctx_; } + void set_noise_psk(psk_t psk) { this->noise_ctx_.set_psk(psk); } + APINoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE void handle_disconnect(APIConnection *conn); @@ -228,7 +228,7 @@ class APIServer : public Component, public Controller { // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE - std::shared_ptr noise_ctx_ = std::make_shared(); + APINoiseContext noise_ctx_; ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index c81defd19f..4655907983 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -118,7 +118,7 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx()->has_psk(); + bool has_psk = api::global_api_server->get_noise_ctx().has_psk(); const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); #endif diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 9055b4421e..a810d98adf 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -140,7 +140,7 @@ void MQTTClientComponent::send_device_info_() { #endif #ifdef USE_API_NOISE - root[api::global_api_server->get_noise_ctx()->has_psk() ? "api_encryption" : "api_encryption_supported"] = + root[api::global_api_server->get_noise_ctx().has_psk() ? "api_encryption" : "api_encryption_supported"] = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; #endif }, From c146d924255eebc958e4f5b8fc5706c2021af494 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 11:53:42 -0600 Subject: [PATCH 109/320] [api] Remove redundant socket pointer from APIFrameHelper (#11985) --- esphome/components/api/api_frame_helper.h | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9aaada3cf7..d931a6e3a9 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -84,9 +84,7 @@ class APIFrameHelper { public: APIFrameHelper() = default; explicit APIFrameHelper(std::unique_ptr socket, const ClientInfo *client_info) - : socket_owned_(std::move(socket)), client_info_(client_info) { - socket_ = socket_owned_.get(); - } + : socket_(std::move(socket)), client_info_(client_info) {} virtual ~APIFrameHelper() = default; virtual APIError init() = 0; virtual APIError loop(); @@ -149,9 +147,8 @@ class APIFrameHelper { APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector &tx_buf, const std::string &info, StateEnum &state, StateEnum failed_state); - // Pointers first (4 bytes each) - socket::Socket *socket_{nullptr}; - std::unique_ptr socket_owned_; + // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) + std::unique_ptr socket_; // Common state enum for all frame helpers // Note: Not all states are used by all implementations From d1a1bb446b9014ff4e591580102dfc07931099d9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 24 Nov 2025 12:55:04 -0500 Subject: [PATCH 110/320] [wifi] Add runtime power saving mode control (#11478) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 18 ++++- esphome/components/wifi/wifi_component.cpp | 90 +++++++++++++++++++++- esphome/components/wifi/wifi_component.h | 42 ++++++++++ esphome/core/defines.h | 1 + tests/components/wifi/test.esp32-idf.yaml | 11 +++ 5 files changed, 160 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b9c0fa28a7..8a5e5329f1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -607,6 +607,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" +RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" def request_wifi_scan_results(): @@ -619,13 +620,28 @@ def request_wifi_scan_results(): CORE.data[KEEP_SCAN_RESULTS_KEY] = True +def enable_runtime_power_save_control(): + """Enable runtime WiFi power save control. + + Components that need to dynamically switch WiFi power saving on/off for latency + performance (e.g., audio streaming, large data transfers) should call this + function during their code generation. This enables the request_high_performance() + and release_high_performance() APIs. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_POWER_SAVE_KEY] = True + + @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): - """Final code generation step to configure scan result retention.""" + """Final code generation step to configure optional WiFi features.""" if CORE.data.get(KEEP_SCAN_RESULTS_KEY, False): cg.add( cg.RawExpression("wifi::global_wifi_component->set_keep_scan_results(true)") ) + if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") @automation.register_action( diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 23a4020453..41931a7785 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -330,6 +330,19 @@ float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; } void WiFiComponent::setup() { this->wifi_pre_setup_(); + +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + // Create semaphore for high-performance mode requests + // Start at 0, increment on request, decrement on release + this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0); + if (this->high_performance_semaphore_ == nullptr) { + ESP_LOGE(TAG, "Failed semaphore"); + } + + // Store the configured power save mode as baseline + this->configured_power_save_ = this->power_save_; +#endif + if (this->enable_on_boot_) { this->start(); } else { @@ -371,6 +384,19 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Output Power Option failed"); } +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + // Synchronize power_save_ with semaphore state before applying + if (this->high_performance_semaphore_ != nullptr) { + UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_); + if (semaphore_count > 0) { + this->power_save_ = WIFI_POWER_SAVE_NONE; + this->is_high_performance_mode_ = true; + } else { + this->power_save_ = this->configured_power_save_; + this->is_high_performance_mode_ = false; + } + } +#endif if (!this->wifi_apply_power_save_()) { ESP_LOGV(TAG, "Setting Power Save Option failed"); } @@ -525,6 +551,31 @@ void WiFiComponent::loop() { } } } + +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + // Check if power save mode needs to be updated based on high-performance requests + if (this->high_performance_semaphore_ != nullptr) { + // Semaphore count directly represents active requests (starts at 0, increments on request) + UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_); + + if (semaphore_count > 0 && !this->is_high_performance_mode_) { + // Transition to high-performance mode (no power save) + ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count, + semaphore_count == 1 ? "request" : "requests"); + this->power_save_ = WIFI_POWER_SAVE_NONE; + if (this->wifi_apply_power_save_()) { + this->is_high_performance_mode_ = true; + } + } else if (semaphore_count == 0 && this->is_high_performance_mode_) { + // Restore to configured power save mode + ESP_LOGV(TAG, "Restoring power save mode to configured setting"); + this->power_save_ = this->configured_power_save_; + if (this->wifi_apply_power_save_()) { + this->is_high_performance_mode_ = false; + } + } + } +#endif } WiFiComponent::WiFiComponent() { global_wifi_component = this; } @@ -1567,7 +1618,12 @@ bool WiFiComponent::is_connected() { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; } -void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; } +void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { + this->power_save_ = power_save; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + this->configured_power_save_ = power_save; +#endif +} void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; } @@ -1586,6 +1642,38 @@ bool WiFiComponent::is_esp32_improv_active_() { #endif } +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) +bool WiFiComponent::request_high_performance() { + // Already configured for high performance - request satisfied + if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) { + return true; + } + + // Semaphore initialization failed + if (this->high_performance_semaphore_ == nullptr) { + return false; + } + + // Give the semaphore (non-blocking). This increments the count. + return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; +} + +bool WiFiComponent::release_high_performance() { + // Already configured for high performance - nothing to release + if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) { + return true; + } + + // Semaphore initialization failed + if (this->high_performance_semaphore_ == nullptr) { + return false; + } + + // Take the semaphore (non-blocking). This decrements the count. + return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE; +} +#endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE + #ifdef USE_WIFI_FAST_CONNECT bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { SavedWifiFastConnectSettings fast_connect_save{}; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 441606a2c1..0dac80ad21 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -49,6 +49,11 @@ extern "C" { #include #endif +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) +#include +#include +#endif + namespace esphome { namespace wifi { @@ -365,6 +370,37 @@ class WiFiComponent : public Component { int32_t get_wifi_channel(); +#ifdef USE_WIFI_RUNTIME_POWER_SAVE + /** Request high-performance mode (no power saving) for improved WiFi latency. + * + * Components that need maximum WiFi performance (e.g., audio streaming, large data transfers) + * can call this method to temporarily disable WiFi power saving. Multiple components can + * request high performance simultaneously using a counting semaphore. + * + * Power saving will be restored to the YAML-configured mode when all components have + * called release_high_performance(). + * + * Note: Only supported on ESP32. + * + * @return true if request was satisfied (high-performance mode active or already configured), + * false if operation failed (semaphore error) + */ + bool request_high_performance(); + + /** Release a high-performance mode request. + * + * Should be called when a component no longer needs maximum WiFi latency. + * When all requests are released (semaphore count reaches zero), WiFi power saving + * is restored to the YAML-configured mode. + * + * Note: Only supported on ESP32. + * + * @return true if release was successful (or already in high-performance config), + * false if operation failed (semaphore error) + */ + bool release_high_performance(); +#endif // USE_WIFI_RUNTIME_POWER_SAVE + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); @@ -535,6 +571,12 @@ class WiFiComponent : public Component { bool keep_scan_results_{false}; bool did_scan_this_cycle_{false}; bool skip_cooldown_next_cycle_{false}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; + bool is_high_performance_mode_{false}; + + SemaphoreHandle_t high_performance_semaphore_{nullptr}; +#endif // Pointers at the end (naturally aligned) Trigger<> *connect_trigger_{new Trigger<>()}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5e7f51e04c..4b24c395b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -210,6 +210,7 @@ #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 #ifdef USE_ARDUINO diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index 6b3ef20963..3e01d7f990 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,5 +1,16 @@ psram: +# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +esphome: + platformio_options: + build_flags: + - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + on_boot: + - then: + - lambda: |- + esphome::wifi::global_wifi_component->request_high_performance(); + esphome::wifi::global_wifi_component->release_high_performance(); + wifi: use_psram: true min_auth_mode: WPA From 7a73a524b94008c31cdcd895feaa5131335af975 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 12:21:09 -0600 Subject: [PATCH 111/320] [logger] Eliminate strlen overhead on LibreTiny (#11938) --- esphome/components/logger/logger.h | 4 ++-- esphome/components/logger/logger_libretiny.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 8ba3dacacb..6a8b640331 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -72,11 +72,11 @@ static constexpr uint16_t MAX_HEADER_SIZE = 128; static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1; // Platform-specific: does write_msg_ add its own newline? -// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266) +// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266, LibreTiny) // Allows single write call with newline included for efficiency // true: write_msg_ adds newline itself via puts()/println() (other platforms) // Newline should NOT be added to buffer -#if defined(USE_ESP32) || defined(USE_ESP8266) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_LIBRETINY) static constexpr bool WRITE_MSG_ADDS_NEWLINE = false; #else static constexpr bool WRITE_MSG_ADDS_NEWLINE = true; diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index b8017b841d..cdf55e710c 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -49,7 +49,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t len) { this->hw_serial_->write(msg, len); } const LogString *Logger::get_uart_selection_() { switch (this->uart_) { From 0dd842744a1c5ab50a0bae97fa51766f82bbd506 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:44:09 -0600 Subject: [PATCH 112/320] Bump github/codeql-action from 4.31.4 to 4.31.5 (#12080) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 80fab8819a..d10c8bf267 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 + uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 + uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 with: category: "/language:${{matrix.language}}" From 378fc4120ae7621bd03ecebc8a51808dc890b535 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:44:27 -0600 Subject: [PATCH 113/320] Bump peter-evans/create-pull-request from 7.0.8 to 7.0.9 (#12082) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 2e36dc517d..8f95fa68ee 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From e2cd0ccd0e05a43fff011c008425cb6bcfa457c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:44:43 -0600 Subject: [PATCH 114/320] Bump actions/create-github-app-token from 2.1.4 to 2.2.0 (#12081) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 8d8e08a5fc..998f3315c6 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -26,7 +26,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From a0440603b7ea6585680a28de05bc169dbead0739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 13:45:06 -0600 Subject: [PATCH 115/320] [wifi] Use ESP-IDF IP formatting macros directly to eliminate heap allocations (#12078) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4aac03885a..e6e914c0b4 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -603,10 +603,6 @@ const char *get_auth_mode_str(uint8_t mode) { } } -std::string format_ip4_addr(const esp_ip4_addr_t &ip) { return str_snprintf(IPSTR, 15, IP2STR(&ip)); } -#if LWIP_IPV6 -std::string format_ip6_addr(const esp_ip6_addr_t &ip) { return str_snprintf(IPV6STR, 39, IPV62STR(ip)); } -#endif /* LWIP_IPV6 */ const char *get_disconnect_reason_str(uint8_t reason) { switch (reason) { case WIFI_REASON_AUTH_EXPIRE: @@ -761,14 +757,13 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #if USE_NETWORK_IPV6 esp_netif_create_ip6_linklocal(s_sta_netif); #endif /* USE_NETWORK_IPV6 */ - ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(it.ip_info.ip).c_str(), - format_ip4_addr(it.ip_info.gw).c_str()); + ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; #if USE_NETWORK_IPV6 } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_GOT_IP6) { const auto &it = data->data.ip_got_ip6; - ESP_LOGV(TAG, "IPv6 address=%s", format_ip6_addr(it.ip6_info.ip).c_str()); + ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip)); this->num_ipv6_addresses_++; #endif /* USE_NETWORK_IPV6 */ @@ -832,7 +827,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_AP_STAIPASSIGNED) { const auto &it = data->data.ip_ap_staipassigned; - ESP_LOGV(TAG, "AP client assigned IP %s", format_ip4_addr(it.ip).c_str()); + ESP_LOGV(TAG, "AP client assigned IP " IPSTR, IP2STR(&it.ip)); } } From 909baf5e7a8daee99775775220367cf994a377db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 13:45:29 -0600 Subject: [PATCH 116/320] [prometheus] Use current_option() instead of deprecated .state for select entities (#12079) --- esphome/components/prometheus/prometheus_handler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 6b57a3f718..812b547860 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -726,7 +726,7 @@ void PrometheusHandler::select_row_(AsyncResponseStream *stream, select::Select stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",value=\"")); - stream->print(obj->state.c_str()); + stream->print(obj->current_option()); stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); From 97ba67f4eee3a85e0e416565cba3023900f66f0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 13:45:56 -0600 Subject: [PATCH 117/320] [core] Deprecate unsafe const char* APIs in mark_failed() and status_set_error(), add LogString* overloads (#12021) --- .../absolute_humidity/absolute_humidity.cpp | 2 +- esphome/components/aht10/aht10.cpp | 2 +- esphome/components/bh1900nux/bh1900nux.cpp | 2 +- .../components/bme280_base/bme280_base.cpp | 18 +++--- .../components/bmp280_base/bmp280_base.cpp | 16 +++--- esphome/components/camera/camera.cpp | 2 +- .../cst816/touchscreen/cst816_touchscreen.cpp | 4 +- esphome/components/epaper_spi/epaper_spi.cpp | 4 +- .../update/esp32_hosted_update.cpp | 10 ++-- esphome/components/esp_ldo/esp_ldo.cpp | 2 +- esphome/components/gdk101/gdk101.cpp | 6 +- .../gt911/touchscreen/gt911_touchscreen.cpp | 4 +- .../update/http_request_update.cpp | 9 +-- esphome/components/lvgl/lvgl_esphome.cpp | 4 +- esphome/components/max17043/max17043.cpp | 4 +- esphome/components/mipi_dsi/mipi_dsi.cpp | 22 ++++---- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 8 +-- esphome/components/mipi_spi/mipi_spi.h | 2 +- .../mixer/speaker/mixer_speaker.cpp | 13 +++-- esphome/components/nau7802/nau7802.cpp | 2 +- .../packet_transport/packet_transport.cpp | 2 +- esphome/components/qmp6988/qmp6988.cpp | 2 +- .../resampler/speaker/resampler_speaker.cpp | 12 ++-- esphome/components/sht4x/sht4x.cpp | 2 +- esphome/components/stts22h/stts22h.cpp | 12 ++-- esphome/components/udp/udp_component.cpp | 10 ++-- .../components/usb_host/usb_host_client.cpp | 2 +- .../usb_host/usb_host_component.cpp | 2 +- esphome/components/usb_uart/usb_uart.cpp | 4 +- .../voice_assistant/voice_assistant.cpp | 2 +- .../components/wake_on_lan/wake_on_lan.cpp | 2 +- esphome/core/component.cpp | 55 ++++++++++++++----- esphome/core/component.h | 21 ++++++- 34 files changed, 157 insertions(+), 109 deletions(-) diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 2c5603ee3d..d16a024d86 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -87,7 +87,7 @@ void AbsoluteHumidityComponent::loop() { break; default: this->publish_state(NAN); - this->status_set_error("Invalid saturation vapor pressure equation selection!"); + this->status_set_error(LOG_STR("Invalid saturation vapor pressure equation selection!")); return; } ESP_LOGD(TAG, "Saturation vapor pressure %f kPa", es); diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 53c712a7a7..03d9d9cd9e 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -83,7 +83,7 @@ void AHT10Component::setup() { void AHT10Component::restart_read_() { if (this->read_count_ == AHT10_ATTEMPTS) { this->read_count_ = 0; - this->status_set_error("Reading timed out"); + this->status_set_error(LOG_STR("Reading timed out")); return; } this->read_count_++; diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 96a06adaa0..0e71bd6532 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -23,7 +23,7 @@ void BH1900NUXSensor::setup() { i2c::ErrorCode result_code = this->write_register(SOFT_RESET_REG, &SOFT_RESET_PAYLOAD, 1); // Software Reset to check communication if (result_code != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 86b65d361d..c5d4c9c0a5 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -100,18 +100,18 @@ void BME280Component::setup() { if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x60) { this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(BME280_ERROR_WRONG_CHIP_ID); + this->mark_failed(LOG_STR(BME280_ERROR_WRONG_CHIP_ID)); return; } // Send a soft reset. if (!this->write_byte(BME280_REGISTER_RESET, BME280_SOFT_RESET)) { - this->mark_failed("Reset failed"); + this->mark_failed(LOG_STR("Reset failed")); return; } // Wait until the NVM data has finished loading. @@ -120,12 +120,12 @@ void BME280Component::setup() { do { // NOLINT delay(2); if (!this->read_byte(BME280_REGISTER_STATUS, &status)) { - this->mark_failed("Error reading status register"); + this->mark_failed(LOG_STR("Error reading status register")); return; } } while ((status & BME280_STATUS_IM_UPDATE) && (--retry)); if (status & BME280_STATUS_IM_UPDATE) { - this->mark_failed("Timeout loading NVM"); + this->mark_failed(LOG_STR("Timeout loading NVM")); return; } @@ -153,26 +153,26 @@ void BME280Component::setup() { uint8_t humid_control_val = 0; if (!this->read_byte(BME280_REGISTER_CONTROLHUMID, &humid_control_val)) { - this->mark_failed("Read humidity control"); + this->mark_failed(LOG_STR("Read humidity control")); return; } humid_control_val &= ~0b00000111; humid_control_val |= this->humidity_oversampling_ & 0b111; if (!this->write_byte(BME280_REGISTER_CONTROLHUMID, humid_control_val)) { - this->mark_failed("Write humidity control"); + this->mark_failed(LOG_STR("Write humidity control")); return; } uint8_t config_register = 0; if (!this->read_byte(BME280_REGISTER_CONFIG, &config_register)) { - this->mark_failed("Read config"); + this->mark_failed(LOG_STR("Read config")); return; } config_register &= ~0b11111100; config_register |= 0b101 << 5; // 1000 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->write_byte(BME280_REGISTER_CONFIG, config_register)) { - this->mark_failed("Write config"); + this->mark_failed(LOG_STR("Write config")); return; } } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 39654f5875..728eead521 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -65,23 +65,23 @@ void BMP280Component::setup() { // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x58) { this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(BMP280_ERROR_WRONG_CHIP_ID); + this->mark_failed(LOG_STR(BMP280_ERROR_WRONG_CHIP_ID)); return; } // Send a soft reset. if (!this->bmp_write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { - this->mark_failed("Reset failed"); + this->mark_failed(LOG_STR("Reset failed")); return; } // Wait until the NVM data has finished loading. @@ -90,12 +90,12 @@ void BMP280Component::setup() { do { delay(2); if (!this->bmp_read_byte(BMP280_REGISTER_STATUS, &status)) { - this->mark_failed("Error reading status register"); + this->mark_failed(LOG_STR("Error reading status register")); return; } } while ((status & BMP280_STATUS_IM_UPDATE) && (--retry)); if (status & BMP280_STATUS_IM_UPDATE) { - this->mark_failed("Timeout loading NVM"); + this->mark_failed(LOG_STR("Timeout loading NVM")); return; } @@ -116,14 +116,14 @@ void BMP280Component::setup() { uint8_t config_register = 0; if (!this->bmp_read_byte(BMP280_REGISTER_CONFIG, &config_register)) { - this->mark_failed("Read config"); + this->mark_failed(LOG_STR("Read config")); return; } config_register &= ~0b11111100; config_register |= 0b000 << 5; // 0.5 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->bmp_write_byte(BMP280_REGISTER_CONFIG, config_register)) { - this->mark_failed("Write config"); + this->mark_failed(LOG_STR("Write config")); return; } } diff --git a/esphome/components/camera/camera.cpp b/esphome/components/camera/camera.cpp index 3bd632af5c..66b8138f38 100644 --- a/esphome/components/camera/camera.cpp +++ b/esphome/components/camera/camera.cpp @@ -8,7 +8,7 @@ Camera *Camera::global_camera = nullptr; Camera::Camera() { if (global_camera != nullptr) { - this->status_set_error("Multiple cameras are configured, but only one is supported."); + this->status_set_error(LOG_STR("Multiple cameras are configured, but only one is supported.")); this->mark_failed(); return; } diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index 0560f1b475..f6280a75a1 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -20,13 +20,13 @@ void CST816Touchscreen::continue_setup_() { break; default: ESP_LOGE(TAG, "Unknown chip ID: 0x%02X", this->chip_id_); - this->status_set_error("Unknown chip ID"); + this->status_set_error(LOG_STR("Unknown chip ID")); this->mark_failed(); return; } this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); } else if (!this->skip_probe_) { - this->status_set_error("Failed to read chip id"); + this->status_set_error(LOG_STR("Failed to read chip id")); this->mark_failed(); return; } diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index cf6a0b0c3d..39959cd743 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -22,7 +22,7 @@ const char *EPaperBase::epaper_state_to_string_() { void EPaperBase::setup() { if (!this->init_buffer_(this->buffer_length_)) { - this->mark_failed("Failed to initialise buffer"); + this->mark_failed(LOG_STR("Failed to initialise buffer")); return; } this->setup_pins_(); @@ -246,7 +246,7 @@ void EPaperBase::initialise_() { auto length = this->init_sequence_length_; while (index != length) { if (length - index < 2) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } const uint8_t cmd = sequence[index++]; diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index adbcc5bf11..f34a0ae10e 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -88,7 +88,7 @@ void Esp32HostedUpdate::perform(bool force) { hasher.add(this->firmware_data_, this->firmware_size_); hasher.calculate(); if (!hasher.equals_bytes(this->firmware_sha256_.data())) { - this->status_set_error("SHA256 verification failed"); + this->status_set_error(LOG_STR("SHA256 verification failed")); this->publish_state(); return; } @@ -105,7 +105,7 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); this->state_ = prev_state; - this->status_set_error("Failed to begin OTA"); + this->status_set_error(LOG_STR("Failed to begin OTA")); this->publish_state(); return; } @@ -121,7 +121,7 @@ void Esp32HostedUpdate::perform(bool force) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); esp_hosted_slave_ota_end(); // NOLINT this->state_ = prev_state; - this->status_set_error("Failed to write OTA data"); + this->status_set_error(LOG_STR("Failed to write OTA data")); this->publish_state(); return; } @@ -134,7 +134,7 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(err)); this->state_ = prev_state; - this->status_set_error("Failed to end OTA"); + this->status_set_error(LOG_STR("Failed to end OTA")); this->publish_state(); return; } @@ -144,7 +144,7 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to activate OTA: %s", esp_err_to_name(err)); this->state_ = prev_state; - this->status_set_error("Failed to activate OTA"); + this->status_set_error(LOG_STR("Failed to activate OTA")); this->publish_state(); return; } diff --git a/esphome/components/esp_ldo/esp_ldo.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index 9ea7000b70..5e3d4159f3 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -15,7 +15,7 @@ void EspLdo::setup() { auto err = esp_ldo_acquire_channel(&config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_); - this->mark_failed("Failed to acquire LDO channel"); + this->mark_failed(LOG_STR("Failed to acquire LDO channel")); } else { ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_); } diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 6c218f03d9..617e2138fb 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -36,20 +36,20 @@ void GDK101Component::setup() { uint8_t data[2]; // first, reset the sensor if (!this->reset_sensor_(data)) { - this->status_set_error("Reset failed!"); + this->status_set_error(LOG_STR("Reset failed!")); this->mark_failed(); return; } // sensor should acknowledge success of the reset procedure if (data[0] != 1) { - this->status_set_error("Reset not acknowledged!"); + this->status_set_error(LOG_STR("Reset not acknowledged!")); this->mark_failed(); return; } delay(10); // read firmware version if (!this->read_fw_version_(data)) { - this->status_set_error("Failed to read firmware version"); + this->status_set_error(LOG_STR("Failed to read firmware version")); this->mark_failed(); return; } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 992a86cc21..b11880a042 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -79,13 +79,13 @@ void GT911Touchscreen::setup_internal_() { } } if (err != i2c::ERROR_OK) { - this->mark_failed("Calibration error"); + this->mark_failed(LOG_STR("Calibration error")); return; } } if (err != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->setup_done_ = true; diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 9dbf8d181a..c91b0eba73 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -29,7 +29,7 @@ void HttpRequestUpdate::setup() { this->publish_state(); } else if (state == ota::OTAState::OTA_ABORT || state == ota::OTAState::OTA_ERROR) { this->state_ = update::UPDATE_STATE_AVAILABLE; - this->status_set_error("Failed to install firmware"); + this->status_set_error(LOG_STR("Failed to install firmware")); this->publish_state(); } }); @@ -51,7 +51,7 @@ void HttpRequestUpdate::update_task(void *params) { if (container == nullptr || container->status_code != HTTP_STATUS_OK) { ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error("Failed to fetch manifest"); }); + this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to fetch manifest")); }); UPDATE_RETURN; } @@ -60,7 +60,8 @@ void HttpRequestUpdate::update_task(void *params) { if (data == nullptr) { ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error("Failed to allocate memory for manifest"); }); + this_update->defer( + [this_update]() { this_update->status_set_error(LOG_STR("Failed to allocate memory for manifest")); }); container->end(); UPDATE_RETURN; } @@ -123,7 +124,7 @@ void HttpRequestUpdate::update_task(void *params) { if (!valid) { ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update]() { this_update->status_set_error("Failed to parse manifest JSON"); }); + this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to parse manifest JSON")); }); UPDATE_RETURN; } diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 05005b0217..fbcd68378c 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -466,7 +466,7 @@ void LvglComponent::setup() { buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } if (buffer == nullptr) { - this->status_set_error("Memory allocation failure"); + this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } @@ -479,7 +479,7 @@ void LvglComponent::setup() { if (this->rotation != display::DISPLAY_ROTATION_0_DEGREES) { this->rotate_buf_ = static_cast(lv_custom_mem_alloc(buf_bytes)); // NOLINT if (this->rotate_buf_ == nullptr) { - this->status_set_error("Memory allocation failure"); + this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index f605fb1324..e8cf4d5ab1 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -57,14 +57,14 @@ void MAX17043Component::setup() { if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) { ESP_LOGE(TAG, "Device does not appear to be a MAX17043"); - this->status_set_error("unrecognised"); + this->status_set_error(LOG_STR("unrecognised")); this->mark_failed(); return; } // need to write back to config register to reset the sleep bit if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) { - this->status_set_error("sleep reset failed"); + this->status_set_error(LOG_STR("sleep reset failed")); this->mark_failed(); return; } diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 7305435e4b..cae8647398 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -12,8 +12,8 @@ static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel return (need_yield == pdTRUE); } -void MIPI_DSI::smark_failed(const char *message, esp_err_t err) { - ESP_LOGE(TAG, "%s: %s", message, esp_err_to_name(err)); +void MIPI_DSI::smark_failed(const LogString *message, esp_err_t err) { + ESP_LOGE(TAG, "%s: %s", LOG_STR_ARG(message), esp_err_to_name(err)); this->mark_failed(message); } @@ -37,7 +37,7 @@ void MIPI_DSI::setup() { }; auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_); if (err != ESP_OK) { - this->smark_failed("lcd_new_dsi_bus failed", err); + this->smark_failed(LOG_STR("lcd_new_dsi_bus failed"), err); return; } esp_lcd_dbi_io_config_t dbi_config = { @@ -47,7 +47,7 @@ void MIPI_DSI::setup() { }; err = esp_lcd_new_panel_io_dbi(this->bus_handle_, &dbi_config, &this->io_handle_); if (err != ESP_OK) { - this->smark_failed("new_panel_io_dbi failed", err); + this->smark_failed(LOG_STR("new_panel_io_dbi failed"), err); return; } auto pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565; @@ -75,7 +75,7 @@ void MIPI_DSI::setup() { }}; err = esp_lcd_new_panel_dpi(this->bus_handle_, &dpi_config, &this->handle_); if (err != ESP_OK) { - this->smark_failed("esp_lcd_new_panel_dpi failed", err); + this->smark_failed(LOG_STR("esp_lcd_new_panel_dpi failed"), err); return; } if (this->reset_pin_ != nullptr) { @@ -92,14 +92,14 @@ void MIPI_DSI::setup() { auto when = millis() + 120; err = esp_lcd_panel_init(this->handle_); if (err != ESP_OK) { - this->smark_failed("esp_lcd_init failed", err); + this->smark_failed(LOG_STR("esp_lcd_init failed"), err); return; } size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { if (vec.size() - index < 2) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } uint8_t cmd = vec[index++]; @@ -110,7 +110,7 @@ void MIPI_DSI::setup() { } else { uint8_t num_args = x & 0x7F; if (vec.size() - index < num_args) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { @@ -125,7 +125,7 @@ void MIPI_DSI::setup() { format_hex_pretty(ptr, num_args, '.', false).c_str()); err = esp_lcd_panel_io_tx_param(this->io_handle_, cmd, ptr, num_args); if (err != ESP_OK) { - this->smark_failed("lcd_panel_io_tx_param failed", err); + this->smark_failed(LOG_STR("lcd_panel_io_tx_param failed"), err); return; } index += num_args; @@ -140,7 +140,7 @@ void MIPI_DSI::setup() { err = (esp_lcd_dpi_panel_register_event_callbacks(this->handle_, &cbs, this->io_lock_)); if (err != ESP_OK) { - this->smark_failed("Failed to register callbacks", err); + this->smark_failed(LOG_STR("Failed to register callbacks"), err); return; } @@ -222,7 +222,7 @@ bool MIPI_DSI::check_buffer_() { RAMAllocator allocator; this->buffer_ = allocator.allocate(this->height_ * this->width_ * bytes_per_pixel); if (this->buffer_ == nullptr) { - this->mark_failed("Could not allocate buffer for display!"); + this->mark_failed(LOG_STR("Could not allocate buffer for display!")); return false; } return true; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 98ee092ed1..1cffe3b178 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -62,7 +62,7 @@ class MIPI_DSI : public display::Display { void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } - void smark_failed(const char *message, esp_err_t err); + void smark_failed(const LogString *message, esp_err_t err); void update() override; diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 4c687724cf..74eedae4f4 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -73,7 +73,7 @@ void MipiRgbSpi::write_init_sequence_() { auto &vec = this->init_sequence_; while (index != vec.size()) { if (vec.size() - index < 2) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } uint8_t cmd = vec[index++]; @@ -84,7 +84,7 @@ void MipiRgbSpi::write_init_sequence_() { } else { uint8_t num_args = x & 0x7F; if (vec.size() - index < num_args) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { @@ -165,7 +165,7 @@ void MipiRgb::common_setup_() { err = esp_lcd_panel_init(this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err)); - this->mark_failed("lcd setup failed"); + this->mark_failed(LOG_STR("lcd setup failed")); } ESP_LOGCONFIG(TAG, "MipiRgb setup complete"); } @@ -249,7 +249,7 @@ bool MipiRgb::check_buffer_() { RAMAllocator allocator; this->buffer_ = allocator.allocate(this->height_ * this->width_); if (this->buffer_ == nullptr) { - this->mark_failed("Could not allocate buffer for display!"); + this->mark_failed(LOG_STR("Could not allocate buffer for display!")); return false; } return true; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 7e597d1c61..1953aef035 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -478,7 +478,7 @@ class MipiSpiBuffer : public MipiSpi allocator{}; this->buffer_ = allocator.allocate(BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION); if (this->buffer_ == nullptr) { - this->mark_failed("Buffer allocation failed"); + this->mark_failed(LOG_STR("Buffer allocation failed")); } } diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index b0b64f5709..043b629cf1 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -78,19 +78,20 @@ void SourceSpeaker::loop() { } else { switch (err) { case ESP_ERR_NO_MEM: - this->status_set_error("Failed to start mixer: not enough memory"); + this->status_set_error(LOG_STR("Failed to start mixer: not enough memory")); break; case ESP_ERR_NOT_SUPPORTED: - this->status_set_error("Failed to start mixer: unsupported bits per sample"); + this->status_set_error(LOG_STR("Failed to start mixer: unsupported bits per sample")); break; case ESP_ERR_INVALID_ARG: - this->status_set_error("Failed to start mixer: audio stream isn't compatible with the other audio stream."); + this->status_set_error( + LOG_STR("Failed to start mixer: audio stream isn't compatible with the other audio stream.")); break; case ESP_ERR_INVALID_STATE: - this->status_set_error("Failed to start mixer: mixer task failed to start"); + this->status_set_error(LOG_STR("Failed to start mixer: mixer task failed to start")); break; default: - this->status_set_error("Failed to start mixer"); + this->status_set_error(LOG_STR("Failed to start mixer")); break; } @@ -317,7 +318,7 @@ void MixerSpeaker::loop() { xEventGroupClearBits(this->event_group_, MixerEventGroupBits::STATE_STARTING); } if (event_group_bits & MixerEventGroupBits::ERR_ESP_NO_MEM) { - this->status_set_error("Failed to allocate the mixer's internal buffer"); + this->status_set_error(LOG_STR("Failed to allocate the mixer's internal buffer")); xEventGroupClearBits(this->event_group_, MixerEventGroupBits::ERR_ESP_NO_MEM); } if (event_group_bits & MixerEventGroupBits::STATE_RUNNING) { diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 6a31b754f7..11f63a9a33 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -278,7 +278,7 @@ void NAU7802Sensor::loop() { this->set_calibration_failure_(true); this->state_ = CalibrationState::INACTIVE; ESP_LOGE(TAG, "Failed to calibrate sensor"); - this->status_set_error("Calibration Failed"); + this->status_set_error(LOG_STR("Calibration Failed")); return; } diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 857b40ca0e..37e5f3d9e1 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -195,7 +195,7 @@ static void add(std::vector &vec, const char *str) { void PacketTransport::setup() { this->name_ = App.get_name().c_str(); if (strlen(this->name_) > 255) { - this->status_set_error("Device name exceeds 255 chars"); + this->status_set_error(LOG_STR("Device name exceeds 255 chars")); this->mark_failed(); return; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 61fde186d7..57f54b6432 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -310,7 +310,7 @@ void QMP6988Component::calculate_pressure_() { void QMP6988Component::setup() { if (!this->device_check_()) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 5e5615cbb9..ad61aca084 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -66,17 +66,17 @@ void ResamplerSpeaker::loop() { } if (event_group_bits & ResamplingEventGroupBits::ERR_ESP_NO_MEM) { - this->status_set_error("Resampler task failed to allocate the internal buffers"); + this->status_set_error(LOG_STR("Resampler task failed to allocate the internal buffers")); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ERR_ESP_NO_MEM); this->state_ = speaker::STATE_STOPPING; } if (event_group_bits & ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED) { - this->status_set_error("Cannot resample due to an unsupported audio stream"); + this->status_set_error(LOG_STR("Cannot resample due to an unsupported audio stream")); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED); this->state_ = speaker::STATE_STOPPING; } if (event_group_bits & ResamplingEventGroupBits::ERR_ESP_FAIL) { - this->status_set_error("Resampler task failed"); + this->status_set_error(LOG_STR("Resampler task failed")); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ERR_ESP_FAIL); this->state_ = speaker::STATE_STOPPING; } @@ -106,12 +106,12 @@ void ResamplerSpeaker::loop() { } else { switch (err) { case ESP_ERR_INVALID_STATE: - this->status_set_error("Failed to start resampler: resampler task failed to start"); + this->status_set_error(LOG_STR("Failed to start resampler: resampler task failed to start")); break; case ESP_ERR_NO_MEM: - this->status_set_error("Failed to start resampler: not enough memory for task stack"); + this->status_set_error(LOG_STR("Failed to start resampler: not enough memory for task stack")); default: - this->status_set_error("Failed to start resampler"); + this->status_set_error(LOG_STR("Failed to start resampler")); break; } diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 62b8717ded..617b19ef3e 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -13,7 +13,7 @@ void SHT4XComponent::start_heater_() { ESP_LOGD(TAG, "Heater turning on"); if (this->write(cmd, 1) != i2c::ERROR_OK) { - this->status_set_error("Failed to turn on heater"); + this->status_set_error(LOG_STR("Failed to turn on heater")); } } diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 614dc1da8b..2b2559c843 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -21,7 +21,7 @@ static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsiu void STTS22HComponent::setup() { // Check if device is a STTS22H if (!this->is_stts22h_sensor_()) { - this->mark_failed("Device is not a STTS22H sensor"); + this->mark_failed(LOG_STR("Device is not a STTS22H sensor")); return; } @@ -61,12 +61,12 @@ float STTS22HComponent::read_temperature_() { bool STTS22HComponent::is_stts22h_sensor_() { uint8_t whoami_value; if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { - this->mark_failed("Unexpected WHOAMI identifier. Sensor is not a STTS22H"); + this->mark_failed(LOG_STR("Unexpected WHOAMI identifier. Sensor is not a STTS22H")); return false; } @@ -77,7 +77,7 @@ void STTS22HComponent::initialize_sensor_() { // Read current CTRL_REG configuration uint8_t ctrl_value; if (this->read_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -86,14 +86,14 @@ void STTS22HComponent::initialize_sensor_() { // FREERUN bit must be cleared (see sensor documentation) ctrl_value &= ~FREERUN_CTRL_ENABLE_FLAG; // Clear FREERUN bit if (this->write_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Enable LOW ODR mode and ADD_INC ctrl_value |= LOW_ODR_CTRL_ENABLE_FLAG | ADD_INC_ENABLE_FLAG; // Set LOW ODR bit and ADD_INC bit if (this->write_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 7714793e1c..9105ced21e 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -21,7 +21,7 @@ void UDPComponent::setup() { if (this->should_broadcast_) { this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { - this->status_set_error("Could not create socket"); + this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); return; } @@ -41,14 +41,14 @@ void UDPComponent::setup() { if (this->should_listen_) { this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->listen_socket_ == nullptr) { - this->status_set_error("Could not create socket"); + this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); return; } auto err = this->listen_socket_->setblocking(false); if (err < 0) { ESP_LOGE(TAG, "Unable to set nonblocking: errno %d", errno); - this->status_set_error("Unable to set nonblocking"); + this->status_set_error(LOG_STR("Unable to set nonblocking")); this->mark_failed(); return; } @@ -73,7 +73,7 @@ void UDPComponent::setup() { err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); if (err < 0) { ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - this->status_set_error("Failed to set IP_ADD_MEMBERSHIP"); + this->status_set_error(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); this->mark_failed(); return; } @@ -82,7 +82,7 @@ void UDPComponent::setup() { err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); - this->status_set_error("Unable to bind socket"); + this->status_set_error(LOG_STR("Unable to bind socket")); this->mark_failed(); return; } diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4c09cf8a49..fe61353b5d 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -188,7 +188,7 @@ void USBClient::setup() { auto err = usb_host_client_register(&config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "client register failed: %s", esp_err_to_name(err)); - this->status_set_error("Client register failed"); + this->status_set_error(LOG_STR("Client register failed")); this->mark_failed(); return; } diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index fb19239c73..1e70c289df 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -11,7 +11,7 @@ void USBHost::setup() { usb_host_config_t config{}; if (usb_host_install(&config) != ESP_OK) { - this->status_set_error("usb_host_install failed"); + this->status_set_error(LOG_STR("usb_host_install failed")); this->mark_failed(); return; } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index c24fffb11d..6720c1e690 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -320,7 +320,7 @@ static void fix_mps(const usb_ep_desc_t *ep) { void USBUartTypeCdcAcm::on_connected() { auto cdc_devs = this->parse_descriptors(this->device_handle_); if (cdc_devs.empty()) { - this->status_set_error("No CDC-ACM device found"); + this->status_set_error(LOG_STR("No CDC-ACM device found")); this->disconnect(); return; } @@ -341,7 +341,7 @@ void USBUartTypeCdcAcm::on_connected() { if (err != ESP_OK) { ESP_LOGE(TAG, "usb_host_interface_claim failed: %s, channel=%d, intf=%d", esp_err_to_name(err), channel->index_, channel->cdc_dev_.bulk_interface_number); - this->status_set_error("usb_host_interface_claim failed"); + this->status_set_error(LOG_STR("usb_host_interface_claim failed")); this->disconnect(); return; } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index fd35dc7d09..551f0370f2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -206,7 +206,7 @@ void VoiceAssistant::loop() { case State::START_MICROPHONE: { ESP_LOGD(TAG, "Starting Microphone"); if (!this->allocate_buffers_()) { - this->status_set_error("Failed to allocate buffers"); + this->status_set_error(LOG_STR("Failed to allocate buffers")); return; } if (this->status_has_error()) { diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index 7993abd7e7..8c5bdac54b 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -67,7 +67,7 @@ void WakeOnLanButton::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { - this->status_set_error("Could not create socket"); + this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); return; } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index de3dd99d0c..5e6ace8873 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -36,6 +36,9 @@ namespace { struct ComponentErrorMessage { const Component *component; const char *message; + // Track if message is flash pointer (needs LOG_STR_ARG) or RAM pointer + // Remove before 2026.6.0 when deprecated const char* API is removed + bool is_flash_ptr; }; struct ComponentPriorityOverride { @@ -49,6 +52,25 @@ std::unique_ptr> component_error_messages; // Setup priority overrides - freed after setup completes // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::unique_ptr> setup_priority_overrides; + +// Helper to store error messages - reduces duplication between deprecated and new API +// Remove before 2026.6.0 when deprecated const char* API is removed +void store_component_error_message(const Component *component, const char *message, bool is_flash_ptr) { + // Lazy allocate the error messages vector if needed + if (!component_error_messages) { + component_error_messages = std::make_unique>(); + } + // Check if this component already has an error message + for (auto &entry : *component_error_messages) { + if (entry.component == component) { + entry.message = message; + entry.is_flash_ptr = is_flash_ptr; + return; + } + } + // Add new error message + component_error_messages->emplace_back(ComponentErrorMessage{component, message, is_flash_ptr}); +} } // namespace namespace setup_priority { @@ -143,16 +165,20 @@ void Component::call_dump_config() { if (this->is_failed()) { // Look up error message from global vector const char *error_msg = nullptr; + bool is_flash_ptr = false; if (component_error_messages) { for (const auto &entry : *component_error_messages) { if (entry.component == this) { error_msg = entry.message; + is_flash_ptr = entry.is_flash_ptr; break; } } } + // Log with appropriate format based on pointer type ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), - error_msg ? error_msg : LOG_STR_LITERAL("unspecified")); + error_msg ? (is_flash_ptr ? LOG_STR_ARG((const LogString *) error_msg) : error_msg) + : LOG_STR_LITERAL("unspecified")); } } @@ -307,6 +333,7 @@ void Component::status_set_warning(const LogString *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } +void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; @@ -315,19 +342,19 @@ void Component::status_set_error(const char *message) { ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? message : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { - // Lazy allocate the error messages vector if needed - if (!component_error_messages) { - component_error_messages = std::make_unique>(); - } - // Check if this component already has an error message - for (auto &entry : *component_error_messages) { - if (entry.component == this) { - entry.message = message; - return; - } - } - // Add new error message - component_error_messages->emplace_back(ComponentErrorMessage{this, message}); + store_component_error_message(this, message, false); + } +} +void Component::status_set_error(const LogString *message) { + if ((this->component_state_ & STATUS_LED_ERROR) != 0) + return; + this->component_state_ |= STATUS_LED_ERROR; + App.app_state_ |= STATUS_LED_ERROR; + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); + if (message != nullptr) { + // Store the LogString pointer directly (safe because LogString is always in flash/static memory) + store_component_error_message(this, LOG_STR_ARG(message), true); } } void Component::status_clear_warning() { diff --git a/esphome/core/component.h b/esphome/core/component.h index 462e0e301c..51a9290e8b 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -5,6 +5,7 @@ #include #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/optional.h" @@ -157,7 +158,19 @@ class Component { */ virtual void mark_failed(); + // Remove before 2026.6.0 + ESPDEPRECATED("Use mark_failed(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " + "strings. Will stop working in 2026.6.0", + "2025.12.0") void mark_failed(const char *message) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->status_set_error(message); +#pragma GCC diagnostic pop + this->mark_failed(); + } + + void mark_failed(const LogString *message) { this->status_set_error(message); this->mark_failed(); } @@ -216,7 +229,13 @@ class Component { void status_set_warning(const char *message = nullptr); void status_set_warning(const LogString *message); - void status_set_error(const char *message = nullptr); + void status_set_error(); // Set error flag without message + // Remove before 2026.6.0 + ESPDEPRECATED("Use status_set_error(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " + "strings. Will stop working in 2026.6.0", + "2025.12.0") + void status_set_error(const char *message); + void status_set_error(const LogString *message); void status_clear_warning(); From eeb373fca98681ed576b767450044d2fb5cefedf Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:15:30 +1300 Subject: [PATCH 118/320] [online_image] Fix some large PNGs causing watchdog timeout (#12025) Co-authored-by: guillempages --- esphome/components/online_image/png_image.cpp | 9 +++++++++ esphome/components/online_image/png_image.h | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/esphome/components/online_image/png_image.cpp b/esphome/components/online_image/png_image.cpp index 2038d09ed0..ce9d3bdc91 100644 --- a/esphome/components/online_image/png_image.cpp +++ b/esphome/components/online_image/png_image.cpp @@ -2,6 +2,7 @@ #ifdef USE_ONLINE_IMAGE_PNG_SUPPORT #include "esphome/components/display/display_buffer.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -38,6 +39,14 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui PngDecoder *decoder = (PngDecoder *) pngle_get_user_data(pngle); Color color(rgba[0], rgba[1], rgba[2], rgba[3]); decoder->draw(x, y, w, h, color); + + // Feed watchdog periodically to avoid triggering during long decode operations. + // Feed every 1024 pixels to balance efficiency and responsiveness. + uint32_t pixels = w * h; + decoder->increment_pixels_decoded(pixels); + if ((decoder->get_pixels_decoded() % 1024) < pixels) { + App.feed_wdt(); + } } PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) { diff --git a/esphome/components/online_image/png_image.h b/esphome/components/online_image/png_image.h index 46519f8ef4..40e85dde33 100644 --- a/esphome/components/online_image/png_image.h +++ b/esphome/components/online_image/png_image.h @@ -25,9 +25,13 @@ class PngDecoder : public ImageDecoder { int prepare(size_t download_size) override; int HOT decode(uint8_t *buffer, size_t size) override; + void increment_pixels_decoded(uint32_t count) { this->pixels_decoded_ += count; } + uint32_t get_pixels_decoded() const { return this->pixels_decoded_; } + protected: RAMAllocator allocator_; pngle_t *pngle_; + uint32_t pixels_decoded_{0}; }; } // namespace online_image From e09656f20e1abdd7984ee8353a49a1d108ef299d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:21:03 -0600 Subject: [PATCH 119/320] Bump bleak from 1.1.1 to 2.0.0 (#12083) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6ae050b35b..df036eeccc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ pillow==11.3.0 cairosvg==2.8.2 freetype-py==2.5.1 jinja2==3.1.6 -bleak==1.1.1 +bleak==2.0.0 # esp-idf >= 5.0 requires this pyparsing >= 3.0 From 88b898458b6a71a50ff63fed3aae2658e16a19a0 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 24 Nov 2025 15:25:49 -0600 Subject: [PATCH 120/320] [bluetooth_proxy] Fix crash due to null pointer (#12084) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bluetooth_proxy/bluetooth_proxy.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4de541fac2..4363c508ec 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -132,7 +132,11 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ void get_bluetooth_mac_address_pretty(std::span output) { const uint8_t *mac = esp_bt_dev_get_address(); - format_mac_addr_upper(mac, output.data()); + if (mac != nullptr) { + format_mac_addr_upper(mac, output.data()); + } else { + output[0] = '\0'; + } } protected: From 7f1a9a611f7cb8d5d8d17b7b096eeb5595cc8957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 02:09:02 +0000 Subject: [PATCH 121/320] Bump aioesphomeapi from 42.7.0 to 42.8.0 (#12092) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index df036eeccc..a5c919e95f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.7.0 +aioesphomeapi==42.8.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From 2bc8a4a77980493101aa66f089d9f213d8f5213b Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 24 Nov 2025 20:23:10 -0600 Subject: [PATCH 122/320] [wifi_info] Use callbacks instead of polling (#10748) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 14 ++ esphome/components/wifi/automation.h | 116 +++++++++++++++ esphome/components/wifi/wifi_component.cpp | 6 +- esphome/components/wifi/wifi_component.h | 138 ++++-------------- .../wifi/wifi_component_esp8266.cpp | 22 ++- .../wifi/wifi_component_esp_idf.cpp | 22 ++- .../wifi/wifi_component_libretiny.cpp | 23 ++- .../components/wifi/wifi_component_pico_w.cpp | 61 +++++++- esphome/components/wifi_info/text_sensor.py | 37 +++-- .../wifi_info/wifi_info_text_sensor.cpp | 119 ++++++++++++++- .../wifi_info/wifi_info_text_sensor.h | 102 +++---------- esphome/core/defines.h | 1 + 12 files changed, 417 insertions(+), 244 deletions(-) create mode 100644 esphome/components/wifi/automation.h diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 8a5e5329f1..31d9ca0f70 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -608,6 +608,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +WIFI_CALLBACKS_KEY = "wifi_callbacks" def request_wifi_scan_results(): @@ -633,6 +634,17 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True +def request_wifi_callbacks() -> None: + """Request that WiFi callbacks be compiled in. + + Components that need to be notified about WiFi state changes (IP address changes, + scan results, connection state) should call this function during their code generation. + This enables the add_on_ip_state_callback(), add_on_wifi_scan_state_callback(), + and add_on_wifi_connect_state_callback() APIs. + """ + CORE.data[WIFI_CALLBACKS_KEY] = True + + @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): """Final code generation step to configure optional WiFi features.""" @@ -642,6 +654,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(WIFI_CALLBACKS_KEY, False): + cg.add_define("USE_WIFI_CALLBACKS") @automation.register_action( diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h new file mode 100644 index 0000000000..7997baff65 --- /dev/null +++ b/esphome/components/wifi/automation.h @@ -0,0 +1,116 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_WIFI +#include "wifi_component.h" + +namespace esphome::wifi { + +template class WiFiConnectedCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } +}; + +template class WiFiEnabledCondition : public Condition { + public: + bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } +}; + +template class WiFiAPActiveCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } +}; + +template class WiFiEnableAction : public Action { + public: + void play(const Ts &...x) override { global_wifi_component->enable(); } +}; + +template class WiFiDisableAction : public Action { + public: + void play(const Ts &...x) override { global_wifi_component->disable(); } +}; + +template class WiFiConfigureAction : public Action, public Component { + public: + TEMPLATABLE_VALUE(std::string, ssid) + TEMPLATABLE_VALUE(std::string, password) + TEMPLATABLE_VALUE(bool, save) + TEMPLATABLE_VALUE(uint32_t, connection_timeout) + + void play(const Ts &...x) override { + auto ssid = this->ssid_.value(x...); + auto password = this->password_.value(x...); + // Avoid multiple calls + if (this->connecting_) + return; + // If already connected to the same AP, do nothing + if (global_wifi_component->wifi_ssid() == ssid) { + // Callback to notify the user that the connection was successful + this->connect_trigger_->trigger(); + return; + } + // Create a new WiFiAP object with the new SSID and password + this->new_sta_.set_ssid(ssid); + this->new_sta_.set_password(password); + // Save the current STA + this->old_sta_ = global_wifi_component->get_sta(); + // Disable WiFi + global_wifi_component->disable(); + // Set the state to connecting + this->connecting_ = true; + // Store the new STA so once the WiFi is enabled, it will connect to it + // This is necessary because the WiFiComponent will raise an error and fallback to the saved STA + // if trying to connect to a new STA while already connected to another one + if (this->save_.value(x...)) { + global_wifi_component->save_wifi_sta(new_sta_.get_ssid(), new_sta_.get_password()); + } else { + global_wifi_component->set_sta(new_sta_); + } + // Enable WiFi + global_wifi_component->enable(); + // Set timeout for the connection + this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + // If the timeout is reached, stop connecting and revert to the old AP + global_wifi_component->disable(); + global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); + global_wifi_component->enable(); + // Start a timeout for the fallback if the connection to the old AP fails + this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->connecting_ = false; + this->error_trigger_->trigger(); + }); + }); + } + + Trigger<> *get_connect_trigger() const { return this->connect_trigger_; } + Trigger<> *get_error_trigger() const { return this->error_trigger_; } + + void loop() override { + if (!this->connecting_) + return; + if (global_wifi_component->is_connected()) { + // The WiFi is connected, stop the timeout and reset the connecting flag + this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout("wifi-fallback-timeout"); + this->connecting_ = false; + if (global_wifi_component->wifi_ssid() == this->new_sta_.get_ssid()) { + // Callback to notify the user that the connection was successful + this->connect_trigger_->trigger(); + } else { + // Callback to notify the user that the connection failed + this->error_trigger_->trigger(); + } + } + } + + protected: + bool connecting_{false}; + WiFiAP new_sta_; + WiFiAP old_sta_; + Trigger<> *connect_trigger_{new Trigger<>()}; + Trigger<> *error_trigger_{new Trigger<>()}; +}; + +} // namespace esphome::wifi +#endif diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 41931a7785..d53de83bd3 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,8 +37,7 @@ #include "esphome/components/esp32_improv/esp32_improv_component.h" #endif -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi"; @@ -1813,6 +1812,5 @@ bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this-> WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace wifi -} // namespace esphome +} // namespace esphome::wifi #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0dac80ad21..b6b956a12d 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -54,8 +54,7 @@ extern "C" { #include #endif -namespace esphome { -namespace wifi { +namespace esphome::wifi { /// Sentinel value for RSSI when WiFi is not connected static constexpr int8_t WIFI_RSSI_DISCONNECTED = -127; @@ -370,6 +369,27 @@ class WiFiComponent : public Component { int32_t get_wifi_channel(); +#ifdef USE_WIFI_CALLBACKS + /// Add a callback that will be called on configuration changes (IP change, SSID change, etc.) + /// @param callback The callback to be called; template arguments are: + /// - IP addresses + /// - DNS address 1 + /// - DNS address 2 + void add_on_ip_state_callback( + std::function &&callback) { + this->ip_state_callback_.add(std::move(callback)); + } + /// - Wi-Fi scan results + void add_on_wifi_scan_state_callback(std::function &)> &&callback) { + this->wifi_scan_state_callback_.add(std::move(callback)); + } + /// - Wi-Fi SSID + /// - Wi-Fi BSSID + void add_on_wifi_connect_state_callback(std::function &&callback) { + this->wifi_connect_state_callback_.add(std::move(callback)); + } +#endif // USE_WIFI_CALLBACKS + #ifdef USE_WIFI_RUNTIME_POWER_SAVE /** Request high-performance mode (no power saving) for improved WiFi latency. * @@ -526,6 +546,11 @@ class WiFiComponent : public Component { WiFiAP ap_; #endif optional output_power_; +#ifdef USE_WIFI_CALLBACKS + CallbackManager ip_state_callback_; + CallbackManager &)> wifi_scan_state_callback_; + CallbackManager wifi_connect_state_callback_; +#endif // USE_WIFI_CALLBACKS ESPPreferenceObject pref_; #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; @@ -590,112 +615,5 @@ class WiFiComponent : public Component { extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class WiFiConnectedCondition : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } -}; - -template class WiFiEnabledCondition : public Condition { - public: - bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } -}; - -template class WiFiAPActiveCondition : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } -}; - -template class WiFiEnableAction : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->enable(); } -}; - -template class WiFiDisableAction : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->disable(); } -}; - -template class WiFiConfigureAction : public Action, public Component { - public: - TEMPLATABLE_VALUE(std::string, ssid) - TEMPLATABLE_VALUE(std::string, password) - TEMPLATABLE_VALUE(bool, save) - TEMPLATABLE_VALUE(uint32_t, connection_timeout) - - void play(const Ts &...x) override { - auto ssid = this->ssid_.value(x...); - auto password = this->password_.value(x...); - // Avoid multiple calls - if (this->connecting_) - return; - // If already connected to the same AP, do nothing - if (global_wifi_component->wifi_ssid() == ssid) { - // Callback to notify the user that the connection was successful - this->connect_trigger_->trigger(); - return; - } - // Create a new WiFiAP object with the new SSID and password - this->new_sta_.set_ssid(ssid); - this->new_sta_.set_password(password); - // Save the current STA - this->old_sta_ = global_wifi_component->get_sta(); - // Disable WiFi - global_wifi_component->disable(); - // Set the state to connecting - this->connecting_ = true; - // Store the new STA so once the WiFi is enabled, it will connect to it - // This is necessary because the WiFiComponent will raise an error and fallback to the saved STA - // if trying to connect to a new STA while already connected to another one - if (this->save_.value(x...)) { - global_wifi_component->save_wifi_sta(new_sta_.get_ssid(), new_sta_.get_password()); - } else { - global_wifi_component->set_sta(new_sta_); - } - // Enable WiFi - global_wifi_component->enable(); - // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { - // If the timeout is reached, stop connecting and revert to the old AP - global_wifi_component->disable(); - global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); - global_wifi_component->enable(); - // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { - this->connecting_ = false; - this->error_trigger_->trigger(); - }); - }); - } - - Trigger<> *get_connect_trigger() const { return this->connect_trigger_; } - Trigger<> *get_error_trigger() const { return this->error_trigger_; } - - void loop() override { - if (!this->connecting_) - return; - if (global_wifi_component->is_connected()) { - // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); - this->connecting_ = false; - if (global_wifi_component->wifi_ssid() == this->new_sta_.get_ssid()) { - // Callback to notify the user that the connection was successful - this->connect_trigger_->trigger(); - } else { - // Callback to notify the user that the connection failed - this->error_trigger_->trigger(); - } - } - } - - protected: - bool connecting_{false}; - WiFiAP new_sta_; - WiFiAP old_sta_; - Trigger<> *connect_trigger_{new Trigger<>()}; - Trigger<> *error_trigger_{new Trigger<>()}; -}; - -} // namespace wifi -} // namespace esphome +} // namespace esphome::wifi #endif diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 274a505db2..540ad3a585 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -38,8 +38,7 @@ extern "C" { #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_esp8266"; @@ -514,6 +513,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=%s channel=%u", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel); s_sta_connected = true; +#ifdef USE_WIFI_CALLBACKS + global_wifi_component->wifi_connect_state_callback_.call(global_wifi_component->wifi_ssid(), + global_wifi_component->wifi_bssid()); +#endif break; } case EVENT_STAMODE_DISCONNECTED: { @@ -533,6 +536,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { } s_sta_connected = false; s_sta_connecting = false; +#ifdef USE_WIFI_CALLBACKS + global_wifi_component->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#endif break; } case EVENT_STAMODE_AUTHMODE_CHANGE: { @@ -555,6 +561,11 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", format_ip_addr(it.ip).c_str(), format_ip_addr(it.gw).c_str(), format_ip_addr(it.mask).c_str()); s_sta_got_ip = true; +#ifdef USE_WIFI_CALLBACKS + global_wifi_component->ip_state_callback_.call(global_wifi_component->wifi_sta_ip_addresses(), + global_wifi_component->get_dns_address(0), + global_wifi_component->get_dns_address(1)); +#endif break; } case EVENT_STAMODE_DHCP_TIMEOUT: { @@ -729,6 +740,9 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { it->is_hidden != 0); } this->scan_done_ = true; +#ifdef USE_WIFI_CALLBACKS + global_wifi_component->wifi_scan_state_callback_.call(global_wifi_component->scan_result_); +#endif } #ifdef USE_WIFI_AP @@ -885,8 +899,6 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {(const ip_addr_t *) WiFi.dnsIP(num)}; } void WiFiComponent::wifi_loop_() {} -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index e6e914c0b4..c20c96ced0 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -41,8 +41,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_esp32"; @@ -728,6 +727,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); s_sta_connected = true; +#ifdef USE_WIFI_CALLBACKS + this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { const auto &it = data->data.sta_disconnected; @@ -751,6 +753,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; +#ifdef USE_WIFI_CALLBACKS + this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#endif } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { const auto &it = data->data.ip_got_ip; @@ -759,12 +764,18 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif /* USE_NETWORK_IPV6 */ ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#ifdef USE_WIFI_CALLBACKS + this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#endif #if USE_NETWORK_IPV6 } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_GOT_IP6) { const auto &it = data->data.ip_got_ip6; ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip)); this->num_ipv6_addresses_++; +#ifdef USE_WIFI_CALLBACKS + this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#endif #endif /* USE_NETWORK_IPV6 */ } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_LOST_IP) { @@ -804,6 +815,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid.empty()); } +#ifdef USE_WIFI_CALLBACKS + this->wifi_scan_state_callback_.call(this->scan_result_); +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); @@ -1088,8 +1102,6 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_ip); } -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif // USE_ESP32 #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 98cbfddb1d..04d0d4fa85 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -15,8 +15,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_lt"; @@ -288,7 +287,9 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ buf[it.ssid_len] = '\0'; ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); - +#ifdef USE_WIFI_CALLBACKS + this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: { @@ -314,6 +315,9 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } s_sta_connecting = false; +#ifdef USE_WIFI_CALLBACKS + this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE: { @@ -335,11 +339,17 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(WiFi.localIP()).c_str(), format_ip4_addr(WiFi.gatewayIP()).c_str()); s_sta_connecting = false; +#ifdef USE_WIFI_CALLBACKS + this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { // auto it = info.got_ip.ip_info; ESP_LOGV(TAG, "Got IPv6"); +#ifdef USE_WIFI_CALLBACKS + this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_LOST_IP: { @@ -433,6 +443,9 @@ void WiFiComponent::wifi_scan_done_callback_() { } WiFi.scanDelete(); this->scan_done_ = true; +#ifdef USE_WIFI_CALLBACKS + this->wifi_scan_state_callback_.call(this->scan_result_); +#endif } #ifdef USE_WIFI_AP @@ -493,8 +506,6 @@ network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()} network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {WiFi.dnsIP(num)}; } void WiFiComponent::wifi_loop_() {} -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif // USE_LIBRETINY #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 91766e8ab5..326883c0c4 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -1,4 +1,3 @@ - #include "wifi_component.h" #ifdef USE_WIFI @@ -15,11 +14,14 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_pico_w"; +// Track previous state for detecting changes +static bool s_sta_was_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_had_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (sta.has_value()) { if (sta.value()) { @@ -51,7 +53,7 @@ bool WiFiComponent::wifi_apply_power_save_() { return ret == 0; } -// TODO: The driver doesnt seem to have an API for this +// TODO: The driver doesn't seem to have an API for this bool WiFiComponent::wifi_apply_output_power_(float output_power) { return true; } bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { @@ -219,16 +221,61 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { } void WiFiComponent::wifi_loop_() { + // Handle scan completion if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { this->scan_done_ = true; ESP_LOGV(TAG, "Scan done"); +#ifdef USE_WIFI_CALLBACKS + this->wifi_scan_state_callback_.call(this->scan_result_); +#endif + } + + // Poll for connection state changes + // The arduino-pico WiFi library doesn't have event callbacks like ESP8266/ESP32, + // so we need to poll the link status to detect state changes + auto status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); + bool is_connected = (status == CYW43_LINK_UP); + + // Detect connection state change + if (is_connected && !s_sta_was_connected) { + // Just connected + s_sta_was_connected = true; + ESP_LOGV(TAG, "Connected"); +#ifdef USE_WIFI_CALLBACKS + this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#endif + } else if (!is_connected && s_sta_was_connected) { + // Just disconnected + s_sta_was_connected = false; + s_sta_had_ip = false; + ESP_LOGV(TAG, "Disconnected"); +#ifdef USE_WIFI_CALLBACKS + this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#endif + } + + // Detect IP address changes (only when connected) + if (is_connected) { + bool has_ip = false; + // Check for any IP address (IPv4 or IPv6) + for (auto addr : addrList) { + has_ip = true; + break; + } + + if (has_ip && !s_sta_had_ip) { + // Just got IP address + s_sta_had_ip = true; + ESP_LOGV(TAG, "Got IP address"); +#ifdef USE_WIFI_CALLBACKS + this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#endif + } } } void WiFiComponent::wifi_pre_setup_() {} -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif #endif diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index a4da582c55..0feee3d4a9 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -15,31 +15,27 @@ DEPENDENCIES = ["wifi"] wifi_info_ns = cg.esphome_ns.namespace("wifi_info") IPAddressWiFiInfo = wifi_info_ns.class_( - "IPAddressWiFiInfo", text_sensor.TextSensor, cg.PollingComponent + "IPAddressWiFiInfo", text_sensor.TextSensor, cg.Component ) ScanResultsWiFiInfo = wifi_info_ns.class_( - "ScanResultsWiFiInfo", text_sensor.TextSensor, cg.PollingComponent -) -SSIDWiFiInfo = wifi_info_ns.class_( - "SSIDWiFiInfo", text_sensor.TextSensor, cg.PollingComponent + "ScanResultsWiFiInfo", text_sensor.TextSensor, cg.Component ) +SSIDWiFiInfo = wifi_info_ns.class_("SSIDWiFiInfo", text_sensor.TextSensor, cg.Component) BSSIDWiFiInfo = wifi_info_ns.class_( - "BSSIDWiFiInfo", text_sensor.TextSensor, cg.PollingComponent + "BSSIDWiFiInfo", text_sensor.TextSensor, cg.Component ) MacAddressWifiInfo = wifi_info_ns.class_( "MacAddressWifiInfo", text_sensor.TextSensor, cg.Component ) DNSAddressWifiInfo = wifi_info_ns.class_( - "DNSAddressWifiInfo", text_sensor.TextSensor, cg.PollingComponent + "DNSAddressWifiInfo", text_sensor.TextSensor, cg.Component ) CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_IP_ADDRESS): text_sensor.text_sensor_schema( IPAddressWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ) - .extend(cv.polling_component_schema("1s")) - .extend( + ).extend( { cv.Optional(f"address_{x}"): text_sensor.text_sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, @@ -49,22 +45,31 @@ CONFIG_SCHEMA = cv.Schema( ), cv.Optional(CONF_SCAN_RESULTS): text_sensor.text_sensor_schema( ScanResultsWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("60s")), + ), cv.Optional(CONF_SSID): text_sensor.text_sensor_schema( SSIDWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), cv.Optional(CONF_BSSID): text_sensor.text_sensor_schema( BSSIDWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), cv.Optional(CONF_MAC_ADDRESS): text_sensor.text_sensor_schema( MacAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), cv.Optional(CONF_DNS_ADDRESS): text_sensor.text_sensor_schema( DNSAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), } ) +# Keys that require WiFi callbacks +_NETWORK_INFO_KEYS = { + CONF_SSID, + CONF_BSSID, + CONF_IP_ADDRESS, + CONF_DNS_ADDRESS, + CONF_SCAN_RESULTS, +} + async def setup_conf(config, key): if key in config: @@ -74,6 +79,10 @@ async def setup_conf(config, key): async def to_code(config): + # Request WiFi callbacks for any sensor that needs them + if _NETWORK_INFO_KEYS.intersection(config): + wifi.request_wifi_callbacks() + await setup_conf(config, CONF_SSID) await setup_conf(config, CONF_BSSID) await setup_conf(config, CONF_MAC_ADDRESS) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 2612e4af8d..abd590b168 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -2,18 +2,121 @@ #ifdef USE_WIFI #include "esphome/core/log.h" -namespace esphome { -namespace wifi_info { +namespace esphome::wifi_info { static const char *const TAG = "wifi_info"; +static constexpr size_t MAX_STATE_LENGTH = 255; + +/******************** + * IPAddressWiFiInfo + *******************/ + +void IPAddressWiFiInfo::setup() { + wifi::global_wifi_component->add_on_ip_state_callback( + [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { + this->state_callback_(ips); + }); +} + void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this); } -void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); } -void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } -void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } -void MacAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "MAC Address", this); } + +void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { + this->publish_state(ips[0].str()); + uint8_t sensor = 0; + for (const auto &ip : ips) { + if (ip.is_set()) { + if (this->ip_sensors_[sensor] != nullptr) { + this->ip_sensors_[sensor]->publish_state(ip.str()); + } + sensor++; + } + } +} + +/********************* + * DNSAddressWifiInfo + ********************/ + +void DNSAddressWifiInfo::setup() { + wifi::global_wifi_component->add_on_ip_state_callback( + [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { + this->state_callback_(dns1_ip, dns2_ip); + }); +} + void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); } -} // namespace wifi_info -} // namespace esphome +void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { + std::string dns_results = dns1_ip.str() + " " + dns2_ip.str(); + this->publish_state(dns_results); +} + +/********************** + * ScanResultsWiFiInfo + *********************/ + +void ScanResultsWiFiInfo::setup() { + wifi::global_wifi_component->add_on_wifi_scan_state_callback( + [this](const wifi::wifi_scan_vector_t &results) { this->state_callback_(results); }); +} + +void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); } + +void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_t &results) { + std::string scan_results; + for (const auto &scan : results) { + if (scan.get_is_hidden()) + continue; + + scan_results += scan.get_ssid(); + scan_results += ": "; + scan_results += esphome::to_string(scan.get_rssi()); + scan_results += "dB\n"; + } + // There's a limit of 255 characters per state; longer states just don't get sent so we truncate it + if (scan_results.length() > MAX_STATE_LENGTH) { + scan_results.resize(MAX_STATE_LENGTH); + } + this->publish_state(scan_results); +} + +/*************** + * SSIDWiFiInfo + **************/ + +void SSIDWiFiInfo::setup() { + wifi::global_wifi_component->add_on_wifi_connect_state_callback( + [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(ssid); }); +} + +void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } + +void SSIDWiFiInfo::state_callback_(const std::string &ssid) { this->publish_state(ssid); } + +/**************** + * BSSIDWiFiInfo + ***************/ + +void BSSIDWiFiInfo::setup() { + wifi::global_wifi_component->add_on_wifi_connect_state_callback( + [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(bssid); }); +} + +void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } + +void BSSIDWiFiInfo::state_callback_(const wifi::bssid_t &bssid) { + char buf[18] = "unknown"; + if (mac_address_is_valid(bssid.data())) { + format_mac_addr_upper(bssid.data(), buf); + } + this->publish_state(buf); +} +/********************* + * MacAddressWifiInfo + ********************/ + +void MacAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "MAC Address", this); } + +} // namespace esphome::wifi_info #endif diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 0814336c43..12666b4059 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -7,121 +7,54 @@ #ifdef USE_WIFI #include -namespace esphome { -namespace wifi_info { +namespace esphome::wifi_info { -static constexpr size_t MAX_STATE_LENGTH = 255; - -class IPAddressWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +class IPAddressWiFiInfo : public Component, public text_sensor::TextSensor { public: - void update() override { - auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); - if (ips != this->last_ips_) { - this->last_ips_ = ips; - this->publish_state(ips[0].str()); - uint8_t sensor = 0; - for (auto &ip : ips) { - if (ip.is_set()) { - if (this->ip_sensors_[sensor] != nullptr) { - this->ip_sensors_[sensor]->publish_state(ip.str()); - } - sensor++; - } - } - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } protected: - network::IPAddresses last_ips_; + void state_callback_(const network::IPAddresses &ips); std::array ip_sensors_; }; -class DNSAddressWifiInfo : public PollingComponent, public text_sensor::TextSensor { +class DNSAddressWifiInfo : public Component, public text_sensor::TextSensor { public: - void update() override { - auto dns_one = wifi::global_wifi_component->get_dns_address(0); - auto dns_two = wifi::global_wifi_component->get_dns_address(1); - - std::string dns_results = dns_one.str() + " " + dns_two.str(); - - if (dns_results != this->last_results_) { - this->last_results_ = dns_results; - this->publish_state(dns_results); - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; protected: - std::string last_results_; + void state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip); }; -class ScanResultsWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +class ScanResultsWiFiInfo : public Component, public text_sensor::TextSensor { public: - void update() override { - std::string scan_results; - for (auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) - continue; - - scan_results += scan.get_ssid(); - scan_results += ": "; - scan_results += esphome::to_string(scan.get_rssi()); - scan_results += "dB\n"; - } - - // There's a limit of 255 characters per state. - // Longer states just don't get sent so we truncate it. - if (scan_results.length() > MAX_STATE_LENGTH) { - scan_results.resize(MAX_STATE_LENGTH); - } - if (this->last_scan_results_ != scan_results) { - this->last_scan_results_ = scan_results; - this->publish_state(scan_results); - } - } + void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void dump_config() override; protected: - std::string last_scan_results_; + void state_callback_(const wifi::wifi_scan_vector_t &results); }; -class SSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +class SSIDWiFiInfo : public Component, public text_sensor::TextSensor { public: - void update() override { - std::string ssid = wifi::global_wifi_component->wifi_ssid(); - if (this->last_ssid_ != ssid) { - this->last_ssid_ = ssid; - this->publish_state(this->last_ssid_); - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; protected: - std::string last_ssid_; + void state_callback_(const std::string &ssid); }; -class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +class BSSIDWiFiInfo : public Component, public text_sensor::TextSensor { public: - void update() override { - wifi::bssid_t bssid = wifi::global_wifi_component->wifi_bssid(); - if (memcmp(bssid.data(), last_bssid_.data(), 6) != 0) { - std::copy(bssid.begin(), bssid.end(), last_bssid_.begin()); - char buf[18]; - format_mac_addr_upper(bssid.data(), buf); - this->publish_state(buf); - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; protected: - wifi::bssid_t last_bssid_; + void state_callback_(const wifi::bssid_t &bssid); }; class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { @@ -133,6 +66,5 @@ class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { void dump_config() override; }; -} // namespace wifi_info -} // namespace esphome +} // namespace esphome::wifi_info #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 4b24c395b9..1373ea6366 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -210,6 +210,7 @@ #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_CALLBACKS #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 From 1c808a3375a824f37cfdb6bfb067f96975be733b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 21:19:18 -0600 Subject: [PATCH 123/320] [ble_client] Write static BLE data directly from flash without allocation (#11826) --- esphome/components/ble_client/automation.h | 27 +++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 9c5646b3d1..bbc2dd05e0 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -122,16 +122,19 @@ template class BLEClientWriteAction : public Action, publ void play_complex(const Ts &...x) override { this->num_running_++; this->var_ = std::make_tuple(x...); - std::vector value; + + bool result; if (this->len_ >= 0) { - // Static mode: copy from flash to vector - value.assign(this->value_.data, this->value_.data + this->len_); + // Static mode: write directly from flash pointer + result = this->write(this->value_.data, this->len_); } else { - // Template mode: call function - value = this->value_.func(x...); + // Template mode: call function and write the vector + std::vector value = this->value_.func(x...); + result = this->write(value); } + // on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work. - if (!write(value)) + if (!result) this->play_next_(x...); } @@ -144,15 +147,15 @@ template class BLEClientWriteAction : public Action, publ * errors. */ // initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event. - bool write(const std::vector &value) { + bool write(const uint8_t *data, size_t len) { if (this->node_state != espbt::ClientState::ESTABLISHED) { esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected"); return false; } - esph_log_vv(Automation::TAG, "Will write %d bytes: %s", value.size(), format_hex_pretty(value).c_str()); - esp_err_t err = esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), - this->char_handle_, value.size(), const_cast(value.data()), - this->write_type_, ESP_GATT_AUTH_REQ_NONE); + esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty(data, len).c_str()); + esp_err_t err = + esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len, + const_cast(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE); if (err != ESP_OK) { esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err)); return false; @@ -160,6 +163,8 @@ template class BLEClientWriteAction : public Action, publ return true; } + bool write(const std::vector &value) { return this->write(value.data(), value.size()); } + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { switch (event) { From 46a26560fd32eceedc547b154018ddad4deefd8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Nov 2025 21:21:56 -0600 Subject: [PATCH 124/320] [template.alarm_control_panel] Replace std::map with FixedVector for heap and flash savings (#11893) --- .../template/alarm_control_panel/__init__.py | 6 +- .../template_alarm_control_panel.cpp | 35 +++-- .../template_alarm_control_panel.h | 20 ++- ...late_alarm_control_panel_many_sensors.yaml | 136 ++++++++++++++++++ ...mplate_alarm_control_panel_many_sensors.py | 118 +++++++++++++++ 5 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml create mode 100644 tests/integration/test_template_alarm_control_panel_many_sensors.py diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index 5d2421fcbc..256c7f276a 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -137,7 +137,11 @@ async def to_code(config): cg.add(var.set_arming_night_time(config[CONF_ARMING_NIGHT_TIME])) supports_arm_night = True - for sensor in config.get(CONF_BINARY_SENSORS, []): + if sensors := config.get(CONF_BINARY_SENSORS, []): + # Initialize FixedVector with the exact number of sensors + cg.add(var.init_sensors(len(sensors))) + + for sensor in sensors: bs = await cg.get_variable(sensor[CONF_INPUT]) flags = BinarySensorFlags[FLAG_NORMAL] 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 af662a05a0..f025435261 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 @@ -20,10 +20,13 @@ void TemplateAlarmControlPanel::add_sensor(binary_sensor::BinarySensor *sensor, // Save the flags and type. Assign a store index for the per sensor data type. SensorDataStore sd; sd.last_chime_state = false; - this->sensor_map_[sensor].flags = flags; - this->sensor_map_[sensor].type = type; + AlarmSensor alarm_sensor; + alarm_sensor.sensor = sensor; + alarm_sensor.info.flags = flags; + alarm_sensor.info.type = type; + alarm_sensor.info.store_index = this->next_store_index_++; + this->sensors_.push_back(alarm_sensor); this->sensor_data_.push_back(sd); - this->sensor_map_[sensor].store_index = this->next_store_index_++; }; static const LogString *sensor_type_to_string(AlarmSensorType type) { @@ -45,7 +48,7 @@ void TemplateAlarmControlPanel::dump_config() { ESP_LOGCONFIG(TAG, "TemplateAlarmControlPanel:\n" " Current State: %s\n" - " Number of Codes: %u\n" + " Number of Codes: %zu\n" " Requires Code To Arm: %s\n" " Arming Away Time: %" PRIu32 "s\n" " Arming Home Time: %" PRIu32 "s\n" @@ -58,7 +61,8 @@ void TemplateAlarmControlPanel::dump_config() { (this->arming_home_time_ / 1000), (this->arming_night_time_ / 1000), (this->pending_time_ / 1000), (this->trigger_time_ / 1000), this->get_supported_features()); #ifdef USE_BINARY_SENSOR - for (auto const &[sensor, info] : this->sensor_map_) { + for (const auto &alarm_sensor : this->sensors_) { + const uint16_t flags = alarm_sensor.info.flags; ESP_LOGCONFIG(TAG, " Binary Sensor:\n" " Name: %s\n" @@ -67,11 +71,10 @@ void TemplateAlarmControlPanel::dump_config() { " Armed night bypass: %s\n" " Auto bypass: %s\n" " Chime mode: %s", - sensor->get_name().c_str(), LOG_STR_ARG(sensor_type_to_string(info.type)), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_CHIME)); + alarm_sensor.sensor->get_name().c_str(), LOG_STR_ARG(sensor_type_to_string(alarm_sensor.info.type)), + TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), + TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), + TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_AUTO), TRUEFALSE(flags & BINARY_SENSOR_MODE_CHIME)); } #endif } @@ -121,7 +124,9 @@ void TemplateAlarmControlPanel::loop() { #ifdef USE_BINARY_SENSOR // Test all of the sensors regardless of the alarm panel state - for (auto const &[sensor, info] : this->sensor_map_) { + for (const auto &alarm_sensor : this->sensors_) { + const auto &info = alarm_sensor.info; + auto *sensor = alarm_sensor.sensor; // Check for chime zones if (info.flags & BINARY_SENSOR_MODE_CHIME) { // Look for the transition from closed to open @@ -242,11 +247,11 @@ void TemplateAlarmControlPanel::arm_(optional code, alarm_control_p void TemplateAlarmControlPanel::bypass_before_arming() { #ifdef USE_BINARY_SENSOR - for (auto const &[sensor, info] : this->sensor_map_) { + for (const auto &alarm_sensor : this->sensors_) { // Check for faulted bypass_auto sensors and remove them from monitoring - if ((info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor->state)) { - ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", sensor->get_name().c_str()); - this->bypassed_sensor_indicies_.push_back(info.store_index); + if ((alarm_sensor.info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (alarm_sensor.sensor->state)) { + ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", alarm_sensor.sensor->get_name().c_str()); + this->bypassed_sensor_indicies_.push_back(alarm_sensor.info.store_index); } } #endif diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 202dc7c13f..80ce34b8ae 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -1,11 +1,12 @@ #pragma once #include -#include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/alarm_control_panel/alarm_control_panel.h" @@ -49,6 +50,13 @@ struct SensorInfo { uint8_t store_index; }; +#ifdef USE_BINARY_SENSOR +struct AlarmSensor { + binary_sensor::BinarySensor *sensor; + SensorInfo info; +}; +#endif + class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControlPanel, public Component { public: TemplateAlarmControlPanel(); @@ -63,6 +71,12 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void bypass_before_arming(); #ifdef USE_BINARY_SENSOR + /** Initialize the sensors vector with the specified capacity. + * + * @param capacity The number of sensors to allocate space for. + */ + void init_sensors(size_t capacity) { this->sensors_.init(capacity); } + /** Add a binary_sensor to the alarm_panel. * * @param sensor The BinarySensor instance. @@ -122,8 +136,8 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl protected: void control(const alarm_control_panel::AlarmControlPanelCall &call) override; #ifdef USE_BINARY_SENSOR - // This maps a binary sensor to its alarm specific info - std::map sensor_map_; + // List of binary sensors with their alarm-specific info + FixedVector sensors_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; #endif diff --git a/tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml b/tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml new file mode 100644 index 0000000000..836d3f11d5 --- /dev/null +++ b/tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml @@ -0,0 +1,136 @@ +esphome: + name: template-alarm-many-sensors + friendly_name: "Template Alarm Control Panel with Many Sensors" + +logger: + +host: + +api: + +binary_sensor: + - platform: template + id: sensor1 + name: "Door 1" + - platform: template + id: sensor2 + name: "Door 2" + - platform: template + id: sensor3 + name: "Window 1" + - platform: template + id: sensor4 + name: "Window 2" + - platform: template + id: sensor5 + name: "Motion 1" + - platform: template + id: sensor6 + name: "Motion 2" + - platform: template + id: sensor7 + name: "Glass Break 1" + - platform: template + id: sensor8 + name: "Glass Break 2" + - platform: template + id: sensor9 + name: "Smoke Detector" + - platform: template + id: sensor10 + name: "CO Detector" + +alarm_control_panel: + - platform: template + id: test_alarm + name: "Test Alarm" + codes: + - "1234" + requires_code_to_arm: true + arming_away_time: 5s + arming_home_time: 3s + arming_night_time: 3s + pending_time: 10s + trigger_time: 300s + restore_mode: ALWAYS_DISARMED + binary_sensors: + - input: sensor1 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: true + chime: true + trigger_mode: DELAYED + - input: sensor2 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: true + chime: true + trigger_mode: DELAYED + - input: sensor3 + bypass_armed_home: true + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: DELAYED + - input: sensor4 + bypass_armed_home: true + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: DELAYED + - input: sensor5 + bypass_armed_home: false + bypass_armed_night: true + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor6 + bypass_armed_home: false + bypass_armed_night: true + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor7 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor8 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor9 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT_ALWAYS + - input: sensor10 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT_ALWAYS + on_disarmed: + - logger.log: "Alarm disarmed" + on_arming: + - logger.log: "Alarm arming" + on_armed_away: + - logger.log: "Alarm armed away" + on_armed_home: + - logger.log: "Alarm armed home" + on_armed_night: + - logger.log: "Alarm armed night" + on_pending: + - logger.log: "Alarm pending" + on_triggered: + - logger.log: "Alarm triggered" + on_cleared: + - logger.log: "Alarm cleared" + on_chime: + - logger.log: "Chime activated" + on_ready: + - logger.log: "Sensors ready state changed" diff --git a/tests/integration/test_template_alarm_control_panel_many_sensors.py b/tests/integration/test_template_alarm_control_panel_many_sensors.py new file mode 100644 index 0000000000..856815c731 --- /dev/null +++ b/tests/integration/test_template_alarm_control_panel_many_sensors.py @@ -0,0 +1,118 @@ +"""Integration test for template alarm control panel with many sensors.""" + +from __future__ import annotations + +import aioesphomeapi +from aioesphomeapi.model import APIIntEnum +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +class EspHomeACPFeatures(APIIntEnum): + """ESPHome AlarmControlPanel feature numbers.""" + + ARM_HOME = 1 + ARM_AWAY = 2 + ARM_NIGHT = 4 + TRIGGER = 8 + ARM_CUSTOM_BYPASS = 16 + ARM_VACATION = 32 + + +@pytest.mark.asyncio +async def test_template_alarm_control_panel_many_sensors( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test template alarm control panel with 10 binary sensors using FixedVector.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get entity info first + entities, _ = await client.list_entities_services() + + # Find the alarm control panel and binary sensors + alarm_info: aioesphomeapi.AlarmControlPanelInfo | None = None + binary_sensors: list[aioesphomeapi.BinarySensorInfo] = [] + + for entity in entities: + if isinstance(entity, aioesphomeapi.AlarmControlPanelInfo): + alarm_info = entity + elif isinstance(entity, aioesphomeapi.BinarySensorInfo): + binary_sensors.append(entity) + + assert alarm_info is not None, "Alarm control panel entity info not found" + assert alarm_info.name == "Test Alarm" + assert alarm_info.requires_code is True + assert alarm_info.requires_code_to_arm is True + + # Verify we have 10 binary sensors + assert len(binary_sensors) == 10, ( + f"Expected 10 binary sensors, got {len(binary_sensors)}" + ) + + # Verify sensor names + expected_sensor_names = { + "Door 1", + "Door 2", + "Window 1", + "Window 2", + "Motion 1", + "Motion 2", + "Glass Break 1", + "Glass Break 2", + "Smoke Detector", + "CO Detector", + } + actual_sensor_names = {sensor.name for sensor in binary_sensors} + assert actual_sensor_names == expected_sensor_names, ( + f"Sensor names mismatch. Expected: {expected_sensor_names}, " + f"Got: {actual_sensor_names}" + ) + + # Use InitialStateHelper to wait for all initial states + state_helper = InitialStateHelper(entities) + + def on_state(state: aioesphomeapi.EntityState) -> None: + # We'll receive subsequent states here after initial states + pass + + client.subscribe_states(state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states + await state_helper.wait_for_initial_states(timeout=5.0) + + # Verify the alarm state is disarmed initially + alarm_state = state_helper.initial_states.get(alarm_info.key) + assert alarm_state is not None, "Alarm control panel initial state not received" + assert isinstance(alarm_state, aioesphomeapi.AlarmControlPanelEntityState) + assert alarm_state.state == aioesphomeapi.AlarmControlPanelState.DISARMED, ( + f"Expected initial state DISARMED, got {alarm_state.state}" + ) + + # Verify all 10 binary sensors have initial states + binary_sensor_states = [ + state_helper.initial_states.get(sensor.key) for sensor in binary_sensors + ] + assert all(state is not None for state in binary_sensor_states), ( + "Not all binary sensors have initial states" + ) + + # Verify all binary sensor states are BinarySensorState type + for i, state in enumerate(binary_sensor_states): + assert isinstance(state, aioesphomeapi.BinarySensorState), ( + f"Binary sensor {i} state is not BinarySensorState: {type(state)}" + ) + + # Verify supported features + expected_features = ( + EspHomeACPFeatures.ARM_HOME + | EspHomeACPFeatures.ARM_AWAY + | EspHomeACPFeatures.ARM_NIGHT + | EspHomeACPFeatures.TRIGGER + ) + assert alarm_info.supported_features == expected_features, ( + f"Expected supported_features={expected_features} (ARM_HOME|ARM_AWAY|ARM_NIGHT|TRIGGER), " + f"got {alarm_info.supported_features}" + ) From 66a871840e1f0b6ba37b03f833b49e6bb73afaaf Mon Sep 17 00:00:00 2001 From: bdm310 Date: Mon, 24 Nov 2025 22:14:23 -0800 Subject: [PATCH 125/320] Add more lvgl arc update parameters (#12066) --- esphome/components/lvgl/widgets/arc.py | 54 +++++++++++++++++++------ tests/components/lvgl/lvgl-package.yaml | 12 ++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ef4da0d815..21530441f8 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -20,7 +20,13 @@ from ..defines import ( CONF_START_ANGLE, literal, ) -from ..lv_validation import get_start_value, lv_angle_degrees, lv_float, lv_int +from ..lv_validation import ( + get_start_value, + lv_angle_degrees, + lv_float, + lv_int, + lv_positive_int, +) from ..lvcode import lv, lv_expr, lv_obj from ..types import LvNumber, NumberType from . import Widget @@ -36,13 +42,20 @@ ARC_SCHEMA = cv.Schema( cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees, cv.Optional(CONF_ADJUSTABLE, default=False): bool, cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of, - cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t, + cv.Optional(CONF_CHANGE_RATE, default=720): lv_positive_int, } ) ARC_MODIFY_SCHEMA = cv.Schema( { cv.Optional(CONF_VALUE): lv_float, + cv.Optional(CONF_MIN_VALUE): lv_int, + cv.Optional(CONF_MAX_VALUE): lv_int, + cv.Optional(CONF_START_ANGLE): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE): lv_angle_degrees, + cv.Optional(CONF_ROTATION): lv_angle_degrees, + cv.Optional(CONF_MODE): ARC_MODES.one_of, + cv.Optional(CONF_CHANGE_RATE): lv_positive_int, } ) @@ -58,17 +71,34 @@ class ArcType(NumberType): ) async def to_code(self, w: Widget, config): - if CONF_MIN_VALUE in config: + if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: max_value = await lv_int.process(config[CONF_MAX_VALUE]) min_value = await lv_int.process(config[CONF_MIN_VALUE]) lv.arc_set_range(w.obj, min_value, max_value) - start = await lv_angle_degrees.process(config[CONF_START_ANGLE]) - end = await lv_angle_degrees.process(config[CONF_END_ANGLE]) - rotation = await lv_angle_degrees.process(config[CONF_ROTATION]) - lv.arc_set_bg_angles(w.obj, start, end) - lv.arc_set_rotation(w.obj, rotation) - lv.arc_set_mode(w.obj, literal(config[CONF_MODE])) - lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE]) + elif CONF_MIN_VALUE in config: + max_value = w.get_property(CONF_MAX_VALUE) + min_value = await lv_int.process(config[CONF_MIN_VALUE]) + lv.arc_set_range(w.obj, min_value, max_value) + elif CONF_MAX_VALUE in config: + max_value = await lv_int.process(config[CONF_MAX_VALUE]) + min_value = w.get_property(CONF_MIN_VALUE) + lv.arc_set_range(w.obj, min_value, max_value) + + await w.set_property( + CONF_START_ANGLE, + await lv_angle_degrees.process(config.get(CONF_START_ANGLE)), + ) + await w.set_property( + CONF_END_ANGLE, await lv_angle_degrees.process(config.get(CONF_END_ANGLE)) + ) + await w.set_property( + CONF_ROTATION, await lv_angle_degrees.process(config.get(CONF_ROTATION)) + ) + await w.set_property(CONF_MODE, config) + await w.set_property( + CONF_CHANGE_RATE, + await lv_positive_int.process(config.get(CONF_CHANGE_RATE)), + ) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: @@ -78,9 +108,7 @@ class ArcType(NumberType): # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) - value = await get_start_value(config) - if value is not None: - lv.arc_set_value(w.obj, value) + await w.set_property(CONF_VALUE, await get_start_value(config)) arc_spec = ArcType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 5839643638..d54aef8b4a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -781,6 +781,18 @@ lvgl: arc_color: 0xFFFF00 focused: arc_color: 0x808080 + on_click: + then: + - lvgl.arc.update: + id: lv_arc_1 + value: !lambda return (int)((float)rand() / RAND_MAX * 100); + min_value: !lambda return (int)((float)rand() / RAND_MAX * 100); + max_value: !lambda return (int)((float)rand() / RAND_MAX * 100); + start_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); + end_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); + rotation: !lambda return (int)((float)rand() / RAND_MAX * 100); + change_rate: !lambda return (uint)((float)rand() / RAND_MAX * 100); + mode: NORMAL - bar: id: bar_id align: top_mid From 18c97a08c38193e4a2807c254a4ff52f265f2f28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 01:47:06 -0600 Subject: [PATCH 126/320] [esp8266] Use C++17 nested namespaces and constexpr (#12096) --- esphome/components/esp8266/core.h | 4 +--- esphome/components/esp8266/gpio.cpp | 9 +++++---- esphome/components/esp8266/gpio.h | 6 ++---- esphome/components/esp8266/preferences.cpp | 20 ++++++++++---------- esphome/components/esp8266/preferences.h | 6 ++---- 5 files changed, 20 insertions(+), 25 deletions(-) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index ac33305669..1abe67be86 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -7,8 +7,6 @@ extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16]; extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16]; -namespace esphome { -namespace esp8266 {} // namespace esp8266 -} // namespace esphome +namespace esphome::esp8266 {} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index ee3683c67d..17a495bc1d 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -3,8 +3,7 @@ #include "gpio.h" #include "esphome/core/log.h" -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { static const char *const TAG = "esp8266"; @@ -110,9 +109,11 @@ void ESP8266GPIOPin::digital_write(bool value) { } void ESP8266GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace esp8266 +} // namespace esphome::esp8266 -using namespace esp8266; +namespace esphome { + +using esp8266::ISRPinArg; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index a1b6d79b3b..213a5c54be 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { class ESP8266GPIOPin : public InternalGPIOPin { public: @@ -33,7 +32,6 @@ class ESP8266GPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esp8266 -} // namespace esphome +} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index a26e9cc498..197d244dc4 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -15,24 +15,24 @@ extern "C" { #include #include -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { static const char *const TAG = "esp8266.preferences"; -static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t *s_flash_storage = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const uint32_t ESP_RTC_USER_MEM_START = 0x60001200; +static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; + #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -static const uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; -static const uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; #ifdef USE_ESP8266_PREFERENCES_FLASH -static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; #else -static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; #endif static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) { @@ -284,10 +284,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esp8266 +} // namespace esphome::esp8266 +namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - } // namespace esphome #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.h b/esphome/components/esp8266/preferences.h index edec915794..16cf80a129 100644 --- a/esphome/components/esp8266/preferences.h +++ b/esphome/components/esp8266/preferences.h @@ -2,13 +2,11 @@ #ifdef USE_ESP8266 -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esp8266 -} // namespace esphome +} // namespace esphome::esp8266 #endif // USE_ESP8266 From 697c5f424ebf0aa9c07693cce2c1c79675b164f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 02:17:53 -0600 Subject: [PATCH 127/320] [api] Use const char* pointers for light effects to eliminate heap allocations (#12090) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 10 +++++++--- esphome/components/api/api_pb2.cpp | 10 +++++----- esphome/components/api/api_pb2.h | 2 +- esphome/components/api/api_pb2_dump.cpp | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 26d1fa6876..74a8e8ff7f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -518,7 +518,7 @@ message ListEntitiesLightResponse { bool legacy_supports_color_temperature = 8 [deprecated=true]; float min_mireds = 9; float max_mireds = 10; - repeated string effects = 11; + repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"]; bool disabled_by_default = 13; string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 15; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ebfc641537..12cbbb991d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -484,12 +484,16 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c msg.min_mireds = traits.get_min_mireds(); msg.max_mireds = traits.get_max_mireds(); } + FixedVector effects_list; if (light->supports_effects()) { - msg.effects.emplace_back("None"); - for (auto *effect : light->get_effects()) { - msg.effects.emplace_back(effect->get_name()); + auto &light_effects = light->get_effects(); + effects_list.init(light_effects.size() + 1); + effects_list.push_back("None"); + for (auto *effect : light_effects) { + effects_list.push_back(effect->get_name()); } } + msg.effects = &effects_list; return fill_and_encode_entity_info(light, msg, ListEntitiesLightResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index d52135a566..c131815456 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -476,8 +476,8 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_float(9, this->min_mireds); buffer.encode_float(10, this->max_mireds); - for (auto &it : this->effects) { - buffer.encode_string(11, it, true); + for (const char *it : *this->effects) { + buffer.encode_string(11, it, strlen(it), true); } buffer.encode_bool(13, this->disabled_by_default); #ifdef USE_ENTITY_ICON @@ -499,9 +499,9 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { } size.add_float(1, this->min_mireds); size.add_float(1, this->max_mireds); - if (!this->effects.empty()) { - for (const auto &it : this->effects) { - size.add_length_force(1, it.size()); + if (!this->effects->empty()) { + for (const char *it : *this->effects) { + size.add_length_force(1, strlen(it)); } } size.add_bool(1, this->disabled_by_default); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b19e92d4ff..93ece74d85 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -793,7 +793,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { const light::ColorModeMask *supported_color_modes{}; float min_mireds{0.0f}; float max_mireds{0.0f}; - std::vector effects{}; + const FixedVector *effects{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ea752ba3ba..a985e052ac 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -924,7 +924,7 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { } dump_field(out, "min_mireds", this->min_mireds); dump_field(out, "max_mireds", this->max_mireds); - for (const auto &it : this->effects) { + for (const auto &it : *this->effects) { dump_field(out, "effects", it, 4); } dump_field(out, "disabled_by_default", this->disabled_by_default); From c30b92019347b681698551857e06eddac406a1d7 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:48:32 +0100 Subject: [PATCH 128/320] [nextion] Do not set alternative baud rate when not specified or `<= 0` (#12097) --- esphome/components/nextion/nextion_upload_arduino.cpp | 3 +++ esphome/components/nextion/nextion_upload_idf.cpp | 3 +++ 2 files changed, 6 insertions(+) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index b4d217d7aa..baea938729 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_idf.cpp index 3b0d65643d..942e3dd6c3 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_idf.cpp @@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client From cdf27f144766759efc1bb809a5f7631cc8a7cf85 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:14:53 -0500 Subject: [PATCH 129/320] [esp32] Fix platformio flash size print (#12099) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 59c6029334..d372af3e6a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -854,6 +854,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: async def to_code(config): cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) + cg.add_platformio_option( + "board_upload.maximum_size", + int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, + ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) From a571033b43f3418c54f42ef078b357b90b6f8bed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 10:30:01 -0600 Subject: [PATCH 130/320] [script] Fix script.wait hanging when triggered from on_boot (#12102) --- esphome/components/script/script.h | 7 +- .../fixtures/script_wait_on_boot.yaml | 54 ++++++++ tests/integration/test_script_wait_on_boot.py | 130 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/script_wait_on_boot.yaml create mode 100644 tests/integration/test_script_wait_on_boot.py diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index d60ed657f7..3a0823f3cc 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -278,7 +278,12 @@ template class ScriptWaitAction : public Action, 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/script_wait_on_boot.yaml b/tests/integration/fixtures/script_wait_on_boot.yaml new file mode 100644 index 0000000000..8736b02294 --- /dev/null +++ b/tests/integration/fixtures/script_wait_on_boot.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-script-wait-on-boot + on_boot: + # Use default priority (600.0) which is same as ScriptWaitAction's setup priority + # This tests the race condition where on_boot runs before ScriptWaitAction::setup() + then: + - logger.log: "=== on_boot: Starting boot sequence ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== on_boot: First script completed, starting second ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== on_boot: All boot scripts completed successfully ===" + +host: + +api: + actions: + # Manual trigger for additional testing + - action: test_script_wait + then: + - logger.log: "=== Manual test: Starting ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== Manual test: First script completed ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== Manual test: All completed ===" + +logger: + level: DEBUG + +script: + # First script - simulates display initialization + - id: show_start_page + mode: single + then: + - logger.log: "show_start_page: Starting" + - delay: 100ms + - logger.log: "show_start_page: After delay 1" + - delay: 100ms + - logger.log: "show_start_page: Completed" + + # Second script - simulates page flip sequence + - id: flip_thru_pages + mode: single + then: + - logger.log: "flip_thru_pages: Starting" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 1" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 2" + - delay: 50ms + - logger.log: "flip_thru_pages: Completed" diff --git a/tests/integration/test_script_wait_on_boot.py b/tests/integration/test_script_wait_on_boot.py new file mode 100644 index 0000000000..478090f782 --- /dev/null +++ b/tests/integration/test_script_wait_on_boot.py @@ -0,0 +1,130 @@ +"""Integration test for script.wait during on_boot (issue #12043). + +This test verifies that script.wait works correctly when triggered from on_boot. +The issue was that ScriptWaitAction::setup() unconditionally disabled the loop, +even if play_complex() had already been called (from an on_boot trigger at the +same priority level) and enabled it. + +The race condition occurs because: +1. on_boot's default priority is 600.0 (setup_priority::DATA) +2. ScriptWaitAction's default setup priority is also DATA (600.0) +3. When they have the same priority, if on_boot runs first and triggers a script, + ScriptWaitAction::play_complex() enables the loop +4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop +5. The wait never completes because the loop is disabled + +The fix adds a conditional check (like WaitUntilAction has) to only disable the +loop in setup() if num_running_ is 0. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_wait_on_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that script.wait works correctly when triggered from on_boot. + + This reproduces issue #12043 where script.wait would hang forever when + triggered from on_boot due to a race condition in ScriptWaitAction::setup(). + """ + test_complete = asyncio.Event() + + # Track progress through the boot sequence + boot_started = False + first_script_started = False + first_script_completed = False + first_wait_returned = False + second_script_started = False + second_script_completed = False + all_completed = False + + # Patterns for boot sequence logs + boot_start_pattern = re.compile(r"on_boot: Starting boot sequence") + show_start_pattern = re.compile(r"show_start_page: Starting") + show_complete_pattern = re.compile(r"show_start_page: Completed") + first_wait_pattern = re.compile(r"on_boot: First script completed") + flip_start_pattern = re.compile(r"flip_thru_pages: Starting") + flip_complete_pattern = re.compile(r"flip_thru_pages: Completed") + all_complete_pattern = re.compile(r"on_boot: All boot scripts completed") + + def check_output(line: str) -> None: + """Check log output for boot sequence progress.""" + nonlocal boot_started, first_script_started, first_script_completed + nonlocal first_wait_returned, second_script_started, second_script_completed + nonlocal all_completed + + if boot_start_pattern.search(line): + boot_started = True + elif show_start_pattern.search(line): + first_script_started = True + elif show_complete_pattern.search(line): + first_script_completed = True + elif first_wait_pattern.search(line): + first_wait_returned = True + elif flip_start_pattern.search(line): + second_script_started = True + elif flip_complete_pattern.search(line): + second_script_completed = True + elif all_complete_pattern.search(line): + all_completed = True + test_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-script-wait-on-boot" + + # Wait for on_boot sequence to complete + # The boot sequence should complete automatically + # Timeout is generous to allow for delays in the scripts + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + # Build a detailed error message showing where the boot sequence got stuck + progress = [] + if boot_started: + progress.append("boot started") + if first_script_started: + progress.append("show_start_page started") + if first_script_completed: + progress.append("show_start_page completed") + if first_wait_returned: + progress.append("first script.wait returned") + if second_script_started: + progress.append("flip_thru_pages started") + if second_script_completed: + progress.append("flip_thru_pages completed") + + if not first_wait_returned and first_script_completed: + pytest.fail( + f"Test timed out - script.wait hung after show_start_page completed! " + f"This is the issue #12043 bug. Progress: {', '.join(progress)}" + ) + else: + pytest.fail( + f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}" + ) + + # Verify the complete boot sequence executed in order + assert boot_started, "on_boot did not start" + assert first_script_started, "show_start_page did not start" + assert first_script_completed, "show_start_page did not complete" + assert first_wait_returned, "First script.wait did not return" + assert second_script_started, "flip_thru_pages did not start" + assert second_script_completed, "flip_thru_pages did not complete" + assert all_completed, "Boot sequence did not complete" From cf8c2056444bf5b6f02accf6a8480b913d0f1c67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 12:15:45 -0600 Subject: [PATCH 131/320] [core] Reduce flash size by combining set_name() and set_object_id() calls (#11941) --- esphome/core/entity_base.cpp | 6 ++++++ esphome/core/entity_base.h | 3 +++ esphome/core/entity_helpers.py | 6 ++---- .../binary_sensor/test_binary_sensor.py | 2 +- tests/component_tests/button/test_button.py | 2 +- tests/component_tests/text/test_text.py | 2 +- .../text_sensor/test_text_sensor.py | 15 ++++++++++++--- tests/unit_tests/core/test_entity_helpers.py | 13 ++++++++++--- 8 files changed, 36 insertions(+), 13 deletions(-) diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 4883c72cf1..046f99d8cc 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -74,6 +74,12 @@ void EntityBase::set_object_id(const char *object_id) { this->calc_object_id_(); } +void EntityBase::set_name_and_object_id(const char *name, const char *object_id) { + this->set_name(name); + this->object_id_c_str_ = object_id; + this->calc_object_id_(); +} + // Calculate Object ID Hash from Entity Name void EntityBase::calc_object_id_() { this->object_id_hash_ = diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2b52d66f76..aa9b92877a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -41,6 +41,9 @@ class EntityBase { std::string get_object_id() const; void set_object_id(const char *object_id); + // Set both name and object_id in one call (reduces generated code size) + void set_name_and_object_id(const char *name, const char *object_id); + // Get the unique Object ID of this Entity uint32_t get_object_id_hash(); diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 9b4786f835..f360b4d809 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -84,8 +84,6 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: # Get device name for object ID calculation device_name = device_id_obj.id - add(var.set_name(config[CONF_NAME])) - # Calculate base object_id using the same logic as C++ # This must match the C++ behavior in esphome/core/entity_base.cpp base_object_id = get_base_entity_object_id( @@ -97,8 +95,8 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: "Entity has empty name, using '%s' as object_id base", base_object_id ) - # Set the object ID - add(var.set_object_id(base_object_id)) + # Set both name and object_id in one call to reduce generated code size + add(var.set_name_and_object_id(config[CONF_NAME], base_object_id)) _LOGGER.debug( "Setting object_id '%s' for entity '%s' on platform '%s'", base_object_id, diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 32d74027ba..86e0705023 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->set_name("test bs1");' in main_cpp + assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 512ef42b44..b21665288c 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->set_name("wol_test_1");' in main_cpp + assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 99ddd78ee7..bfc3131f6d 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->set_name("test 1 text");' in main_cpp + assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 1c4ef6633d..934ee67cef 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -25,9 +25,18 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->set_name("Template Text Sensor 1");' in main_cpp - assert 'ts_2->set_name("Template Text Sensor 2");' in main_cpp - assert 'ts_3->set_name("Template Text Sensor 3");' in main_cpp + assert ( + 'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");' + in main_cpp + ) + assert ( + 'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");' + in main_cpp + ) + assert ( + 'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");' + in main_cpp + ) def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 9ba5367413..01de0f27f9 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -27,8 +27,13 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex pattern for extracting object IDs from expressions +# Pre-compiled regex patterns for extracting object IDs from expressions +# Matches both old format: .set_object_id("obj_id") +# and new format: .set_name_and_object_id("name", "obj_id") OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') +COMBINED_PATTERN = re.compile( + r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)' +) FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -273,8 +278,10 @@ def setup_test_environment() -> Generator[list[str], None, None]: def extract_object_id_from_expressions(expressions: list[str]) -> str | None: """Extract the object ID that was set from the generated expressions.""" for expr in expressions: - # Look for set_object_id calls with regex to handle various formats - # Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2') + # First try new combined format: .set_name_and_object_id("name", "obj_id") + if match := COMBINED_PATTERN.search(expr): + return match.group(1) + # Fall back to old format: .set_object_id("obj_id") if match := OBJECT_ID_PATTERN.search(expr): return match.group(1) return None From 8c5985f68a4cc8e14e84b90577e98255ac1a51e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 12:16:02 -0600 Subject: [PATCH 132/320] [web_server] Consolidate turn_on/turn_off handlers to eliminate duplicate lambdas (#12094) --- esphome/components/web_server/web_server.cpp | 65 +++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cc51463fe7..6bf6524fbc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -690,8 +690,14 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); - } else if (match.method_equals("turn_on") || match.method_equals("turn_off")) { - auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off(); + } else { + bool is_on = match.method_equals("turn_on"); + bool is_off = match.method_equals("turn_off"); + if (!is_on && !is_off) { + request->send(404); + return; + } + auto call = is_on ? obj->turn_on() : obj->turn_off(); parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed); @@ -715,8 +721,6 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } this->defer([call]() mutable { call.perform(); }); request->send(200); - } else { - request->send(404); } return; } @@ -766,32 +770,35 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); - } else if (match.method_equals("turn_on")) { - auto call = obj->turn_on(); - - // Parse color parameters - parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); - parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); - parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); - parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); - parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); - parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); - - // Parse timing parameters - parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); - parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); - - parse_string_param_(request, "effect", call, &decltype(call)::set_effect); - - this->defer([call]() mutable { call.perform(); }); - request->send(200); - } else if (match.method_equals("turn_off")) { - auto call = obj->turn_off(); - parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); - this->defer([call]() mutable { call.perform(); }); - request->send(200); } else { - request->send(404); + bool is_on = match.method_equals("turn_on"); + bool is_off = match.method_equals("turn_off"); + if (!is_on && !is_off) { + request->send(404); + return; + } + auto call = is_on ? obj->turn_on() : obj->turn_off(); + + if (is_on) { + // Parse color parameters + parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); + parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); + parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); + parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); + parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); + parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); + + // Parse timing parameters + parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); + } + parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); + + if (is_on) { + parse_string_param_(request, "effect", call, &decltype(call)::set_effect); + } + + this->defer([call]() mutable { call.perform(); }); + request->send(200); } return; } From 310693467819523eea3a53b2a1681db50aec3e36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 12:16:27 -0600 Subject: [PATCH 133/320] [esp32_ble] Optimize name storage to reduce RAM and eliminate heap allocations (#12071) --- esphome/components/esp32_ble/ble.cpp | 31 ++++++++++++++++++---------- esphome/components/esp32_ble/ble.h | 6 ++---- esphome/core/application.h | 8 ++++--- esphome/core/helpers.cpp | 10 ++++++--- esphome/core/helpers.h | 11 ++++++++++ 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d0bfb6f843..a0ed9ee90c 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -256,29 +256,38 @@ bool ESP32BLE::ble_setup_() { } #endif - std::string name; - if (this->name_.has_value()) { - name = this->name_.value(); + const char *device_name; + std::string name_with_suffix; + + if (this->name_ != nullptr) { if (App.is_name_add_mac_suffix_enabled()) { + // MAC address length: 12 hex chars + null terminator + constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - const std::string mac_addr = get_mac_address(); - const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len; - name = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); + char mac_addr[mac_address_len]; + get_mac_address_into_buffer(mac_addr); + const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; + name_with_suffix = + make_name_with_suffix(this->name_, strlen(this->name_), '-', mac_suffix_ptr, mac_address_suffix_len); + device_name = name_with_suffix.c_str(); + } else { + device_name = this->name_; } } else { - name = App.get_name(); - if (name.length() > 20) { + name_with_suffix = App.get_name(); + if (name_with_suffix.length() > 20) { if (App.is_name_add_mac_suffix_enabled()) { // Keep first 13 chars and last 7 chars (MAC suffix), remove middle - name.erase(13, name.length() - 20); + name_with_suffix.erase(13, name_with_suffix.length() - 20); } else { - name.resize(20); + name_with_suffix.resize(20); } } + device_name = name_with_suffix.c_str(); } - err = esp_ble_gap_set_device_name(name.c_str()); + err = esp_ble_gap_set_device_name(device_name); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gap_set_device_name failed: %d", err); return false; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 2fb60bb822..393ec2e911 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -112,7 +112,7 @@ class ESP32BLE : public Component { void loop() override; void dump_config() override; float get_setup_priority() const override; - void set_name(const std::string &name) { this->name_ = name; } + void set_name(const char *name) { this->name_ = name; } #ifdef USE_ESP32_BLE_ADVERTISING void advertising_start(); @@ -191,13 +191,11 @@ class ESP32BLE : public Component { esphome::LockFreeQueue ble_events_; esphome::EventPool ble_event_pool_; - // optional (typically 16+ bytes on 32-bit, aligned to 4 bytes) - optional name_; - // 4-byte aligned members #ifdef USE_ESP32_BLE_ADVERTISING BLEAdvertising *advertising_{}; // 4 bytes (pointer) #endif + const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash) esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) uint32_t advertising_cycle_time_{}; // 4 bytes diff --git a/esphome/core/application.h b/esphome/core/application.h index dae44d8902..14e800342e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -105,11 +105,13 @@ class Application { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { + // MAC address length: 12 hex chars + null terminator + constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - const std::string mac_addr = get_mac_address(); - // Use pointer + offset to avoid substr() allocation - const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len; + char mac_addr[mac_address_len]; + get_mac_address_into_buffer(mac_addr); + const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; this->name_ = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); if (!friendly_name.empty()) { this->friendly_name_ = make_name_with_suffix(friendly_name, ' ', mac_suffix_ptr, mac_address_suffix_len); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 50af71649c..1f675563c7 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -238,9 +238,9 @@ std::string str_sprintf(const char *fmt, ...) { // Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; -std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { +std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, + size_t suffix_len) { char buffer[MAX_NAME_WITH_SUFFIX_SIZE]; - size_t name_len = name.size(); size_t total_len = name_len + 1 + suffix_len; // Silently truncate if needed: prioritize keeping the full suffix @@ -252,13 +252,17 @@ std::string make_name_with_suffix(const std::string &name, char sep, const char total_len = name_len + 1 + suffix_len; } - memcpy(buffer, name.c_str(), name_len); + memcpy(buffer, name, name_len); buffer[name_len] = sep; memcpy(buffer + name_len + 1, suffix_ptr, suffix_len); buffer[total_len] = '\0'; return std::string(buffer, total_len); } +std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { + return make_name_with_suffix(name.c_str(), name.size(), sep, suffix_ptr, suffix_len); +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index d8c1f4647e..a43c55e06b 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -512,6 +512,17 @@ std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, . /// @return The concatenated string: name + sep + suffix std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len); +/// Optimized string concatenation: name + separator + suffix (const char* overload) +/// Uses a fixed stack buffer to avoid heap allocations. +/// @param name The base name string +/// @param name_len Length of the name +/// @param sep Single character separator +/// @param suffix_ptr Pointer to the suffix characters +/// @param suffix_len Length of the suffix +/// @return The concatenated string: name + sep + suffix +std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, + size_t suffix_len); + ///@} /// @name Parsing & formatting From 6ca0cd1e8b3f6b1622acfdcd0b8ed6a4c84d1a0b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 12:16:48 -0600 Subject: [PATCH 134/320] [ltr390] Simplify mode tracking with bitmask instead of vector/function (#12093) --- esphome/components/ltr390/ltr390.cpp | 55 ++++++++++++++-------------- esphome/components/ltr390/ltr390.h | 14 +++---- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index c1885dcb6f..ba4a7ea5cb 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -104,12 +104,17 @@ void LTR390Component::read_uvs_() { } } -void LTR390Component::read_mode_(int mode_index) { - // Set mode - LTR390MODE mode = std::get<0>(this->mode_funcs_[mode_index]); - +void LTR390Component::standby_() { std::bitset<8> ctrl = this->reg(LTR390_MAIN_CTRL).get(); - ctrl[LTR390_CTRL_MODE] = mode; + ctrl[LTR390_CTRL_EN] = false; + this->reg(LTR390_MAIN_CTRL) = ctrl.to_ulong(); + this->reading_ = false; +} + +void LTR390Component::read_mode_(LTR390MODE mode) { + // Set mode + std::bitset<8> ctrl = this->reg(LTR390_MAIN_CTRL).get(); + ctrl[LTR390_CTRL_MODE] = (mode == LTR390_MODE_UVS); ctrl[LTR390_CTRL_EN] = true; this->reg(LTR390_MAIN_CTRL) = ctrl.to_ulong(); @@ -129,21 +134,18 @@ void LTR390Component::read_mode_(int mode_index) { } // After the sensor integration time do the following - this->set_timeout(int_time + LTR390_WAKEUP_TIME + LTR390_SETTLE_TIME, [this, mode_index]() { - // Read from the sensor - std::get<1>(this->mode_funcs_[mode_index])(); - - // If there are more modes to read then begin the next - // otherwise stop - if (mode_index + 1 < (int) this->mode_funcs_.size()) { - this->read_mode_(mode_index + 1); + this->set_timeout(int_time + LTR390_WAKEUP_TIME + LTR390_SETTLE_TIME, [this, mode]() { + // Read from the sensor and continue to next mode or standby + if (mode == LTR390_MODE_ALS) { + this->read_als_(); + if (this->enabled_modes_ & ENABLED_MODE_UVS) { + this->read_mode_(LTR390_MODE_UVS); + return; + } } else { - // put sensor in standby - std::bitset<8> ctrl = this->reg(LTR390_MAIN_CTRL).get(); - ctrl[LTR390_CTRL_EN] = false; - this->reg(LTR390_MAIN_CTRL) = ctrl.to_ulong(); - this->reading_ = false; + this->read_uvs_(); } + this->standby_(); }); } @@ -172,14 +174,12 @@ void LTR390Component::setup() { // Set sensor read state this->reading_ = false; - // If we need the light sensor then add to the list + // Determine which modes are enabled based on configured sensors if (this->light_sensor_ != nullptr || this->als_sensor_ != nullptr) { - this->mode_funcs_.emplace_back(LTR390_MODE_ALS, std::bind(<R390Component::read_als_, this)); + this->enabled_modes_ |= ENABLED_MODE_ALS; } - - // If we need the UV sensor then add to the list if (this->uvi_sensor_ != nullptr || this->uv_sensor_ != nullptr) { - this->mode_funcs_.emplace_back(LTR390_MODE_UVS, std::bind(<R390Component::read_uvs_, this)); + this->enabled_modes_ |= ENABLED_MODE_UVS; } } @@ -195,10 +195,11 @@ void LTR390Component::dump_config() { } void LTR390Component::update() { - if (!this->reading_ && !mode_funcs_.empty()) { - this->reading_ = true; - this->read_mode_(0); - } + if (this->reading_ || this->enabled_modes_ == 0) + return; + + this->reading_ = true; + this->read_mode_((this->enabled_modes_ & ENABLED_MODE_ALS) ? LTR390_MODE_ALS : LTR390_MODE_UVS); } } // namespace ltr390 diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 7db73d68ff..47884b9166 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -1,7 +1,5 @@ #pragma once -#include -#include #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" @@ -60,17 +58,19 @@ class LTR390Component : public PollingComponent, public i2c::I2CDevice { void set_uv_sensor(sensor::Sensor *uv_sensor) { this->uv_sensor_ = uv_sensor; } protected: + static constexpr uint8_t ENABLED_MODE_ALS = 1 << 0; + static constexpr uint8_t ENABLED_MODE_UVS = 1 << 1; + optional read_sensor_data_(LTR390MODE mode); void read_als_(); void read_uvs_(); - void read_mode_(int mode_index); + void read_mode_(LTR390MODE mode); + void standby_(); - bool reading_; - - // a list of modes and corresponding read functions - std::vector>> mode_funcs_; + bool reading_{false}; + uint8_t enabled_modes_{0}; LTR390GAIN gain_als_; LTR390GAIN gain_uv_; From dec323e786314b9fc1d3cdddd8c8153ab237e490 Mon Sep 17 00:00:00 2001 From: Nikolai Ryzhkov Date: Tue, 25 Nov 2025 19:27:35 +0100 Subject: [PATCH 135/320] [sht4x] Read and store a serial number of SHT4x sensors (#12089) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/sht4x/sht4x.cpp | 23 ++++++++++++++++++++++- esphome/components/sht4x/sht4x.h | 2 ++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 617b19ef3e..9d29746f0b 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -7,6 +7,7 @@ namespace sht4x { static const char *const TAG = "sht4x"; static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; +static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; void SHT4XComponent::start_heater_() { uint8_t cmd[] = {MEASURECOMMANDS[this->heater_command_]}; @@ -17,6 +18,17 @@ void SHT4XComponent::start_heater_() { } } +void SHT4XComponent::read_serial_number_() { + uint16_t buffer[2]; + if (!this->get_8bit_register(SERIAL_NUMBER_COMMAND, buffer, 2, 1)) { + ESP_LOGE(TAG, "Get serial number failed"); + this->serial_number_ = 0; + return; + } + this->serial_number_ = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1])); + ESP_LOGD(TAG, "Serial number: %08" PRIx32, this->serial_number_); +} + void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { @@ -24,6 +36,8 @@ void SHT4XComponent::setup() { return; } + this->read_serial_number_(); + if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) { uint32_t heater_interval = static_cast(static_cast(this->heater_time_) / this->duty_cycle_); ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval); @@ -54,11 +68,18 @@ void SHT4XComponent::setup() { } void SHT4XComponent::dump_config() { - ESP_LOGCONFIG(TAG, "SHT4x:"); + ESP_LOGCONFIG(TAG, + "SHT4x:\n" + " Serial number: %08" PRIx32, + this->serial_number_); + LOG_I2C_DEVICE(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } + if (this->serial_number_ == 0) { + ESP_LOGW(TAG, "Get serial number failed"); + } } void SHT4XComponent::update() { diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index accc7323be..aec0f3d7f8 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -36,7 +36,9 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri float duty_cycle_; void start_heater_(); + void read_serial_number_(); uint8_t heater_command_; + uint32_t serial_number_; sensor::Sensor *temp_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; From b6be5e3eda156568c114089b22a9ba1e989a6f12 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:06:42 +1000 Subject: [PATCH 136/320] [lvgl] Allow multiple widgets per grid cell (#12091) --- esphome/components/lvgl/layout.py | 9 ++++++++- tests/components/lvgl/lvgl-package.yaml | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index a6aa816fda..caa503ef0d 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -36,6 +36,8 @@ from .defines import ( ) from .lv_validation import padding, size +CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell" + cell_alignments = LV_CELL_ALIGNMENTS.one_of grid_alignments = LV_GRID_ALIGNMENTS.one_of flex_alignments = LV_FLEX_ALIGNMENTS.one_of @@ -220,6 +222,7 @@ class GridLayout(Layout): cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, cv.Optional(CONF_PAD_ROW): padding, cv.Optional(CONF_PAD_COLUMN): padding, + cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean, }, { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, @@ -263,6 +266,7 @@ class GridLayout(Layout): # should be guaranteed to be a dict at this point assert isinstance(layout, dict) assert layout.get(CONF_TYPE).lower() == TYPE_GRID + allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False) rows = len(layout[CONF_GRID_ROWS]) columns = len(layout[CONF_GRID_COLUMNS]) used_cells = [[None] * columns for _ in range(rows)] @@ -299,7 +303,10 @@ class GridLayout(Layout): f"exceeds grid size {rows}x{columns}", [CONF_WIDGETS, index], ) - if used_cells[row + i][column + j] is not None: + if ( + not allow_multiple + and used_cells[row + i][column + j] is not None + ): raise cv.Invalid( f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", [CONF_WIDGETS, index], diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d54aef8b4a..708dfa2cb1 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -893,6 +893,7 @@ lvgl: grid_columns: [40, fr(1), fr(1)] pad_row: 6px pad_column: 0 + multiple_widgets_per_cell: true widgets: - image: grid_cell_row_pos: 0 @@ -917,6 +918,10 @@ lvgl: grid_cell_row_pos: 1 grid_cell_column_pos: 0 text: "Grid cell 1/0" + - label: + grid_cell_row_pos: 1 + grid_cell_column_pos: 0 + text: "Duplicate for 1/0" - label: styles: bdr_style grid_cell_row_pos: 1 From 70df4ecaa93d7c6bacfada4980df9034dfecc06d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:35:40 -0600 Subject: [PATCH 137/320] Bump actions/setup-python from 6.0.0 to 6.1.0 (#12106) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/sync-device-classes.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index b377ca76d8..2bee5ed211 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 9556b99015..1826ed27cf 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5287d92b10..c76d9cf2a5 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -45,7 +45,7 @@ jobs: steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c2fab0912..9cfc02d5cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -240,7 +240,7 @@ jobs: uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 497ecd29e7..1ff810d869 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 8f95fa68ee..baaa29df2c 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -22,7 +22,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: 3.13 From ae60b5e6a133b4df3266831078fc1df3976fa314 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:27:49 -0600 Subject: [PATCH 138/320] Bump actions/setup-python from 6.0.0 to 6.1.0 in /.github/actions/restore-python (#12108) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index f314e79ad9..c4ac3d1a9e 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment From 50bdcdee0c851810940652913d12dfaa38330e6e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:39:41 +1300 Subject: [PATCH 139/320] Add developer-breaking-change labelling (#12095) --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/auto-label-pr.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 28437e6302..41dd02458e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,7 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Developer breaking change (an API change that could break external components) - [ ] Code quality improvements to existing code or addition of tests - [ ] Other diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 998f3315c6..d09072d814 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -68,6 +68,7 @@ jobs: 'bugfix', 'new-feature', 'breaking-change', + 'developer-breaking-change', 'code-quality' ]; @@ -367,6 +368,7 @@ jobs: { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, + { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } ]; From ffae3501ab00c1fc06dd7d395dee64b95154fe0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 17:44:50 -0600 Subject: [PATCH 140/320] [core] Replace seq<>/gens<> with std::index_sequence for code clarity (#11921) --- esphome/components/api/user_services.h | 10 ++++--- esphome/components/script/script.h | 12 ++++----- esphome/core/automation.h | 36 +++++++++++++++++++------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 501b702e6b..d9c13c520b 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -51,13 +51,14 @@ template class UserServiceBase : public UserServiceDescriptor { return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, typename gens::type()); + this->execute_(req.args, std::make_index_sequence{}); return true; } protected: virtual void execute(Ts... x) = 0; - template void execute_(const ArgsContainer &args, seq type) { + template + void execute_(const ArgsContainer &args, std::index_sequence type) { this->execute((get_execute_arg_value(args[S]))...); } @@ -95,13 +96,14 @@ template class UserServiceDynamic : public UserServiceDescriptor return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, typename gens::type()); + this->execute_(req.args, std::make_index_sequence{}); return true; } protected: virtual void execute(Ts... x) = 0; - template void execute_(const ArgsContainer &args, seq type) { + template + void execute_(const ArgsContainer &args, std::index_sequence type) { this->execute((get_execute_arg_value(args[S]))...); } diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 3a0823f3cc..cd1a084f16 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -46,14 +46,14 @@ template class Script : public ScriptLogger, public Trigger &tuple) { - this->execute_tuple_(tuple, typename gens::type()); + this->execute_tuple_(tuple, std::make_index_sequence{}); } // Internal function to give scripts readable names. void set_name(const LogString *name) { name_ = name; } protected: - template void execute_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void execute_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->execute(std::get(tuple)...); } @@ -157,7 +157,7 @@ template class QueueingScript : public Script, public Com const size_t queue_capacity = static_cast(this->max_runs_ - 1); auto tuple_ptr = std::move(this->var_queue_[this->queue_front_]); this->queue_front_ = (this->queue_front_ + 1) % queue_capacity; - this->trigger_tuple_(*tuple_ptr, typename gens::type()); + this->trigger_tuple_(*tuple_ptr, std::make_index_sequence{}); } } @@ -174,7 +174,7 @@ template class QueueingScript : public Script, public Com } } - template void trigger_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->trigger(std::get(tuple)...); } @@ -313,7 +313,7 @@ template class ScriptWaitAction : public Action, // play_next_() can trigger more items to be queued if (!this->param_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, typename gens::type()); + this->play_next_tuple_(params, std::make_index_sequence{}); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -330,7 +330,7 @@ template class ScriptWaitAction : public Action, } protected: - template void play_next_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play_next_(std::get(tuple)...); } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 33e08c9c1c..dacadd35e8 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -11,10 +11,26 @@ namespace esphome { +// C++20 std::index_sequence is now used for tuple unpacking +// Legacy seq<>/gens<> pattern deprecated but kept for backwards compatibility // https://stackoverflow.com/questions/7858817/unpacking-a-tuple-to-call-a-matching-function-pointer/7858971#7858971 -template struct seq {}; // NOLINT -template struct gens : gens {}; // NOLINT -template struct gens<0, S...> { using type = seq; }; // NOLINT +// Remove before 2026.6.0 +// NOLINTBEGIN(readability-identifier-naming) +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + +template struct ESPDEPRECATED("Use std::index_sequence instead. Removed in 2026.6.0", "2025.12.0") seq {}; +template +struct ESPDEPRECATED("Use std::make_index_sequence instead. Removed in 2026.6.0", "2025.12.0") gens + : gens {}; +template struct gens<0, S...> { using type = seq; }; + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif +// NOLINTEND(readability-identifier-naming) #define TEMPLATABLE_VALUE_(type, name) \ protected: \ @@ -152,11 +168,11 @@ template class Condition { /// Call check with a tuple of values as parameter. bool check_tuple(const std::tuple &tuple) { - return this->check_tuple_(tuple, typename gens::type()); + return this->check_tuple_(tuple, std::make_index_sequence{}); } protected: - template bool check_tuple_(const std::tuple &tuple, seq /*unused*/) { + template bool check_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { return this->check(std::get(tuple)...); } }; @@ -231,11 +247,11 @@ template class Action { } } } - template void play_next_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play_next_(std::get(tuple)...); } void play_next_tuple_(const std::tuple &tuple) { - this->play_next_tuple_(tuple, typename gens::type()); + this->play_next_tuple_(tuple, std::make_index_sequence{}); } virtual void stop() {} @@ -277,7 +293,9 @@ template class ActionList { if (this->actions_begin_ != nullptr) this->actions_begin_->play_complex(x...); } - void play_tuple(const std::tuple &tuple) { this->play_tuple_(tuple, typename gens::type()); } + void play_tuple(const std::tuple &tuple) { + this->play_tuple_(tuple, std::make_index_sequence{}); + } void stop() { if (this->actions_begin_ != nullptr) this->actions_begin_->stop_complex(); @@ -298,7 +316,7 @@ template class ActionList { } protected: - template void play_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } From bda17180df0c9ca735d25dc52def20b83b2465f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 17:48:08 -0600 Subject: [PATCH 141/320] [core] Deduplicate identical stateless lambdas to reduce flash usage (#11918) --- esphome/cpp_generator.py | 177 ++++++++++++++- tests/component_tests/text/test_text.py | 19 +- tests/unit_tests/test_lambda_dedup.py | 286 ++++++++++++++++++++++++ 3 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/test_lambda_dedup.py diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6f1af01a5b..4f91696ca1 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,11 +19,21 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) +from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase +# Keys for lambda deduplication storage in CORE.data +_KEY_LAMBDA_DEDUP = "lambda_dedup" +_KEY_LAMBDA_DEDUP_DECLARATIONS = "lambda_dedup_declarations" + +# Regex patterns for static variable detection (compiled once) +_RE_CPP_SINGLE_LINE_COMMENT = re.compile(r"//.*?$", re.MULTILINE) +_RE_CPP_MULTI_LINE_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) +_RE_STATIC_VARIABLE = re.compile(r"\bstatic\s+(?!cast|assert|pointer_cast)\w+\s+\w+") + class RawExpression(Expression): __slots__ = ("text",) @@ -188,7 +198,7 @@ class LambdaExpression(Expression): def __init__( self, parts, parameters, capture: str = "=", return_type=None, source=None - ): + ) -> None: self.parts = parts if not isinstance(parameters, ParameterListExpression): parameters = ParameterListExpression(*parameters) @@ -197,16 +207,21 @@ class LambdaExpression(Expression): self.capture = capture self.return_type = safe_exp(return_type) if return_type is not None else None - def __str__(self): + def format_body(self) -> str: + """Format the lambda body with source directive and content.""" + body = "" + if self.source is not None: + body += f"{self.source.as_line_directive}\n" + body += self.content + return body + + def __str__(self) -> str: # Stateless lambdas (empty capture) implicitly convert to function pointers # when assigned to function pointer types - no unary + needed cpp = f"[{self.capture}]({self.parameters})" if self.return_type is not None: cpp += f" -> {self.return_type}" - cpp += " {\n" - if self.source is not None: - cpp += f"{self.source.as_line_directive}\n" - cpp += f"{self.content}\n}}" + cpp += f" {{\n{self.format_body()}\n}}" return indent_all_but_first_and_last(cpp) @property @@ -214,6 +229,37 @@ class LambdaExpression(Expression): return "".join(str(part) for part in self.parts) +class SharedFunctionLambdaExpression(LambdaExpression): + """A lambda expression that references a shared deduplicated function. + + This class wraps a function pointer but maintains the LambdaExpression + interface so calling code works unchanged. + """ + + __slots__ = ("_func_name",) + + def __init__( + self, + func_name: str, + parameters: TemplateArgsType, + return_type: SafeExpType | None = None, + ) -> None: + # Initialize parent with empty parts since we're just a function reference + super().__init__( + [], parameters, capture="", return_type=return_type, source=None + ) + self._func_name = func_name + + def __str__(self) -> str: + # Just return the function name - it's already a function pointer + return self._func_name + + @property + def content(self) -> str: + # No content, just a function reference + return "" + + # pylint: disable=abstract-method class Literal(Expression, metaclass=abc.ABCMeta): __slots__ = () @@ -583,6 +629,25 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False): CORE.add_global(expression, prepend) +@coroutine_with_priority(CoroPriority.FINAL) +async def flush_lambda_dedup_declarations() -> None: + """Flush all deferred lambda deduplication declarations to global scope. + + This is a coroutine that runs with FINAL priority (after all components) + to ensure all referenced variables are declared before the shared + lambda functions that use them. + """ + if _KEY_LAMBDA_DEDUP_DECLARATIONS not in CORE.data: + return + + declarations = CORE.data[_KEY_LAMBDA_DEDUP_DECLARATIONS] + for func_declaration in declarations: + add_global(RawStatement(func_declaration)) + + # Clear the list so we don't add them again + CORE.data[_KEY_LAMBDA_DEDUP_DECLARATIONS] = [] + + def add_library(name: str, version: str | None, repository: str | None = None): """Add a library to the codegen library storage. @@ -656,6 +721,93 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]: return await CORE.get_variable_with_full_id(id_) +def _has_static_variables(code: str) -> bool: + """Check if code contains static variable definitions. + + Static variables in lambdas should not be deduplicated because each lambda + instance should have its own static variable state. + + Args: + code: The lambda body code to check + + Returns: + True if code contains static variable definitions + """ + # Remove C++ comments to avoid false positives + # Remove single-line comments (// ...) + code_no_comments = _RE_CPP_SINGLE_LINE_COMMENT.sub("", code) + # Remove multi-line comments (/* ... */) + code_no_comments = _RE_CPP_MULTI_LINE_COMMENT.sub("", code_no_comments) + + # Match: static + # But not: static_cast, static_assert, static_pointer_cast + return bool(_RE_STATIC_VARIABLE.search(code_no_comments)) + + +def _get_shared_lambda_name(lambda_expr: LambdaExpression) -> str | None: + """Get the shared function name for a lambda expression. + + If an identical lambda was already generated, returns the existing shared + function name. Otherwise, creates a new shared function and returns its name. + + Lambdas with static variables are not deduplicated to preserve their + independent state. + + Args: + lambda_expr: The lambda expression to deduplicate + + Returns: + The name of the shared function for this lambda (either existing or newly created), + or None if the lambda should not be deduplicated (e.g., contains static variables) + """ + # Create a unique key from the lambda content, parameters, and return type + content = lambda_expr.content + + # Don't deduplicate lambdas with static variables - each instance needs its own state + if _has_static_variables(content): + return None + param_str = str(lambda_expr.parameters) + return_str = ( + str(lambda_expr.return_type) if lambda_expr.return_type is not None else "void" + ) + + # Use tuple of (content, params, return_type) as key + lambda_key = (content, param_str, return_str) + + # Initialize deduplication storage in CORE.data if not exists + if _KEY_LAMBDA_DEDUP not in CORE.data: + CORE.data[_KEY_LAMBDA_DEDUP] = {} + # Register the flush job to run after all components (FINAL priority) + # This ensures all variables are declared before shared lambda functions + CORE.add_job(flush_lambda_dedup_declarations) + + lambda_cache = CORE.data[_KEY_LAMBDA_DEDUP] + + # Check if we've seen this lambda before + if lambda_key in lambda_cache: + # Return name of existing shared function + return lambda_cache[lambda_key] + + # First occurrence - create a shared function + # Use the cache size as the function number + func_name = f"shared_lambda_{len(lambda_cache)}" + + # Build the function declaration using lambda's body formatting + func_declaration = ( + f"{return_str} {func_name}({param_str}) {{\n{lambda_expr.format_body()}\n}}" + ) + + # Store the declaration to be added later (after all variable declarations) + # We can't add it immediately because it might reference variables not yet declared + CORE.data.setdefault(_KEY_LAMBDA_DEDUP_DECLARATIONS, []).append(func_declaration) + + # Store in cache + lambda_cache[lambda_key] = func_name + + # Return the function name (this is the first occurrence, but we still generate shared function) + return func_name + + async def process_lambda( value: Lambda, parameters: TemplateArgsType, @@ -713,6 +865,19 @@ async def process_lambda( location.line += value.content_offset else: location = None + + # Lambda deduplication: Only deduplicate stateless lambdas (empty capture). + # Stateful lambdas cannot be shared as they capture different contexts. + # Lambdas with static variables are also not deduplicated to preserve independent state. + if capture == "": + lambda_expr = LambdaExpression( + parts, parameters, capture, return_type, location + ) + func_name = _get_shared_lambda_name(lambda_expr) + if func_name is not None: + # Return a shared function reference instead of inline lambda + return SharedFunctionLambdaExpression(func_name, parameters, return_type) + return LambdaExpression(parts, parameters, capture, return_type, location) diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index bfc3131f6d..56dee205b4 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -1,4 +1,6 @@ -"""Tests for the binary sensor component.""" +"""Tests for the text component.""" + +from esphome.core import CORE def test_text_is_setup(generate_main): @@ -56,15 +58,22 @@ def test_text_config_value_mode_set(generate_main): assert "it_3->traits.set_mode(text::TEXT_MODE_PASSWORD);" in main_cpp -def test_text_config_lamda_is_set(generate_main): +def test_text_config_lambda_is_set(generate_main) -> None: """ - Test if lambda is set for lambda mode (optimized with stateless lambda) + Test if lambda is set for lambda mode (optimized with stateless lambda and deduplication) """ # Given # When main_cpp = generate_main("tests/component_tests/text/test_text.yaml") + # Get both global and main sections to find the shared lambda definition + full_cpp = CORE.cpp_global_section + main_cpp + # Then - assert "it_4->set_template([]() -> esphome::optional {" in main_cpp - assert 'return std::string{"Hello"};' in main_cpp + # Lambda is deduplicated into a shared function (reference in main section) + assert "it_4->set_template(shared_lambda_" in main_cpp + # Lambda body should be in the code somewhere + assert 'return std::string{"Hello"};' in full_cpp + # Verify the shared lambda function is defined (in global section) + assert "esphome::optional shared_lambda_" in full_cpp diff --git a/tests/unit_tests/test_lambda_dedup.py b/tests/unit_tests/test_lambda_dedup.py new file mode 100644 index 0000000000..bbf5f02e6d --- /dev/null +++ b/tests/unit_tests/test_lambda_dedup.py @@ -0,0 +1,286 @@ +"""Tests for lambda deduplication in cpp_generator.""" + +from esphome import cpp_generator as cg +from esphome.core import CORE + + +def test_deduplicate_identical_lambdas() -> None: + """Test that identical stateless lambdas are deduplicated.""" + # Create two identical lambda expressions + lambda1 = cg.LambdaExpression( + parts=["return 42;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + lambda2 = cg.LambdaExpression( + parts=["return 42;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + # Try to deduplicate them + func_name1 = cg._get_shared_lambda_name(lambda1) + func_name2 = cg._get_shared_lambda_name(lambda2) + + # Both should get the same function name (deduplication happened) + assert func_name1 == func_name2 + assert func_name1 == "shared_lambda_0" + + +def test_different_lambdas_not_deduplicated() -> None: + """Test that different lambdas get different function names.""" + lambda1 = cg.LambdaExpression( + parts=["return 42;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + lambda2 = cg.LambdaExpression( + parts=["return 24;"], # Different content + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + func_name1 = cg._get_shared_lambda_name(lambda1) + func_name2 = cg._get_shared_lambda_name(lambda2) + + # Different lambdas should get different function names + assert func_name1 != func_name2 + assert func_name1 == "shared_lambda_0" + assert func_name2 == "shared_lambda_1" + + +def test_different_return_types_not_deduplicated() -> None: + """Test that lambdas with different return types are not deduplicated.""" + lambda1 = cg.LambdaExpression( + parts=["return 42;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + lambda2 = cg.LambdaExpression( + parts=["return 42;"], # Same content + parameters=[], + capture="", + return_type=cg.RawExpression("float"), # Different return type + ) + + func_name1 = cg._get_shared_lambda_name(lambda1) + func_name2 = cg._get_shared_lambda_name(lambda2) + + # Different return types = different functions + assert func_name1 != func_name2 + + +def test_different_parameters_not_deduplicated() -> None: + """Test that lambdas with different parameters are not deduplicated.""" + lambda1 = cg.LambdaExpression( + parts=["return x;"], + parameters=[("int", "x")], + capture="", + return_type=cg.RawExpression("int"), + ) + + lambda2 = cg.LambdaExpression( + parts=["return x;"], # Same content + parameters=[("float", "x")], # Different parameter type + capture="", + return_type=cg.RawExpression("int"), + ) + + func_name1 = cg._get_shared_lambda_name(lambda1) + func_name2 = cg._get_shared_lambda_name(lambda2) + + # Different parameters = different functions + assert func_name1 != func_name2 + + +def test_flush_lambda_dedup_declarations() -> None: + """Test that deferred declarations are properly stored for later flushing.""" + # Create a lambda which will create a deferred declaration + lambda1 = cg.LambdaExpression( + parts=["return 42;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + cg._get_shared_lambda_name(lambda1) + + # Check that declaration was stored + assert cg._KEY_LAMBDA_DEDUP_DECLARATIONS in CORE.data + assert len(CORE.data[cg._KEY_LAMBDA_DEDUP_DECLARATIONS]) == 1 + + # Verify the declaration content is correct + declaration = CORE.data[cg._KEY_LAMBDA_DEDUP_DECLARATIONS][0] + assert "shared_lambda_0" in declaration + assert "return 42;" in declaration + + # Note: The actual flushing happens via CORE.add_job with FINAL priority + # during real code generation, so we don't test that here + + +def test_shared_function_lambda_expression() -> None: + """Test SharedFunctionLambdaExpression behaves correctly.""" + shared_lambda = cg.SharedFunctionLambdaExpression( + func_name="shared_lambda_0", + parameters=[], + return_type=cg.RawExpression("int"), + ) + + # Should output just the function name + assert str(shared_lambda) == "shared_lambda_0" + + # Should have empty capture (stateless) + assert shared_lambda.capture == "" + + # Should have empty content (just a reference) + assert shared_lambda.content == "" + + +def test_lambda_deduplication_counter() -> None: + """Test that lambda counter increments correctly.""" + # Create 3 different lambdas + for i in range(3): + lambda_expr = cg.LambdaExpression( + parts=[f"return {i};"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + func_name = cg._get_shared_lambda_name(lambda_expr) + assert func_name == f"shared_lambda_{i}" + + +def test_lambda_format_body() -> None: + """Test that format_body correctly formats lambda body with source.""" + # Without source + lambda1 = cg.LambdaExpression( + parts=["return 42;"], + parameters=[], + capture="", + return_type=None, + source=None, + ) + assert lambda1.format_body() == "return 42;" + + # With source would need a proper source object, skip for now + + +def test_stateful_lambdas_not_deduplicated() -> None: + """Test that stateful lambdas (non-empty capture) are not deduplicated.""" + # _get_shared_lambda_name is only called for stateless lambdas (capture == "") + # Stateful lambdas bypass deduplication entirely in process_lambda + + # Verify that a stateful lambda would NOT get deduplicated + # by checking it's not in the stateless dedup cache + stateful_lambda = cg.LambdaExpression( + parts=["return x + y;"], + parameters=[], + capture="=", # Non-empty capture means stateful + return_type=cg.RawExpression("int"), + ) + + # Stateful lambdas should NOT be passed to _get_shared_lambda_name + # This is enforced by the `if capture == ""` check in process_lambda + # We verify the lambda has a non-empty capture + assert stateful_lambda.capture != "" + assert stateful_lambda.capture == "=" + + +def test_static_variable_detection() -> None: + """Test detection of static variables in lambda code.""" + # Should detect static variables + assert cg._has_static_variables("static int counter = 0;") + assert cg._has_static_variables("static bool flag = false; return flag;") + assert cg._has_static_variables(" static float value = 1.0; ") + + # Should NOT detect static_cast, static_assert, etc. (with underscores) + assert not cg._has_static_variables("return static_cast(value);") + assert not cg._has_static_variables("static_assert(sizeof(int) == 4);") + assert not cg._has_static_variables("auto ptr = static_pointer_cast(bar);") + + # Edge case: 'cast', 'assert', 'pointer_cast' are NOT C++ keywords + # Someone could use them as type names, but we should NOT flag them + # because they're not actually static variables with state + # NOTE: These are valid C++ but extremely unlikely in ESPHome lambdas + assert not cg._has_static_variables("static cast obj;") # 'cast' as type name + assert not cg._has_static_variables("static assert value;") # 'assert' as type name + assert not cg._has_static_variables( + "static pointer_cast ptr;" + ) # 'pointer_cast' as type + + # Should NOT detect in comments + assert not cg._has_static_variables("// static int x = 0;\nreturn 42;") + assert not cg._has_static_variables("/* static int y = 0; */ return 42;") + + # Should detect even with comments elsewhere + assert cg._has_static_variables("// comment\nstatic int x = 0;\nreturn x;") + + # Should NOT detect non-static code + assert not cg._has_static_variables("int counter = 0; return counter++;") + assert not cg._has_static_variables("return 42;") + + # Should handle newlines between static and type/variable + assert cg._has_static_variables("static int\nfoo = 0;") + assert cg._has_static_variables("static\nint\nbar = 0;") + assert cg._has_static_variables( + "static int \n foo = 0;" + ) # Mixed spaces/newlines + + +def test_lambdas_with_static_not_deduplicated() -> None: + """Test that lambdas with static variables are not deduplicated.""" + # Two identical lambdas with static variables + lambda1 = cg.LambdaExpression( + parts=["static int counter = 0; return counter++;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + lambda2 = cg.LambdaExpression( + parts=["static int counter = 0; return counter++;"], + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + # Should return None (not deduplicated) + func_name1 = cg._get_shared_lambda_name(lambda1) + func_name2 = cg._get_shared_lambda_name(lambda2) + + assert func_name1 is None + assert func_name2 is None + + +def test_lambdas_without_static_still_deduplicated() -> None: + """Test that lambdas without static variables are still deduplicated.""" + # Two identical lambdas WITHOUT static variables + lambda1 = cg.LambdaExpression( + parts=["int counter = 0; return counter++;"], # No static + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + lambda2 = cg.LambdaExpression( + parts=["int counter = 0; return counter++;"], # No static + parameters=[], + capture="", + return_type=cg.RawExpression("int"), + ) + + # Should be deduplicated (same function name) + func_name1 = cg._get_shared_lambda_name(lambda1) + func_name2 = cg._get_shared_lambda_name(lambda2) + + assert func_name1 is not None + assert func_name2 is not None + assert func_name1 == func_name2 From 03a8ef71ff4224ad57315402bfedc897906d6038 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 18:37:49 -0600 Subject: [PATCH 142/320] [esp32_ble_client] Replace std::string with char[18] for BLE address storage (#12070) --- esphome/components/alpha3/alpha3.cpp | 18 ++--- .../components/am43/sensor/am43_sensor.cpp | 13 ++-- esphome/components/anova/anova.cpp | 9 +-- esphome/components/ble_client/automation.h | 2 +- esphome/components/ble_client/ble_client.cpp | 2 +- .../ble_client/output/ble_binary_output.cpp | 4 +- .../ble_client/sensor/ble_rssi_sensor.cpp | 6 +- .../ble_client/sensor/ble_sensor.cpp | 2 +- .../text_sensor/ble_text_sensor.cpp | 2 +- .../bluetooth_proxy/bluetooth_connection.cpp | 41 +++++----- .../bluetooth_proxy/bluetooth_proxy.cpp | 11 ++- .../esp32_ble_client/ble_characteristic.cpp | 6 +- .../esp32_ble_client/ble_client_base.cpp | 75 +++++++++---------- .../esp32_ble_client/ble_client_base.h | 16 ++-- .../esp32_ble_client/ble_service.cpp | 4 +- .../display/pvvx_display.cpp | 36 +++++---- 16 files changed, 115 insertions(+), 132 deletions(-) diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 344f2d5a03..f22a8e2444 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -56,13 +56,13 @@ bool Alpha3::is_current_response_type_(const uint8_t *response_type) { void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) { if (this->response_offset_ >= this->response_length_) { - ESP_LOGD(TAG, "[%s] GENI response begin", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] GENI response begin", this->parent_->address_str()); if (length < GENI_RESPONSE_HEADER_LENGTH) { - ESP_LOGW(TAG, "[%s] response to short", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] response too short", this->parent_->address_str()); return; } if (response[0] != 36 || response[2] != 248 || response[3] != 231 || response[4] != 10) { - ESP_LOGW(TAG, "[%s] response bytes %d %d %d %d %d don't match GENI HEADER", this->parent_->address_str().c_str(), + ESP_LOGW(TAG, "[%s] response bytes %d %d %d %d %d don't match GENI HEADER", this->parent_->address_str(), response[0], response[1], response[2], response[3], response[4]); return; } @@ -77,11 +77,11 @@ void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) { }; if (this->is_current_response_type_(GENI_RESPONSE_TYPE_FLOW_HEAD)) { - ESP_LOGD(TAG, "[%s] FLOW HEAD Response", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] FLOW HEAD Response", this->parent_->address_str()); extract_publish_sensor_value(GENI_RESPONSE_FLOW_OFFSET, this->flow_sensor_, 3600.0F); extract_publish_sensor_value(GENI_RESPONSE_HEAD_OFFSET, this->head_sensor_, .0001F); } else if (this->is_current_response_type_(GENI_RESPONSE_TYPE_POWER)) { - ESP_LOGD(TAG, "[%s] POWER Response", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] POWER Response", this->parent_->address_str()); extract_publish_sensor_value(GENI_RESPONSE_POWER_OFFSET, this->power_sensor_, 1.0F); extract_publish_sensor_value(GENI_RESPONSE_CURRENT_OFFSET, this->current_sensor_, 1.0F); extract_publish_sensor_value(GENI_RESPONSE_MOTOR_SPEED_OFFSET, this->speed_sensor_, 1.0F); @@ -100,7 +100,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc if (param->open.status == ESP_GATT_OK) { this->response_offset_ = 0; this->response_length_ = 0; - ESP_LOGI(TAG, "[%s] connection open", this->parent_->address_str().c_str()); + ESP_LOGI(TAG, "[%s] connection open", this->parent_->address_str()); } break; } @@ -132,7 +132,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc case ESP_GATTC_SEARCH_CMPL_EVT: { auto *chr = this->parent_->get_characteristic(ALPHA3_GENI_SERVICE_UUID, ALPHA3_GENI_CHARACTERISTIC_UUID); if (chr == nullptr) { - ESP_LOGE(TAG, "[%s] No GENI service found at device, not an Alpha3..?", this->parent_->address_str().c_str()); + ESP_LOGE(TAG, "[%s] No GENI service found at device, not an Alpha3..?", this->parent_->address_str()); break; } auto status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(), @@ -164,12 +164,12 @@ void Alpha3::send_request_(uint8_t *request, size_t len) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len, request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } void Alpha3::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str()); return; } diff --git a/esphome/components/am43/sensor/am43_sensor.cpp b/esphome/components/am43/sensor/am43_sensor.cpp index 4cc99001ae..b2bc3254e2 100644 --- a/esphome/components/am43/sensor/am43_sensor.cpp +++ b/esphome/components/am43/sensor/am43_sensor.cpp @@ -44,11 +44,9 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i auto *chr = this->parent_->get_characteristic(AM43_SERVICE_UUID, AM43_CHARACTERISTIC_UUID); if (chr == nullptr) { if (this->parent_->get_characteristic(AM43_TUYA_SERVICE_UUID, AM43_TUYA_CHARACTERISTIC_UUID) != nullptr) { - ESP_LOGE(TAG, "[%s] Detected a Tuya AM43 which is not supported, sorry.", - this->parent_->address_str().c_str()); + ESP_LOGE(TAG, "[%s] Detected a Tuya AM43 which is not supported, sorry.", this->parent_->address_str()); } else { - ESP_LOGE(TAG, "[%s] No control service found at device, not an AM43..?", - this->parent_->address_str().c_str()); + ESP_LOGE(TAG, "[%s] No control service found at device, not an AM43..?", this->parent_->address_str()); } break; } @@ -82,8 +80,7 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), - status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } this->current_sensor_ = 0; @@ -97,7 +94,7 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i void Am43::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str()); return; } if (this->current_sensor_ == 0) { @@ -107,7 +104,7 @@ void Am43::update() { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } this->current_sensor_++; diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index d0e8f6827f..2693224a97 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -42,7 +42,7 @@ void Anova::control(const ClimateCall &call) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } if (call.get_target_temperature().has_value()) { @@ -51,7 +51,7 @@ void Anova::control(const ClimateCall &call) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } } @@ -124,8 +124,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), - status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } } @@ -150,7 +149,7 @@ void Anova::update() { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } this->current_request_++; } diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index bbc2dd05e0..788eac4a57 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -198,7 +198,7 @@ template class BLEClientWriteAction : public Action, publ } this->node_state = espbt::ClientState::ESTABLISHED; esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_string().c_str(), - ble_client_->address_str().c_str()); + ble_client_->address_str()); break; } default: diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index 5cf096c9d4..b8968fe4ba 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -39,7 +39,7 @@ void BLEClient::set_enabled(bool enabled) { return; this->enabled = enabled; if (!enabled) { - ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str().c_str()); + ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str()); this->disconnect(); } } diff --git a/esphome/components/ble_client/output/ble_binary_output.cpp b/esphome/components/ble_client/output/ble_binary_output.cpp index ce67193be7..84558717f8 100644 --- a/esphome/components/ble_client/output/ble_binary_output.cpp +++ b/esphome/components/ble_client/output/ble_binary_output.cpp @@ -14,7 +14,7 @@ void BLEBinaryOutput::dump_config() { " MAC address : %s\n" " Service UUID : %s\n" " Characteristic UUID: %s", - this->parent_->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent_->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str()); LOG_BINARY_OUTPUT(this); } @@ -44,7 +44,7 @@ void BLEBinaryOutput::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i } this->node_state = espbt::ClientState::ESTABLISHED; ESP_LOGD(TAG, "Found characteristic %s on device %s", this->char_uuid_.to_string().c_str(), - this->parent()->address_str().c_str()); + this->parent()->address_str()); this->node_state = espbt::ClientState::ESTABLISHED; break; } diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 663c52ac10..4edcbd3877 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -19,7 +19,7 @@ void BLEClientRSSISensor::loop() { void BLEClientRSSISensor::dump_config() { LOG_SENSOR("", "BLE Client RSSI Sensor", this); - ESP_LOGCONFIG(TAG, " MAC address : %s", this->parent()->address_str().c_str()); + ESP_LOGCONFIG(TAG, " MAC address : %s", this->parent()->address_str()); LOG_UPDATE_INTERVAL(this); } @@ -69,10 +69,10 @@ void BLEClientRSSISensor::update() { this->get_rssi_(); } void BLEClientRSSISensor::get_rssi_() { - ESP_LOGV(TAG, "requesting rssi from %s", this->parent()->address_str().c_str()); + ESP_LOGV(TAG, "requesting rssi from %s", this->parent()->address_str()); auto status = esp_ble_gap_read_rssi(this->parent()->get_remote_bda()); if (status != ESP_OK) { - ESP_LOGW(TAG, "esp_ble_gap_read_rssi error, address=%s, status=%d", this->parent()->address_str().c_str(), status); + ESP_LOGW(TAG, "esp_ble_gap_read_rssi error, address=%s, status=%d", this->parent()->address_str(), status); this->status_set_warning(); this->publish_state(NAN); } diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 61685c0566..8e3e483003 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -25,7 +25,7 @@ void BLESensor::dump_config() { " Characteristic UUID: %s\n" " Descriptor UUID : %s\n" " Notifications : %s", - this->parent()->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent()->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str(), this->descr_uuid_.to_string().c_str(), YESNO(this->notify_)); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index b7a6d154db..bb771aed99 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -28,7 +28,7 @@ void BLETextSensor::dump_config() { " Characteristic UUID: %s\n" " Descriptor UUID : %s\n" " Notifications : %s", - this->parent()->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent()->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str(), this->descr_uuid_.to_string().c_str(), YESNO(this->notify_)); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fcc344dda9..1d6f7e23b3 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -196,8 +196,8 @@ void BluetoothConnection::send_service_for_discovery_() { if (service_status != ESP_GATT_OK || service_count == 0) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str().c_str(), - service_status != ESP_GATT_OK ? "error" : "missing", service_status, service_count, this->send_service_); + this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", + service_status, service_count, this->send_service_); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -312,13 +312,13 @@ void BluetoothConnection::send_service_for_discovery_() { if (resp.services.size() > 1) { resp.services.pop_back(); ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", - this->connection_index_, this->address_str().c_str(), this->send_service_, current_size, service_size, + this->connection_index_, this->address_str(), this->send_service_, current_size, service_size, MAX_PACKET_SIZE); // Don't increment send_service_ - we'll retry this service in next batch } else { // This single service is too large, but we have to send it anyway ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, - this->address_str().c_str(), this->send_service_, service_size); + this->address_str(), this->send_service_, service_size); // Increment so we don't get stuck this->send_service_++; } @@ -337,21 +337,20 @@ void BluetoothConnection::send_service_for_discovery_() { } void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str().c_str(), operation, - status); + ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); } void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str().c_str(), operation, err); + ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); } void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str().c_str(), - action, type); + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, + type); } void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str().c_str(), + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), operation, handle, status); } @@ -372,14 +371,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_DISCONNECT_EVT: { // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, param->disconnect.reason); // Send disconnection notification but don't free the slot yet this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, param->close.reason); // Now the GATT connection is fully closed and controller resources are freed // Safe to mark the connection slot as available @@ -463,7 +462,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga break; } case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_.c_str(), + ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, param->notify.handle); api::BluetoothGATTNotifyDataResponse resp; resp.address = this->address_; @@ -502,8 +501,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_read_char", err); @@ -515,8 +513,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) @@ -532,8 +529,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { this->log_gatt_not_connected_("read", "descriptor"); return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); @@ -544,8 +540,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t * this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) @@ -564,13 +559,13 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl if (enable) { ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_.c_str(), handle); + this->address_str_, handle); esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); } ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_.c_str(), handle); + this->address_str_, handle); esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 34e0aa93a3..71f8da75a7 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -47,12 +47,11 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), - connection->address_str().c_str(), espbt::client_state_to_string(state)); + connection->address_str(), espbt::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { - ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str().c_str(), - message); + ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { @@ -186,7 +185,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), - connection->address_str().c_str()); + connection->address_str()); this->send_device_connection(msg.address, false); return; } @@ -199,7 +198,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } else if (connection->state() == espbt::ClientState::CONNECTING) { if (connection->disconnect_pending()) { ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str().c_str()); + connection->get_connection_index(), connection->address_str()); connection->cancel_pending_disconnect(); return; } @@ -339,7 +338,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer return; } if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str().c_str()); + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); this->send_gatt_services_done(msg.address); return; } diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index 36229c23c3..e0d0174c57 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -38,7 +38,7 @@ void BLECharacteristic::parse_descriptors() { } if (status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", - this->service->client->get_connection_index(), this->service->client->address_str().c_str(), status); + this->service->client->get_connection_index(), this->service->client->address_str(), status); break; } if (count == 0) { @@ -51,7 +51,7 @@ void BLECharacteristic::parse_descriptors() { desc->characteristic = this; this->descriptors.push_back(desc); ESP_LOGV(TAG, "[%d] [%s] descriptor %s, handle 0x%x", this->service->client->get_connection_index(), - this->service->client->address_str().c_str(), desc->uuid.to_string().c_str(), desc->handle); + this->service->client->address_str(), desc->uuid.to_string().c_str(), desc->handle); offset++; } } @@ -84,7 +84,7 @@ esp_err_t BLECharacteristic::write_value(uint8_t *new_val, int16_t new_val_size, new_val, write_type, ESP_GATT_AUTH_REQ_NONE); if (status) { ESP_LOGW(TAG, "[%d] [%s] Error sending write value to BLE gattc server, status=%d", - this->service->client->get_connection_index(), this->service->client->address_str().c_str(), status); + this->service->client->get_connection_index(), this->service->client->address_str(), status); } return status; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 18321ef91c..07e88c7528 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -41,7 +41,7 @@ void BLEClientBase::setup() { } void BLEClientBase::set_state(espbt::ClientState st) { - ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); + ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_, (int) st); ESPBTClient::set_state(st); } @@ -71,7 +71,7 @@ void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" " Auto-Connect: %s", - this->address_str().c_str(), TRUEFALSE(this->auto_connect_)); + this->address_str(), TRUEFALSE(this->auto_connect_)); ESP_LOGCONFIG(TAG, " State: %s", espbt::client_state_to_string(this->state())); if (this->status_ == ESP_GATT_NO_RESOURCES) { ESP_LOGE(TAG, " Failed due to no resources. Try to reduce number of BLE clients in config."); @@ -104,12 +104,11 @@ void BLEClientBase::connect() { // Prevent duplicate connection attempts if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED || this->state_ == espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_)); + ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_, + espbt::client_state_to_string(this->state_)); return; } - ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), - this->remote_addr_type_); + ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // Enable loop for state processing this->enable_loop(); @@ -135,13 +134,13 @@ esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda void BLEClientBase::disconnect() { if (this->state_ == espbt::ClientState::IDLE || this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state_)); return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, - this->address_str_.c_str()); + this->address_str_); this->want_disconnect_ = true; return; } @@ -150,8 +149,7 @@ void BLEClientBase::disconnect() { void BLEClientBase::unconditional_disconnect() { // Disconnect without checking the state. - ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_.c_str(), - this->conn_id_); + ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_, this->conn_id_); if (this->state_ == espbt::ClientState::DISCONNECTING) { this->log_error_("Already disconnecting"); return; @@ -192,24 +190,23 @@ void BLEClientBase::release_services() { } void BLEClientBase::log_event_(const char *name) { - ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); + ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name); } void BLEClientBase::log_gattc_event_(const char *name) { - ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_.c_str(), name); + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_, name); } void BLEClientBase::log_gattc_warning_(const char *operation, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, - status); + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_, operation, status); } void BLEClientBase::log_gattc_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, err); + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_, operation, err); } 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); + ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_, param_type); } void BLEClientBase::handle_connection_result_(esp_err_t ret) { @@ -220,15 +217,15 @@ void BLEClientBase::handle_connection_result_(esp_err_t ret) { } void BLEClientBase::log_error_(const char *message) { - ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); + ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_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); + ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_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); + ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, message); } void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, @@ -264,13 +261,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) return false; - ESP_LOGV(TAG, "[%d] [%s] gattc_event_handler: event=%d gattc_if=%d", this->connection_index_, - this->address_str_.c_str(), event, esp_gattc_if); + ESP_LOGV(TAG, "[%d] [%s] gattc_event_handler: event=%d gattc_if=%d", this->connection_index_, this->address_str_, + event, esp_gattc_if); switch (event) { case ESP_GATTC_REG_EVT: { if (param->reg.status == ESP_GATT_OK) { - ESP_LOGV(TAG, "[%d] [%s] gattc registered app id %d", this->connection_index_, this->address_str_.c_str(), + ESP_LOGV(TAG, "[%d] [%s] gattc registered app id %d", this->connection_index_, this->address_str_, this->app_id); this->gattc_if_ = esp_gattc_if; } else { @@ -292,7 +289,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // 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); + this->address_str_, param->open.status); break; } @@ -301,7 +298,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // because it means we have a bad assumption about how the // ESP BT stack works. 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); + this->address_str_, espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -318,7 +315,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } // MTU negotiation already started in ESP_GATTC_CONNECT_EVT this->set_state(espbt::ClientState::CONNECTED); - ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. @@ -354,8 +351,8 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->state_ == espbt::ClientState::CONNECTED) { 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); + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_, + param->disconnect.reason); } this->release_services(); this->set_state(espbt::ClientState::IDLE); @@ -366,12 +363,12 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->cfg_mtu.conn_id) return false; if (param->cfg_mtu.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] cfg_mtu failed, mtu %d, status %d", this->connection_index_, - this->address_str_.c_str(), param->cfg_mtu.mtu, param->cfg_mtu.status); + ESP_LOGW(TAG, "[%d] [%s] cfg_mtu failed, mtu %d, status %d", this->connection_index_, this->address_str_, + param->cfg_mtu.mtu, param->cfg_mtu.status); // No state change required here - disconnect event will follow if needed. break; } - ESP_LOGD(TAG, "[%d] [%s] cfg_mtu status %d, mtu %d", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] cfg_mtu status %d, mtu %d", this->connection_index_, this->address_str_, param->cfg_mtu.status, param->cfg_mtu.mtu); this->mtu_ = param->cfg_mtu.mtu; break; @@ -415,14 +412,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } 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(), + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_, svc->uuid.to_string().c_str()); - ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, - this->address_str_.c_str(), svc->start_handle, svc->end_handle); + ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_, + svc->start_handle, svc->end_handle); } #endif } - ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_); this->state_ = espbt::ClientState::ESTABLISHED; break; } @@ -503,7 +500,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ 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); + ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_, event); break; } return true; @@ -520,7 +517,7 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ case ESP_GAP_BLE_SEC_REQ_EVT: if (!this->check_addr(param->ble_security.auth_cmpl.bd_addr)) return; - ESP_LOGV(TAG, "[%d] [%s] ESP_GAP_BLE_SEC_REQ_EVT %x", this->connection_index_, this->address_str_.c_str(), event); + ESP_LOGV(TAG, "[%d] [%s] ESP_GAP_BLE_SEC_REQ_EVT %x", this->connection_index_, this->address_str_, event); esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); break; // This event is sent once authentication has completed @@ -529,13 +526,13 @@ 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 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_, format_hex(bd_addr, 6).c_str()); if (!param->ble_security.auth_cmpl.success) { this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { this->paired_ = true; - ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_, param->ble_security.auth_cmpl.addr_type, param->ble_security.auth_cmpl.auth_mode); } break; @@ -598,7 +595,7 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { } } ESP_LOGW(TAG, "[%d] [%s] Cannot parse characteristic value of type 0x%x length %d", this->connection_index_, - this->address_str_.c_str(), value[0], length); + this->address_str_, value[0], length); return NAN; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 7f0ae3b83e..7786495915 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -10,7 +10,6 @@ #endif #include -#include #include #include @@ -23,6 +22,7 @@ namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; static const int UNSET_CONN_ID = 0xFFFF; +static constexpr size_t MAC_ADDR_STR_LEN = 18; // "AA:BB:CC:DD:EE:FF\0" class BLEClientBase : public espbt::ESPBTClient, public Component { public: @@ -58,14 +58,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { this->remote_bda_[4] = (address >> 8) & 0xFF; this->remote_bda_[5] = (address >> 0) & 0xFF; if (address == 0) { - this->address_str_ = ""; + this->address_str_[0] = '\0'; } else { - char buf[18]; - format_mac_addr_upper(this->remote_bda_, buf); - this->address_str_ = buf; + format_mac_addr_upper(this->remote_bda_, this->address_str_); } } - const std::string &address_str() const { return this->address_str_; } + const char *address_str() const { return this->address_str_; } #ifdef USE_ESP32_BLE_DEVICE BLEService *get_service(espbt::ESPBTUUID uuid); @@ -104,7 +102,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint64_t address_{0}; // Group 2: Container types (grouped for memory optimization) - std::string address_str_{}; #ifdef USE_ESP32_BLE_DEVICE std::vector services_; #endif @@ -113,8 +110,9 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { int gattc_if_; esp_gatt_status_t status_{ESP_GATT_OK}; - // Group 4: Arrays (6 bytes) - esp_bd_addr_t remote_bda_; + // Group 4: Arrays + char address_str_[MAC_ADDR_STR_LEN]{}; // 18 bytes: "AA:BB:CC:DD:EE:FF\0" + esp_bd_addr_t remote_bda_; // 6 bytes // Group 5: 2-byte types uint16_t conn_id_{UNSET_CONN_ID}; diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index accaad15e1..deaaa3de02 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -51,7 +51,7 @@ void BLEService::parse_characteristics() { } if (status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->client->get_connection_index(), - this->client->address_str().c_str(), status); + this->client->address_str(), status); break; } if (count == 0) { @@ -65,7 +65,7 @@ void BLEService::parse_characteristics() { characteristic->service = this; this->characteristics.push_back(characteristic); ESP_LOGV(TAG, "[%d] [%s] characteristic %s, handle 0x%x, properties 0x%x", this->client->get_connection_index(), - this->client->address_str().c_str(), characteristic->uuid.to_string().c_str(), characteristic->handle, + this->client->address_str(), characteristic->uuid.to_string().c_str(), characteristic->handle, characteristic->properties); offset++; } diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index b6916ad68f..8436633619 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -14,7 +14,7 @@ void PVVXDisplay::dump_config() { " Service UUID : %s\n" " Characteristic UUID : %s\n" " Auto clear : %s", - this->parent_->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent_->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str(), YESNO(this->auto_clear_enabled_)); #ifdef USE_TIME ESP_LOGCONFIG(TAG, " Set time on connection: %s", YESNO(this->time_ != nullptr)); @@ -28,12 +28,12 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t switch (event) { case ESP_GATTC_OPEN_EVT: if (param->open.status == ESP_GATT_OK) { - ESP_LOGV(TAG, "[%s] Connected successfully!", this->parent_->address_str().c_str()); + ESP_LOGV(TAG, "[%s] Connected successfully!", this->parent_->address_str()); this->delayed_disconnect_(); } break; case ESP_GATTC_DISCONNECT_EVT: - ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str().c_str()); + ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str()); this->connection_established_ = false; this->cancel_timeout("disconnect"); this->char_handle_ = 0; @@ -41,7 +41,7 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t case ESP_GATTC_SEARCH_CMPL_EVT: { auto *chr = this->parent_->get_characteristic(this->service_uuid_, this->char_uuid_); if (chr == nullptr) { - ESP_LOGW(TAG, "[%s] Characteristic not found.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Characteristic not found.", this->parent_->address_str()); break; } this->connection_established_ = true; @@ -66,11 +66,11 @@ void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb return; if (param->ble_security.auth_cmpl.success) { - ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_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()); + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str()); } break; } @@ -89,22 +89,20 @@ void PVVXDisplay::update() { void PVVXDisplay::display() { if (!this->parent_->enabled) { - ESP_LOGD(TAG, "[%s] BLE client not enabled. Init connection.", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] BLE client not enabled. Init connection.", this->parent_->address_str()); this->parent_->set_enabled(true); return; } if (!this->connection_established_) { - ESP_LOGW(TAG, "[%s] Not connected to BLE client. State update can not be written.", - this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Not connected to BLE client. State update can not be written.", this->parent_->address_str()); return; } if (!this->char_handle_) { - ESP_LOGW(TAG, "[%s] No ble handle to BLE client. State update can not be written.", - this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] No ble handle to BLE client. State update can not be written.", this->parent_->address_str()); return; } ESP_LOGD(TAG, "[%s] Send to display: bignum %d, smallnum: %d, cfg: 0x%02x, validity period: %u.", - this->parent_->address_str().c_str(), this->bignum_, this->smallnum_, this->cfg_, this->validity_period_); + this->parent_->address_str(), this->bignum_, this->smallnum_, this->cfg_, this->validity_period_); uint8_t blk[8] = {}; blk[0] = 0x22; blk[1] = this->bignum_ & 0xff; @@ -128,16 +126,16 @@ void PVVXDisplay::setcfgbit_(uint8_t bit, bool value) { void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { if (!this->connection_established_) { - ESP_LOGW(TAG, "[%s] Not connected to BLE client.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Not connected to BLE client.", this->parent_->address_str()); return; } auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, size, blk, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } else { - ESP_LOGV(TAG, "[%s] send %u bytes", this->parent_->address_str().c_str(), size); + ESP_LOGV(TAG, "[%s] send %u bytes", this->parent_->address_str(), size); this->delayed_disconnect_(); } } @@ -161,21 +159,21 @@ void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) return; if (!this->connection_established_) { - ESP_LOGW(TAG, "[%s] Not connected to BLE client. Time can not be synced.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Not connected to BLE client. Time can not be synced.", this->parent_->address_str()); return; } if (!this->char_handle_) { - ESP_LOGW(TAG, "[%s] No ble handle to BLE client. Time can not be synced.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] No ble handle to BLE client. Time can not be synced.", this->parent_->address_str()); return; } auto time = this->time_->now(); if (!time.is_valid()) { - ESP_LOGW(TAG, "[%s] Time is not yet valid. Time can not be synced.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Time is not yet valid. Time can not be synced.", this->parent_->address_str()); return; } time.recalc_timestamp_utc(true); // calculate timestamp of local time uint8_t blk[5] = {}; - ESP_LOGD(TAG, "[%s] Sync time with timestamp %" PRIu64 ".", this->parent_->address_str().c_str(), time.timestamp); + ESP_LOGD(TAG, "[%s] Sync time with timestamp %" PRIu64 ".", this->parent_->address_str(), time.timestamp); blk[0] = 0x23; blk[1] = time.timestamp & 0xff; blk[2] = (time.timestamp >> 8) & 0xff; From d443dbbf344626357cb2321c84cf189f0fc75bef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Nov 2025 19:42:09 -0600 Subject: [PATCH 143/320] [lvgl] Fix lambda return types for coord and font validators (#12113) --- esphome/components/lvgl/lv_validation.py | 6 ++++-- esphome/components/lvgl/widgets/line.py | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 23c322c31f..9c1dd22085 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -40,7 +40,7 @@ from .helpers import ( lv_fonts_used, requires_component, ) -from .types import lv_font_t, lv_gradient_t +from .types import lv_gradient_t opacity_consts = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -498,7 +498,9 @@ class LvFont(LValidator): esphome_fonts_used.add(fontval) return requires_component("font")(fontval) - super().__init__(validator, lv_font_t) + # Use font::Font* as return type for lambdas returning ESPHome fonts + # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* + super().__init__(validator, Font.operator("ptr")) async def process(self, value, args=()): if is_lv_font(value): diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index bd90edbefc..57cb965737 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -6,7 +6,7 @@ from esphome.core import Lambda from ..defines import CONF_MAIN, call_lambda from ..lvcode import lv_add from ..schemas import point_schema -from ..types import LvCompound, LvType +from ..types import LvCompound, LvType, lv_coord_t from . import Widget, WidgetType CONF_LINE = "line" @@ -23,9 +23,7 @@ LINE_SCHEMA = { async def process_coord(coord): if isinstance(coord, Lambda): - coord = call_lambda( - await cg.process_lambda(coord, [], return_type="lv_coord_t") - ) + coord = call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) if not coord.endswith("()"): coord = f"static_cast({coord})" return cg.RawExpression(coord) From f071b6232a2f9cd043b4870384a9070019221b2d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:47:27 +1000 Subject: [PATCH 144/320] [lvgl] Fix position of errors in widget config (#12111) Co-authored-by: J. Nick Koston --- esphome/components/lvgl/schemas.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 6b77f66abb..b2d463c5fd 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,6 +1,7 @@ from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation from esphome.components.time import RealTimeClock +from esphome.config_validation import prepend_path from esphome.const import ( CONF_ARGS, CONF_FORMAT, @@ -422,7 +423,10 @@ def any_widget_schema(extras=None): def validator(value): if isinstance(value, dict): # Convert to list + is_dict = True value = [{k: v} for k, v in value.items()] + else: + is_dict = False if not isinstance(value, list): raise cv.Invalid("Expected a list of widgets") result = [] @@ -443,7 +447,9 @@ def any_widget_schema(extras=None): ) # Apply custom validation value = widget_type.validate(value or {}) - result.append({key: container_validator(value)}) + path = [key] if is_dict else [index, key] + with prepend_path(path): + result.append({key: container_validator(value)}) return result return validator From e071380532a3082ac3ba7de0c52aae5250720226 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:49:47 +1000 Subject: [PATCH 145/320] [lvgl] Add missing obj scroll properties (#11901) Co-authored-by: J. Nick Koston --- esphome/components/lvgl/defines.py | 5 +++++ esphome/components/lvgl/schemas.py | 19 ++++++++++++++++++- esphome/components/lvgl/widgets/__init__.py | 6 +++--- tests/components/lvgl/lvgl-package.yaml | 5 +++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index f2bcb6cc06..6b3b9c97ef 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -279,6 +279,8 @@ KEYBOARD_MODES = LvConstant( ) ROLLER_MODES = LvConstant("LV_ROLLER_MODE_", "NORMAL", "INFINITE") TILE_DIRECTIONS = DIRECTIONS.extend("HOR", "VER", "ALL") +SCROLL_DIRECTIONS = TILE_DIRECTIONS.extend("NONE") +SNAP_DIRECTIONS = LvConstant("LV_SCROLL_SNAP_", "NONE", "START", "END", "CENTER") CHILD_ALIGNMENTS = LvConstant( "LV_ALIGN_", "TOP_LEFT", @@ -511,6 +513,9 @@ CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" +CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLL_SNAP_X = "scroll_snap_x" +CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index b2d463c5fd..f2704f99de 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -20,7 +20,14 @@ from esphome.core import TimePeriod from esphome.core.config import StartupTrigger from . import defines as df, lv_validation as lvalid -from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR +from .defines import ( + CONF_SCROLL_DIR, + CONF_SCROLL_SNAP_X, + CONF_SCROLL_SNAP_Y, + CONF_SCROLLBAR_MODE, + CONF_TIME_FORMAT, + LV_GRAD_DIR, +) from .helpers import CONF_IF_NAN, requires_component, validate_printf from .layout import ( FLEX_OBJ_SCHEMA, @@ -234,9 +241,19 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant( "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO" ).one_of, + cv.Optional(CONF_SCROLL_DIR): df.SCROLL_DIRECTIONS.one_of, + cv.Optional(CONF_SCROLL_SNAP_X): df.SNAP_DIRECTIONS.one_of, + cv.Optional(CONF_SCROLL_SNAP_Y): df.SNAP_DIRECTIONS.one_of, } ) +OBJ_PROPERTIES = { + CONF_SCROLL_SNAP_X, + CONF_SCROLL_SNAP_Y, + CONF_SCROLL_DIR, + CONF_SCROLLBAR_MODE, +} + # Also allow widget specific properties for use in style definitions FULL_STYLE_SCHEMA = STYLE_SCHEMA.extend( { diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 187b5828c2..2e7948522e 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -21,7 +21,6 @@ from ..defines import ( CONF_MAIN, CONF_PAD_COLUMN, CONF_PAD_ROW, - CONF_SCROLLBAR_MODE, CONF_STYLES, CONF_WIDGETS, OBJ_FLAGS, @@ -45,7 +44,7 @@ from ..lvcode import ( lv_obj, lv_Pvariable, ) -from ..schemas import ALL_STYLES, STYLE_REMAP, WIDGET_TYPES +from ..schemas import ALL_STYLES, OBJ_PROPERTIES, STYLE_REMAP, WIDGET_TYPES from ..types import LV_STATE, LvType, WidgetType, lv_coord_t, lv_obj_t, lv_obj_t_ptr EVENT_LAMB = "event_lamb__" @@ -414,7 +413,8 @@ async def set_obj_properties(w: Widget, config): w.add_state(state) cond.else_() w.clear_state(state) - await w.set_property(CONF_SCROLLBAR_MODE, config, lv_name="obj") + for property in OBJ_PROPERTIES: + await w.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 708dfa2cb1..eddcbe9fd5 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -537,6 +537,9 @@ lvgl: - tileview: id: tileview_id scrollbar_mode: active + scroll_dir: all + scroll_elastic: true + scroll_momentum: true on_value: then: - if: @@ -546,6 +549,8 @@ lvgl: - logger.log: "tile 1 is now showing" tiles: - id: tile_1 + scroll_snap_y: center + scroll_snap_x: start layout: vertical row: 0 column: 0 From 1207b9e99532733cb77597384c47876c3358a1f4 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:53:51 +1000 Subject: [PATCH 146/320] [lvgl] Automatically pad rows and columns (#11879) Co-authored-by: J. Nick Koston --- esphome/components/lvgl/layout.py | 6 +++++- tests/components/lvgl/lvgl-package.yaml | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index caa503ef0d..b27a0b54a2 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -172,10 +172,14 @@ class DirectionalLayout(FlexLayout): def validate(self, config): assert config[CONF_LAYOUT].lower() == self.direction - config[CONF_LAYOUT] = { + layout = { **FLEX_HV_STYLE, CONF_FLEX_FLOW: "LV_FLEX_FLOW_" + self.flow.upper(), } + if pad_all := config.get("pad_all"): + layout[CONF_PAD_ROW] = pad_all + layout[CONF_PAD_COLUMN] = pad_all + config[CONF_LAYOUT] = layout return config diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index eddcbe9fd5..30866a603c 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -552,6 +552,7 @@ lvgl: scroll_snap_y: center scroll_snap_x: start layout: vertical + pad_all: 6px row: 0 column: 0 dir: ALL @@ -1049,6 +1050,7 @@ lvgl: opa: 0% - id: page3 layout: Horizontal + pad_all: 6px widgets: - keyboard: id: lv_keyboard From b328758634676b84839f13ad2f70469351b976dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Nov 2025 10:53:44 -0600 Subject: [PATCH 147/320] Revert "[core] Deduplicate identical stateless lambdas to reduce flash usage" (#12117) --- esphome/cpp_generator.py | 177 +-------------- tests/component_tests/text/test_text.py | 19 +- tests/unit_tests/test_lambda_dedup.py | 286 ------------------------ 3 files changed, 11 insertions(+), 471 deletions(-) delete mode 100644 tests/unit_tests/test_lambda_dedup.py diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 4f91696ca1..6f1af01a5b 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,21 +19,11 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.coroutine import CoroPriority, coroutine_with_priority from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase -# Keys for lambda deduplication storage in CORE.data -_KEY_LAMBDA_DEDUP = "lambda_dedup" -_KEY_LAMBDA_DEDUP_DECLARATIONS = "lambda_dedup_declarations" - -# Regex patterns for static variable detection (compiled once) -_RE_CPP_SINGLE_LINE_COMMENT = re.compile(r"//.*?$", re.MULTILINE) -_RE_CPP_MULTI_LINE_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) -_RE_STATIC_VARIABLE = re.compile(r"\bstatic\s+(?!cast|assert|pointer_cast)\w+\s+\w+") - class RawExpression(Expression): __slots__ = ("text",) @@ -198,7 +188,7 @@ class LambdaExpression(Expression): def __init__( self, parts, parameters, capture: str = "=", return_type=None, source=None - ) -> None: + ): self.parts = parts if not isinstance(parameters, ParameterListExpression): parameters = ParameterListExpression(*parameters) @@ -207,21 +197,16 @@ class LambdaExpression(Expression): self.capture = capture self.return_type = safe_exp(return_type) if return_type is not None else None - def format_body(self) -> str: - """Format the lambda body with source directive and content.""" - body = "" - if self.source is not None: - body += f"{self.source.as_line_directive}\n" - body += self.content - return body - - def __str__(self) -> str: + def __str__(self): # Stateless lambdas (empty capture) implicitly convert to function pointers # when assigned to function pointer types - no unary + needed cpp = f"[{self.capture}]({self.parameters})" if self.return_type is not None: cpp += f" -> {self.return_type}" - cpp += f" {{\n{self.format_body()}\n}}" + cpp += " {\n" + if self.source is not None: + cpp += f"{self.source.as_line_directive}\n" + cpp += f"{self.content}\n}}" return indent_all_but_first_and_last(cpp) @property @@ -229,37 +214,6 @@ class LambdaExpression(Expression): return "".join(str(part) for part in self.parts) -class SharedFunctionLambdaExpression(LambdaExpression): - """A lambda expression that references a shared deduplicated function. - - This class wraps a function pointer but maintains the LambdaExpression - interface so calling code works unchanged. - """ - - __slots__ = ("_func_name",) - - def __init__( - self, - func_name: str, - parameters: TemplateArgsType, - return_type: SafeExpType | None = None, - ) -> None: - # Initialize parent with empty parts since we're just a function reference - super().__init__( - [], parameters, capture="", return_type=return_type, source=None - ) - self._func_name = func_name - - def __str__(self) -> str: - # Just return the function name - it's already a function pointer - return self._func_name - - @property - def content(self) -> str: - # No content, just a function reference - return "" - - # pylint: disable=abstract-method class Literal(Expression, metaclass=abc.ABCMeta): __slots__ = () @@ -629,25 +583,6 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False): CORE.add_global(expression, prepend) -@coroutine_with_priority(CoroPriority.FINAL) -async def flush_lambda_dedup_declarations() -> None: - """Flush all deferred lambda deduplication declarations to global scope. - - This is a coroutine that runs with FINAL priority (after all components) - to ensure all referenced variables are declared before the shared - lambda functions that use them. - """ - if _KEY_LAMBDA_DEDUP_DECLARATIONS not in CORE.data: - return - - declarations = CORE.data[_KEY_LAMBDA_DEDUP_DECLARATIONS] - for func_declaration in declarations: - add_global(RawStatement(func_declaration)) - - # Clear the list so we don't add them again - CORE.data[_KEY_LAMBDA_DEDUP_DECLARATIONS] = [] - - def add_library(name: str, version: str | None, repository: str | None = None): """Add a library to the codegen library storage. @@ -721,93 +656,6 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]: return await CORE.get_variable_with_full_id(id_) -def _has_static_variables(code: str) -> bool: - """Check if code contains static variable definitions. - - Static variables in lambdas should not be deduplicated because each lambda - instance should have its own static variable state. - - Args: - code: The lambda body code to check - - Returns: - True if code contains static variable definitions - """ - # Remove C++ comments to avoid false positives - # Remove single-line comments (// ...) - code_no_comments = _RE_CPP_SINGLE_LINE_COMMENT.sub("", code) - # Remove multi-line comments (/* ... */) - code_no_comments = _RE_CPP_MULTI_LINE_COMMENT.sub("", code_no_comments) - - # Match: static - # But not: static_cast, static_assert, static_pointer_cast - return bool(_RE_STATIC_VARIABLE.search(code_no_comments)) - - -def _get_shared_lambda_name(lambda_expr: LambdaExpression) -> str | None: - """Get the shared function name for a lambda expression. - - If an identical lambda was already generated, returns the existing shared - function name. Otherwise, creates a new shared function and returns its name. - - Lambdas with static variables are not deduplicated to preserve their - independent state. - - Args: - lambda_expr: The lambda expression to deduplicate - - Returns: - The name of the shared function for this lambda (either existing or newly created), - or None if the lambda should not be deduplicated (e.g., contains static variables) - """ - # Create a unique key from the lambda content, parameters, and return type - content = lambda_expr.content - - # Don't deduplicate lambdas with static variables - each instance needs its own state - if _has_static_variables(content): - return None - param_str = str(lambda_expr.parameters) - return_str = ( - str(lambda_expr.return_type) if lambda_expr.return_type is not None else "void" - ) - - # Use tuple of (content, params, return_type) as key - lambda_key = (content, param_str, return_str) - - # Initialize deduplication storage in CORE.data if not exists - if _KEY_LAMBDA_DEDUP not in CORE.data: - CORE.data[_KEY_LAMBDA_DEDUP] = {} - # Register the flush job to run after all components (FINAL priority) - # This ensures all variables are declared before shared lambda functions - CORE.add_job(flush_lambda_dedup_declarations) - - lambda_cache = CORE.data[_KEY_LAMBDA_DEDUP] - - # Check if we've seen this lambda before - if lambda_key in lambda_cache: - # Return name of existing shared function - return lambda_cache[lambda_key] - - # First occurrence - create a shared function - # Use the cache size as the function number - func_name = f"shared_lambda_{len(lambda_cache)}" - - # Build the function declaration using lambda's body formatting - func_declaration = ( - f"{return_str} {func_name}({param_str}) {{\n{lambda_expr.format_body()}\n}}" - ) - - # Store the declaration to be added later (after all variable declarations) - # We can't add it immediately because it might reference variables not yet declared - CORE.data.setdefault(_KEY_LAMBDA_DEDUP_DECLARATIONS, []).append(func_declaration) - - # Store in cache - lambda_cache[lambda_key] = func_name - - # Return the function name (this is the first occurrence, but we still generate shared function) - return func_name - - async def process_lambda( value: Lambda, parameters: TemplateArgsType, @@ -865,19 +713,6 @@ async def process_lambda( location.line += value.content_offset else: location = None - - # Lambda deduplication: Only deduplicate stateless lambdas (empty capture). - # Stateful lambdas cannot be shared as they capture different contexts. - # Lambdas with static variables are also not deduplicated to preserve independent state. - if capture == "": - lambda_expr = LambdaExpression( - parts, parameters, capture, return_type, location - ) - func_name = _get_shared_lambda_name(lambda_expr) - if func_name is not None: - # Return a shared function reference instead of inline lambda - return SharedFunctionLambdaExpression(func_name, parameters, return_type) - return LambdaExpression(parts, parameters, capture, return_type, location) diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 56dee205b4..bfc3131f6d 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -1,6 +1,4 @@ -"""Tests for the text component.""" - -from esphome.core import CORE +"""Tests for the binary sensor component.""" def test_text_is_setup(generate_main): @@ -58,22 +56,15 @@ def test_text_config_value_mode_set(generate_main): assert "it_3->traits.set_mode(text::TEXT_MODE_PASSWORD);" in main_cpp -def test_text_config_lambda_is_set(generate_main) -> None: +def test_text_config_lamda_is_set(generate_main): """ - Test if lambda is set for lambda mode (optimized with stateless lambda and deduplication) + Test if lambda is set for lambda mode (optimized with stateless lambda) """ # Given # When main_cpp = generate_main("tests/component_tests/text/test_text.yaml") - # Get both global and main sections to find the shared lambda definition - full_cpp = CORE.cpp_global_section + main_cpp - # Then - # Lambda is deduplicated into a shared function (reference in main section) - assert "it_4->set_template(shared_lambda_" in main_cpp - # Lambda body should be in the code somewhere - assert 'return std::string{"Hello"};' in full_cpp - # Verify the shared lambda function is defined (in global section) - assert "esphome::optional shared_lambda_" in full_cpp + assert "it_4->set_template([]() -> esphome::optional {" in main_cpp + assert 'return std::string{"Hello"};' in main_cpp diff --git a/tests/unit_tests/test_lambda_dedup.py b/tests/unit_tests/test_lambda_dedup.py deleted file mode 100644 index bbf5f02e6d..0000000000 --- a/tests/unit_tests/test_lambda_dedup.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Tests for lambda deduplication in cpp_generator.""" - -from esphome import cpp_generator as cg -from esphome.core import CORE - - -def test_deduplicate_identical_lambdas() -> None: - """Test that identical stateless lambdas are deduplicated.""" - # Create two identical lambda expressions - lambda1 = cg.LambdaExpression( - parts=["return 42;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - lambda2 = cg.LambdaExpression( - parts=["return 42;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - # Try to deduplicate them - func_name1 = cg._get_shared_lambda_name(lambda1) - func_name2 = cg._get_shared_lambda_name(lambda2) - - # Both should get the same function name (deduplication happened) - assert func_name1 == func_name2 - assert func_name1 == "shared_lambda_0" - - -def test_different_lambdas_not_deduplicated() -> None: - """Test that different lambdas get different function names.""" - lambda1 = cg.LambdaExpression( - parts=["return 42;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - lambda2 = cg.LambdaExpression( - parts=["return 24;"], # Different content - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - func_name1 = cg._get_shared_lambda_name(lambda1) - func_name2 = cg._get_shared_lambda_name(lambda2) - - # Different lambdas should get different function names - assert func_name1 != func_name2 - assert func_name1 == "shared_lambda_0" - assert func_name2 == "shared_lambda_1" - - -def test_different_return_types_not_deduplicated() -> None: - """Test that lambdas with different return types are not deduplicated.""" - lambda1 = cg.LambdaExpression( - parts=["return 42;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - lambda2 = cg.LambdaExpression( - parts=["return 42;"], # Same content - parameters=[], - capture="", - return_type=cg.RawExpression("float"), # Different return type - ) - - func_name1 = cg._get_shared_lambda_name(lambda1) - func_name2 = cg._get_shared_lambda_name(lambda2) - - # Different return types = different functions - assert func_name1 != func_name2 - - -def test_different_parameters_not_deduplicated() -> None: - """Test that lambdas with different parameters are not deduplicated.""" - lambda1 = cg.LambdaExpression( - parts=["return x;"], - parameters=[("int", "x")], - capture="", - return_type=cg.RawExpression("int"), - ) - - lambda2 = cg.LambdaExpression( - parts=["return x;"], # Same content - parameters=[("float", "x")], # Different parameter type - capture="", - return_type=cg.RawExpression("int"), - ) - - func_name1 = cg._get_shared_lambda_name(lambda1) - func_name2 = cg._get_shared_lambda_name(lambda2) - - # Different parameters = different functions - assert func_name1 != func_name2 - - -def test_flush_lambda_dedup_declarations() -> None: - """Test that deferred declarations are properly stored for later flushing.""" - # Create a lambda which will create a deferred declaration - lambda1 = cg.LambdaExpression( - parts=["return 42;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - cg._get_shared_lambda_name(lambda1) - - # Check that declaration was stored - assert cg._KEY_LAMBDA_DEDUP_DECLARATIONS in CORE.data - assert len(CORE.data[cg._KEY_LAMBDA_DEDUP_DECLARATIONS]) == 1 - - # Verify the declaration content is correct - declaration = CORE.data[cg._KEY_LAMBDA_DEDUP_DECLARATIONS][0] - assert "shared_lambda_0" in declaration - assert "return 42;" in declaration - - # Note: The actual flushing happens via CORE.add_job with FINAL priority - # during real code generation, so we don't test that here - - -def test_shared_function_lambda_expression() -> None: - """Test SharedFunctionLambdaExpression behaves correctly.""" - shared_lambda = cg.SharedFunctionLambdaExpression( - func_name="shared_lambda_0", - parameters=[], - return_type=cg.RawExpression("int"), - ) - - # Should output just the function name - assert str(shared_lambda) == "shared_lambda_0" - - # Should have empty capture (stateless) - assert shared_lambda.capture == "" - - # Should have empty content (just a reference) - assert shared_lambda.content == "" - - -def test_lambda_deduplication_counter() -> None: - """Test that lambda counter increments correctly.""" - # Create 3 different lambdas - for i in range(3): - lambda_expr = cg.LambdaExpression( - parts=[f"return {i};"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - func_name = cg._get_shared_lambda_name(lambda_expr) - assert func_name == f"shared_lambda_{i}" - - -def test_lambda_format_body() -> None: - """Test that format_body correctly formats lambda body with source.""" - # Without source - lambda1 = cg.LambdaExpression( - parts=["return 42;"], - parameters=[], - capture="", - return_type=None, - source=None, - ) - assert lambda1.format_body() == "return 42;" - - # With source would need a proper source object, skip for now - - -def test_stateful_lambdas_not_deduplicated() -> None: - """Test that stateful lambdas (non-empty capture) are not deduplicated.""" - # _get_shared_lambda_name is only called for stateless lambdas (capture == "") - # Stateful lambdas bypass deduplication entirely in process_lambda - - # Verify that a stateful lambda would NOT get deduplicated - # by checking it's not in the stateless dedup cache - stateful_lambda = cg.LambdaExpression( - parts=["return x + y;"], - parameters=[], - capture="=", # Non-empty capture means stateful - return_type=cg.RawExpression("int"), - ) - - # Stateful lambdas should NOT be passed to _get_shared_lambda_name - # This is enforced by the `if capture == ""` check in process_lambda - # We verify the lambda has a non-empty capture - assert stateful_lambda.capture != "" - assert stateful_lambda.capture == "=" - - -def test_static_variable_detection() -> None: - """Test detection of static variables in lambda code.""" - # Should detect static variables - assert cg._has_static_variables("static int counter = 0;") - assert cg._has_static_variables("static bool flag = false; return flag;") - assert cg._has_static_variables(" static float value = 1.0; ") - - # Should NOT detect static_cast, static_assert, etc. (with underscores) - assert not cg._has_static_variables("return static_cast(value);") - assert not cg._has_static_variables("static_assert(sizeof(int) == 4);") - assert not cg._has_static_variables("auto ptr = static_pointer_cast(bar);") - - # Edge case: 'cast', 'assert', 'pointer_cast' are NOT C++ keywords - # Someone could use them as type names, but we should NOT flag them - # because they're not actually static variables with state - # NOTE: These are valid C++ but extremely unlikely in ESPHome lambdas - assert not cg._has_static_variables("static cast obj;") # 'cast' as type name - assert not cg._has_static_variables("static assert value;") # 'assert' as type name - assert not cg._has_static_variables( - "static pointer_cast ptr;" - ) # 'pointer_cast' as type - - # Should NOT detect in comments - assert not cg._has_static_variables("// static int x = 0;\nreturn 42;") - assert not cg._has_static_variables("/* static int y = 0; */ return 42;") - - # Should detect even with comments elsewhere - assert cg._has_static_variables("// comment\nstatic int x = 0;\nreturn x;") - - # Should NOT detect non-static code - assert not cg._has_static_variables("int counter = 0; return counter++;") - assert not cg._has_static_variables("return 42;") - - # Should handle newlines between static and type/variable - assert cg._has_static_variables("static int\nfoo = 0;") - assert cg._has_static_variables("static\nint\nbar = 0;") - assert cg._has_static_variables( - "static int \n foo = 0;" - ) # Mixed spaces/newlines - - -def test_lambdas_with_static_not_deduplicated() -> None: - """Test that lambdas with static variables are not deduplicated.""" - # Two identical lambdas with static variables - lambda1 = cg.LambdaExpression( - parts=["static int counter = 0; return counter++;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - lambda2 = cg.LambdaExpression( - parts=["static int counter = 0; return counter++;"], - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - # Should return None (not deduplicated) - func_name1 = cg._get_shared_lambda_name(lambda1) - func_name2 = cg._get_shared_lambda_name(lambda2) - - assert func_name1 is None - assert func_name2 is None - - -def test_lambdas_without_static_still_deduplicated() -> None: - """Test that lambdas without static variables are still deduplicated.""" - # Two identical lambdas WITHOUT static variables - lambda1 = cg.LambdaExpression( - parts=["int counter = 0; return counter++;"], # No static - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - lambda2 = cg.LambdaExpression( - parts=["int counter = 0; return counter++;"], # No static - parameters=[], - capture="", - return_type=cg.RawExpression("int"), - ) - - # Should be deduplicated (same function name) - func_name1 = cg._get_shared_lambda_name(lambda1) - func_name2 = cg._get_shared_lambda_name(lambda2) - - assert func_name1 is not None - assert func_name2 is not None - assert func_name1 == func_name2 From 12a51ff047641d7ecdc3ec1a5b532ba8bdf5e49a Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 26 Nov 2025 18:00:44 +0100 Subject: [PATCH 148/320] [packages] Fix package schema validation (#12116) Co-authored-by: J. Nick Koston --- esphome/components/packages/__init__.py | 70 ++++++++++++++----- .../component_tests/packages/test_packages.py | 39 +++++++++-- 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 04057c07f2..41cde0391b 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path from esphome import git, yaml_util @@ -20,18 +21,41 @@ from esphome.const import ( ) from esphome.core import EsphomeError +_LOGGER = logging.getLogger(__name__) + DOMAIN = CONF_PACKAGES -def validate_git_package(config: dict): - if CONF_URL not in config: - return config - config = BASE_SCHEMA(config) - new_config = config +def valid_package_contents(package_config: dict): + """Validates that a package_config that will be merged looks as much as possible to a valid config + to fail early on obvious mistakes.""" + if isinstance(package_config, dict): + if CONF_URL in package_config: + # If a URL key is found, then make sure the config conforms to a remote package schema: + return REMOTE_PACKAGE_SCHEMA(package_config) + + # Validate manually since Voluptuous would regenerate dicts and lose metadata + # such as ESPHomeDataBase + for k, v in package_config.items(): + if not isinstance(k, str): + raise cv.Invalid("Package content keys must be strings") + if isinstance(v, (dict, list)): + continue # e.g. script: [] or logger: {level: debug} + if v is None: + continue # e.g. web_server: + raise cv.Invalid("Invalid component content in package definition") + return package_config + + raise cv.Invalid("Package contents must be a dict") + + +def expand_file_to_files(config: dict): if CONF_FILE in config: + new_config = config new_config[CONF_FILES] = [config[CONF_FILE]] del new_config[CONF_FILE] - return new_config + return new_config + return config def validate_yaml_filename(value): @@ -45,7 +69,7 @@ def validate_yaml_filename(value): def validate_source_shorthand(value): if not isinstance(value, str): - raise cv.Invalid("Shorthand only for strings") + raise cv.Invalid("Git URL shorthand only for strings") git_file = git.GitFile.from_shorthand(value) @@ -56,10 +80,17 @@ def validate_source_shorthand(value): if git_file.ref: conf[CONF_REF] = git_file.ref - return BASE_SCHEMA(conf) + return REMOTE_PACKAGE_SCHEMA(conf) -BASE_SCHEMA = cv.All( +def deprecate_single_package(config): + _LOGGER.warning( + "Including a single package under `packages:` is deprecated. Use a list instead." + ) + return config + + +REMOTE_PACKAGE_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_URL): cv.url, @@ -90,23 +121,30 @@ BASE_SCHEMA = cv.All( } ), cv.has_at_least_one_key(CONF_FILE, CONF_FILES), + expand_file_to_files, ) -PACKAGE_SCHEMA = cv.All( - cv.Any(validate_source_shorthand, BASE_SCHEMA, dict), validate_git_package +PACKAGE_SCHEMA = cv.Any( # A package definition is either: + validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or + REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or + valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} + # which will have to be fully validated later as per each component's schema. ) -CONFIG_SCHEMA = cv.Any( +CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: cv.Schema( { - str: PACKAGE_SCHEMA, + str: PACKAGE_SCHEMA, # a named dict of package definitions, or } ), - [PACKAGE_SCHEMA], + [PACKAGE_SCHEMA], # a list of package definitions, or + cv.All( # a single package definition (deprecated) + cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package + ), ) -def _process_base_package(config: dict, skip_update: bool = False) -> dict: +def _process_remote_package(config: dict, skip_update: bool = False) -> dict: # When skip_update is True, use NEVER_REFRESH to prevent updates actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( @@ -185,7 +223,7 @@ def _process_base_package(config: dict, skip_update: bool = False) -> dict: def _process_package(package_config, config, skip_update: bool = False): recursive_package = package_config if CONF_URL in package_config: - package_config = _process_base_package(package_config, skip_update) + package_config = _process_remote_package(package_config, skip_update) if isinstance(package_config, dict): recursive_package = do_packages_pass(package_config, skip_update) return merge_config(recursive_package, config) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 1c4c91aa52..ac4e211fe6 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -95,7 +95,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): @pytest.mark.parametrize( - "package", + "packages", [ {"package1": "github://esphome/non-existant-repo/file1.yml@main"}, {"package2": "github://esphome/non-existant-repo/file1.yml"}, @@ -107,12 +107,12 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): ], ], ) -def test_package_shorthand(package): - CONFIG_SCHEMA(package) +def test_package_shorthand(packages): + CONFIG_SCHEMA(packages) @pytest.mark.parametrize( - "package", + "packages", [ # not github {"package1": "someplace://esphome/non-existant-repo/file1.yml@main"}, @@ -133,9 +133,9 @@ def test_package_shorthand(package): [3], ], ) -def test_package_invalid(package): +def test_package_invalid(packages): with pytest.raises(cv.Invalid): - CONFIG_SCHEMA(package) + CONFIG_SCHEMA(packages) def test_package_include(basic_wifi, basic_esphome): @@ -155,6 +155,33 @@ def test_package_include(basic_wifi, basic_esphome): assert actual == expected +def test_single_package( + basic_esphome, + basic_wifi, + caplog: pytest.LogCaptureFixture, +): + """ + Tests the simple case where a single package is added to the top-level config as is. + In this test, the CONF_WIFI config is expected to be simply added to the top-level config. + This tests the case where the user just put packages: !include package.yaml, not + part of a list or mapping of packages. + This behavior is deprecated, the test also checks if a warning is issued. + """ + config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}} + + expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi} + + with caplog.at_level("WARNING"): + actual = packages_pass(config) + + assert actual == expected + + assert ( + "Including a single package under `packages:` is deprecated. Use a list instead." + in caplog.text + ) + + def test_package_append(basic_wifi, basic_esphome): """ Tests the case where a key is present in both a package and top-level config. From 083886c4b05a95aa117ba4dfafac17b45908b681 Mon Sep 17 00:00:00 2001 From: Pawelo <81100874+pgolawsk@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:06:51 +0100 Subject: [PATCH 149/320] [prometheus] Avoid generating unused light color metrics to reduce memory usage on ESP8266 (#9530) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../prometheus/prometheus_handler.cpp | 135 ++++++++---------- .../prometheus/prometheus_handler.h | 8 ++ tests/components/prometheus/common.yaml | 27 ++++ 3 files changed, 92 insertions(+), 78 deletions(-) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 812b547860..252b477400 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -141,6 +141,24 @@ void PrometheusHandler::add_friendly_name_label_(AsyncResponseStream *stream, st } } +#ifdef USE_ESP8266 +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, + EntityBase *obj, std::string &area, std::string &node, + std::string &friendly_name) { +#else +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, + std::string &area, std::string &node, std::string &friendly_name) { +#endif + stream->print(metric_name); + stream->print(ESPHOME_F("{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); +} + // Type-specific implementation #ifdef USE_SENSOR void PrometheusHandler::sensor_type_(AsyncResponseStream *stream) { @@ -303,13 +321,7 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat if (obj->is_internal() && !this->include_internal_) return; // State - stream->print(ESPHOME_F("esphome_light_state{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + print_metric_labels_(stream, ESPHOME_F("esphome_light_state"), obj, area, node, friendly_name); stream->print(ESPHOME_F("\"} ")); stream->print(obj->remote_values.is_on()); stream->print(ESPHOME_F("\n")); @@ -318,78 +330,45 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat float brightness, r, g, b, w; color.as_brightness(&brightness); color.as_rgbw(&r, &g, &b, &w); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"brightness\"} ")); - stream->print(brightness); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"r\"} ")); - stream->print(r); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"g\"} ")); - stream->print(g); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"b\"} ")); - stream->print(b); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"w\"} ")); - stream->print(w); - stream->print(ESPHOME_F("\n")); - // Effect - std::string effect = obj->get_effect_name(); - if (effect == "None") { - stream->print(ESPHOME_F("esphome_light_effect_active{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",effect=\"None\"} 0\n")); - } else { - stream->print(ESPHOME_F("esphome_light_effect_active{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + if (obj->get_traits().supports_color_capability(light::ColorCapability::BRIGHTNESS)) { + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"brightness\"} ")); + stream->print(brightness); + stream->print(ESPHOME_F("\n")); + } + if (obj->get_traits().supports_color_capability(light::ColorCapability::RGB)) { + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"r\"} ")); + stream->print(r); + stream->print(ESPHOME_F("\n")); + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"g\"} ")); + stream->print(g); + stream->print(ESPHOME_F("\n")); + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"b\"} ")); + stream->print(b); + stream->print(ESPHOME_F("\n")); + } + if (obj->get_traits().supports_color_capability(light::ColorCapability::WHITE)) { + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"w\"} ")); + stream->print(w); + stream->print(ESPHOME_F("\n")); + } + // Skip effect metrics if light has no effects + if (!obj->get_effects().empty()) { + // Effect + std::string effect = obj->get_effect_name(); + print_metric_labels_(stream, ESPHOME_F("esphome_light_effect_active"), obj, area, node, friendly_name); stream->print(ESPHOME_F("\",effect=\"")); - stream->print(effect.c_str()); - stream->print(ESPHOME_F("\"} 1\n")); + // Only vary based on effect + if (effect == "None") { + stream->print(ESPHOME_F("None\"} 0\n")); + } else { + stream->print(effect.c_str()); + stream->print(ESPHOME_F("\"} 1\n")); + } } } #endif diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 45cc81b899..24243c8c98 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -66,6 +66,14 @@ class PrometheusHandler : public AsyncWebHandler, public Component { void add_area_label_(AsyncResponseStream *stream, std::string &area); void add_node_label_(AsyncResponseStream *stream, std::string &node); void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name); + /// Print metric name and common labels (id, area, node, friendly_name, name) +#ifdef USE_ESP8266 + void print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, EntityBase *obj, + std::string &area, std::string &node, std::string &friendly_name); +#else + void print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, std::string &area, + std::string &node, std::string &friendly_name); +#endif #ifdef USE_SENSOR /// Return the type for prometheus diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index 0b90d614dd..7ff416dccb 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -112,6 +112,25 @@ cover: } return COVER_CLOSED; +light: + - platform: binary + name: "Binary Light" + output: test_output + - platform: monochromatic + name: "Brightness Light" + output: test_output + - platform: rgb + name: "RGB Light" + red: test_output + green: test_output + blue: test_output + - platform: rgbw + name: "RGBW Light" + red: test_output + green: test_output + blue: test_output + white: test_output + lock: - platform: template id: template_lock1 @@ -122,6 +141,14 @@ lock: return LOCK_STATE_UNLOCKED; optimistic: true +output: + - platform: template + id: test_output + type: float + write_action: + - lambda: |- + // no-op for CI/build tests + (void)state; select: - platform: template id: template_select1 From eb970cf44ec07ea45596f837c573e4fec7ff09d7 Mon Sep 17 00:00:00 2001 From: Jon Oberheide <506986+jonoberheide@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:56:22 -0500 Subject: [PATCH 150/320] make thermostat humidification_action public (#12132) --- esphome/components/thermostat/thermostat_climate.cpp | 8 ++++---- esphome/components/thermostat/thermostat_climate.h | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 2b51f58f4f..e79eed4055 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -654,7 +654,7 @@ void ThermostatClimate::trigger_supplemental_action_() { void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction action) { // setup_complete_ helps us ensure an action is called immediately after boot - if ((action == this->humidification_action_) && this->setup_complete_) { + if ((action == this->humidification_action) && this->setup_complete_) { // already in target mode return; } @@ -683,7 +683,7 @@ void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction this->prev_humidity_control_trigger_->stop_action(); this->prev_humidity_control_trigger_ = nullptr; } - this->humidification_action_ = action; + this->humidification_action = action; this->prev_humidity_control_trigger_ = trig; if (trig != nullptr) { trig->trigger(); @@ -1114,7 +1114,7 @@ bool ThermostatClimate::dehumidification_required_() { } // if we get here, the current humidity is between target + hysteresis and target - hysteresis, // so the action should not change - return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY; + return this->humidification_action == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY; } bool ThermostatClimate::humidification_required_() { @@ -1127,7 +1127,7 @@ bool ThermostatClimate::humidification_required_() { } // if we get here, the current humidity is between target - hysteresis and target + hysteresis, // so the action should not change - return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY; + return this->humidification_action == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY; } void ThermostatClimate::dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config) { diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 76391f800c..69d2307b1c 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -207,6 +207,9 @@ class ThermostatClimate : public climate::Climate, public Component { void validate_target_temperature_high(); void validate_target_humidity(); + /// The current humidification action + HumidificationAction humidification_action{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE}; + protected: /// Override control to change settings of the climate device. void control(const climate::ClimateCall &call) override; @@ -301,9 +304,6 @@ class ThermostatClimate : public climate::Climate, public Component { /// The current supplemental action climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF}; - /// The current humidification action - HumidificationAction humidification_action_{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE}; - /// Default standard preset to use on start up climate::ClimatePreset default_preset_{}; From caaa08d678f43ebcad29e837dfc25137f21d2776 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 27 Nov 2025 09:05:45 +1000 Subject: [PATCH 151/320] [core] Fix for missing arguments to shared_lambda (#12115) --- esphome/components/lvgl/widgets/__init__.py | 4 ++-- esphome/cpp_generator.py | 8 +------- tests/components/lvgl/lvgl-package.yaml | 12 ++++++++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 2e7948522e..b1d157325b 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -382,7 +382,7 @@ async def set_obj_properties(w: Widget, config): clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") w.clear_flag(clrs) for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], return_type=cg.bool_) + lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) flag = f"LV_OBJ_FLAG_{key.upper()}" with LvConditional(call_lambda(lamb)) as cond: w.add_flag(flag) @@ -407,7 +407,7 @@ async def set_obj_properties(w: Widget, config): clears = join_enums(clears, "LV_STATE_") w.clear_state(clears) for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], return_type=cg.bool_) + lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) state = f"LV_STATE_{key.upper()}" with LvConditional(call_lambda(lamb)) as cond: w.add_state(state) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6f1af01a5b..1a47b346b7 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -659,7 +659,7 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]: async def process_lambda( value: Lambda, parameters: TemplateArgsType, - capture: str = "=", + capture: str = "", return_type: SafeExpType = None, ) -> LambdaExpression | None: """Process the given lambda value into a LambdaExpression. @@ -702,12 +702,6 @@ async def process_lambda( parts[i * 3 + 1] = var parts[i * 3 + 2] = "" - # All id() references are global variables in generated C++ code. - # Global variables should not be captured - they're accessible everywhere. - # Use empty capture instead of capture-by-value. - if capture == "=": - capture = "" - if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: location = value.esp_range.start_mark location.line += value.content_offset diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 30866a603c..a5714d5639 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -16,6 +16,18 @@ binary_sensor: platform: template - id: left_sensor platform: template + - platform: lvgl + id: button_checker + name: LVGL button + widget: button_button + on_state: + then: + - lvgl.checkbox.update: + id: checkbox_id + state: + checked: !lambda |- + auto y = x; // block inlining of one line return + return y; lvgl: log_level: debug From a2d9941c622388f827570077dc6ab45d1d80112f Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 27 Nov 2025 09:06:32 +1000 Subject: [PATCH 152/320] [lvgl] Add option to sync updates with display (#11896) Co-authored-by: J. Nick Koston --- esphome/components/lvgl/__init__.py | 4 ++ esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvgl_esphome.cpp | 48 +++++++++++++++++++---- esphome/components/lvgl/lvgl_esphome.h | 13 +++--- tests/components/lvgl/test.esp32-idf.yaml | 1 + 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index eaa37b54dd..eeabf755a6 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -276,6 +276,7 @@ async def to_code(configs): config[df.CONF_FULL_REFRESH], config[CONF_DRAW_ROUNDING], config[df.CONF_RESUME_ON_INPUT], + config[df.CONF_UPDATE_WHEN_DISPLAY_IDLE], ) await cg.register_component(lv_component, config) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) @@ -373,6 +374,9 @@ LVGL_SCHEMA = cv.All( df.CONF_DEFAULT_FONT, default="montserrat_14" ): lvalid.lv_font, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional( + df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False + ): cv.boolean, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 6b3b9c97ef..1fce6fa458 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -542,6 +542,7 @@ CONF_TOUCHSCREENS = "touchscreens" CONF_TRANSPARENCY_KEY = "transparency_key" CONF_THEME = "theme" CONF_UPDATE_ON_RELEASE = "update_on_release" +CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle" CONF_VISIBLE_ROW_COUNT = "visible_row_count" CONF_WIDGET = "widget" CONF_WIDGETS = "widgets" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index fbcd68378c..18226a9f57 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -106,6 +106,7 @@ void LvglComponent::dump_config() { this->disp_drv_.hor_res, this->disp_drv_.ver_res, 100 / this->buffer_frac_, this->rotation, (int) this->draw_rounding); } + void LvglComponent::set_paused(bool paused, bool show_snow) { this->paused_ = paused; this->show_snow_ = show_snow; @@ -124,32 +125,38 @@ void LvglComponent::esphome_lvgl_init() { lv_update_event = static_cast(lv_event_register_id()); lv_api_event = static_cast(lv_event_register_id()); } + void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) { lv_obj_add_event_cb(obj, callback, event, nullptr); } + void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2) { add_event_cb(obj, callback, event1); add_event_cb(obj, callback, event2); } + void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3) { add_event_cb(obj, callback, event1); add_event_cb(obj, callback, event2); add_event_cb(obj, callback, event3); } + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); lv_disp_set_default(this->disp_); page->setup(this->pages_.size() - 1); } + void LvglComponent::show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time) { if (index >= this->pages_.size()) return; this->current_page_ = index; lv_scr_load_anim(this->pages_[this->current_page_]->obj, anim, time, 0, false); } + void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) { if (this->pages_.empty() || (this->current_page_ == this->pages_.size() - 1 && !this->page_wrap_)) return; @@ -158,6 +165,7 @@ void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) { } while (this->pages_[this->current_page_]->skip); // skip empty pages() this->show_page(this->current_page_, anim, time); } + void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) { if (this->pages_.empty() || (this->current_page_ == 0 && !this->page_wrap_)) return; @@ -166,8 +174,10 @@ void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) { } while (this->pages_[this->current_page_]->skip); // skip empty pages() this->show_page(this->current_page_, anim, time); } + size_t LvglComponent::get_current_page() const { return this->current_page_; } bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; } + void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { auto width = lv_area_get_width(area); auto height = lv_area_get_height(area); @@ -222,7 +232,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { } void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { - if (!this->paused_) { + if (!this->is_paused()) { auto now = millis(); this->draw_buffer_(area, color_p); ESP_LOGVV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", area->x1, area->y1, lv_area_get_width(area), @@ -230,6 +240,7 @@ void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv } lv_disp_flush_ready(disp_drv); } + IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeout) : timeout_(std::move(timeout)) { parent->add_on_idle_callback([this](uint32_t idle_time) { if (!this->is_idle_ && idle_time > this->timeout_.value()) { @@ -377,6 +388,27 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { } #endif // USE_LVGL_KEYBOARD +void LvglComponent::draw_end_() { + if (this->draw_end_callback_ != nullptr) + this->draw_end_callback_->trigger(); + if (this->update_when_display_idle_) { + for (auto *disp : this->displays_) + disp->update(); + } +} + +bool LvglComponent::is_paused() const { + if (this->paused_) + return true; + if (this->update_when_display_idle_) { + for (auto *disp : this->displays_) { + if (!disp->is_idle()) + return true; + } + } + return false; +} + void LvglComponent::write_random_() { int iterations = 6 - lv_disp_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) @@ -426,12 +458,13 @@ void LvglComponent::write_random_() { * presses a key or clicks on the screen. */ LvglComponent::LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, - int draw_rounding, bool resume_on_input) + int draw_rounding, bool resume_on_input, bool update_when_display_idle) : draw_rounding(draw_rounding), displays_(std::move(displays)), buffer_frac_(buffer_frac), full_refresh_(full_refresh), - resume_on_input_(resume_on_input) { + resume_on_input_(resume_on_input), + update_when_display_idle_(update_when_display_idle) { lv_disp_draw_buf_init(&this->draw_buf_, nullptr, nullptr, 0); lv_disp_drv_init(&this->disp_drv_); this->disp_drv_.draw_buf = &this->draw_buf_; @@ -487,7 +520,7 @@ void LvglComponent::setup() { if (this->draw_start_callback_ != nullptr) { this->disp_drv_.render_start_cb = render_start_cb; } - if (this->draw_end_callback_ != nullptr) { + if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { this->disp_drv_.monitor_cb = monitor_cb; } #if LV_USE_LOG @@ -509,14 +542,15 @@ void LvglComponent::setup() { void LvglComponent::update() { // update indicators - if (this->paused_) { + if (this->is_paused()) { return; } this->idle_callbacks_.call(lv_disp_get_inactive_time(this->disp_)); } + void LvglComponent::loop() { - if (this->paused_) { - if (this->show_snow_) + if (this->is_paused()) { + if (this->paused_ && this->show_snow_) this->write_random_(); } else { lv_timer_handler_run_in_period(5); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index bd6f1fdb61..9c82f3646b 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -151,7 +151,7 @@ class LvglComponent : public PollingComponent { public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, - bool resume_on_input); + bool resume_on_input, bool update_when_display_idle); static void static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); float get_setup_priority() const override { return setup_priority::PROCESSOR; } @@ -171,7 +171,9 @@ class LvglComponent : public PollingComponent { // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. void set_paused(bool paused, bool show_snow); - bool is_paused() const { return this->paused_; } + + // Returns true if the display is explicitly paused, or a blocking display update is in progress. + bool is_paused() const; // If the display is paused and we have resume_on_input_ set to true, resume the display. void maybe_wakeup() { if (this->paused_ && this->resume_on_input_) { @@ -210,10 +212,10 @@ class LvglComponent : public PollingComponent { void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } protected: - // these functions are never called unless the callbacks are non-null since the - // LVGL callbacks that call them are not set unless the start/end callbacks are non-null + void draw_end_(); + // Not checking for non-null callback since the + // LVGL callback that calls it is not set in that case void draw_start_() const { this->draw_start_callback_->trigger(); } - void draw_end_() const { this->draw_end_callback_->trigger(); } void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_t *ptr); @@ -222,6 +224,7 @@ class LvglComponent : public PollingComponent { size_t buffer_frac_{1}; bool full_refresh_{}; bool resume_on_input_{}; + bool update_when_display_idle_{}; lv_disp_draw_buf_t draw_buf_{}; lv_disp_drv_t disp_drv_{}; diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 2450d28eb8..e6025e17fc 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -60,6 +60,7 @@ display: update_interval: never lvgl: + update_when_display_idle: true displays: - tft_display - second_display From 927d3715c1ab16d5963ee404790b5ae142b1b613 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 27 Nov 2025 09:06:40 +1000 Subject: [PATCH 153/320] [lvgl] Allow setting text directly on a button (#11964) Co-authored-by: J. Nick Koston --- esphome/components/lvgl/__init__.py | 18 +++++++--- esphome/components/lvgl/defines.py | 22 ++++++++++-- esphome/components/lvgl/schemas.py | 35 ++++++++++++++----- esphome/components/lvgl/types.py | 16 ++++++--- esphome/components/lvgl/widgets/button.py | 42 ++++++++++++++++++++--- tests/components/lvgl/lvgl-package.yaml | 8 +++++ 6 files changed, 115 insertions(+), 26 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index eeabf755a6..040661495c 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -108,7 +108,7 @@ LV_CONF_H_FORMAT = """\ def generate_lv_conf_h(): - definitions = [as_macro(m, v) for m, v in df.lv_defines.items()] + definitions = [as_macro(m, v) for m, v in df.get_data(df.KEY_LV_DEFINES).items()] definitions.sort() return LV_CONF_H_FORMAT.format("\n".join(definitions)) @@ -140,11 +140,11 @@ def multi_conf_validate(configs: list[dict]): ) -def final_validation(configs): - if len(configs) != 1: - multi_conf_validate(configs) +def final_validation(config_list): + if len(config_list) != 1: + multi_conf_validate(config_list) global_config = full_config.get() - for config in configs: + for config in config_list: if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): raise cv.Invalid("At least one page must not be skipped") for display_id in config[df.CONF_DISPLAYS]: @@ -190,6 +190,14 @@ def final_validation(configs): raise cv.Invalid( f"Widget '{w}' does not have any dynamic properties to refresh", ) + # Do per-widget type final validation for update actions + for widget_type, update_configs in df.get_data(df.KEY_UPDATED_WIDGETS).items(): + for conf in update_configs: + for id_conf in conf.get(CONF_ID, ()): + name = id_conf[CONF_ID] + path = global_config.get_path_for_id(name) + widget_conf = global_config.get_config_for_path(path[:-1]) + widget_type.final_validate(name, conf, widget_conf, path[1:]) async def to_code(configs): diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 1fce6fa458..1d528b2f73 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS -from esphome.core import ID, Lambda +from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj from esphome.cpp_types import uint32 from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -20,11 +20,27 @@ from .helpers import requires_component LOGGER = logging.getLogger(__name__) lvgl_ns = cg.esphome_ns.namespace("lvgl") -lv_defines = {} # Dict of #defines to provide as build flags +DOMAIN = "lvgl" +KEY_LV_DEFINES = "lv_defines" +KEY_UPDATED_WIDGETS = "updated_widgets" + + +def get_data(key, default=None): + """ + Get a data structure from the global data store by key + :param key: A key for the data + :param default: The default data - the default is an empty dict + :return: + """ + return CORE.data.setdefault(DOMAIN, {}).setdefault( + key, default if default is not None else {} + ) def add_define(macro, value="1"): - if macro in lv_defines and lv_defines[macro] != value: + lv_defines = get_data(KEY_LV_DEFINES) + value = str(value) + if lv_defines.setdefault(macro, value) != value: LOGGER.error( "Redefinition of %s - was %s now %s", macro, lv_defines[macro], value ) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index f2704f99de..45d933c00e 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation from esphome.components.time import RealTimeClock @@ -311,19 +313,36 @@ def automation_schema(typ: LvType): } -def base_update_schema(widget_type, parts): +def _update_widget(widget_type: WidgetType) -> Callable[[dict], dict]: """ - Create a schema for updating a widgets style properties, states and flags + During validation of update actions, create a map of action types to affected widgets + for use in final validation. + :param widget_type: + :return: + """ + + def validator(value: dict) -> dict: + df.get_data(df.KEY_UPDATED_WIDGETS).setdefault(widget_type, []).append(value) + return value + + return validator + + +def base_update_schema(widget_type: WidgetType | LvType, parts): + """ + Create a schema for updating a widget's style properties, states and flags. :param widget_type: The type of the ID :param parts: The allowable parts to specify :return: """ - return part_schema(parts).extend( + + w_type = widget_type.w_type if isinstance(widget_type, WidgetType) else widget_type + schema = part_schema(parts).extend( { cv.Required(CONF_ID): cv.ensure_list( cv.maybe_simple_value( { - cv.Required(CONF_ID): cv.use_id(widget_type), + cv.Required(CONF_ID): cv.use_id(w_type), }, key=CONF_ID, ) @@ -332,11 +351,9 @@ def base_update_schema(widget_type, parts): } ) - -def create_modify_schema(widget_type): - return base_update_schema(widget_type.w_type, widget_type.parts).extend( - widget_type.modify_schema - ) + if isinstance(widget_type, WidgetType): + schema.add_extra(_update_widget(widget_type)) + return schema def obj_schema(widget_type: WidgetType): diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index b99c0ad5a3..9c92ca7e98 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -152,18 +152,18 @@ class WidgetType: # Local import to avoid circular import from .automation import update_to_code - from .schemas import WIDGET_TYPES, create_modify_schema + from .schemas import WIDGET_TYPES, base_update_schema if not is_mock: if self.name in WIDGET_TYPES: raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") WIDGET_TYPES[self.name] = self - # Register the update action automatically + # Register the update action automatically, adding widget-specific properties register_action( f"lvgl.{self.name}.update", ObjUpdateAction, - create_modify_schema(self), + base_update_schema(self, self.parts).extend(self.modify_schema), )(update_to_code) @property @@ -182,7 +182,6 @@ class WidgetType: Generate code for a given widget :param w: The widget :param config: Its configuration - :return: Generated code as a list of text lines """ async def obj_creator(self, parent: MockObjClass, config: dict): @@ -228,6 +227,15 @@ class WidgetType: """ return value + def final_validate(self, widget, update_config, widget_config, path): + """ + Allow final validation for a given widget type update action + :param widget: A widget + :param update_config: The configuration for the update action + :param widget_config: The configuration for the widget itself + :param path: The path to the widget, for error reporting + """ + class NumberType(WidgetType): def get_max(self, config: dict): diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index b59884ee67..5f2910174f 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -1,20 +1,52 @@ -from esphome.const import CONF_BUTTON +from esphome import config_validation as cv +from esphome.const import CONF_BUTTON, CONF_TEXT +from esphome.cpp_generator import MockObj -from ..defines import CONF_MAIN +from ..defines import CONF_MAIN, CONF_WIDGETS +from ..helpers import add_lv_use +from ..lv_validation import lv_text +from ..lvcode import lv, lv_expr +from ..schemas import TEXT_SCHEMA from ..types import LvBoolean, WidgetType +from . import Widget +from .label import label_spec lv_button_t = LvBoolean("lv_btn_t") class ButtonType(WidgetType): def __init__(self): - super().__init__(CONF_BUTTON, lv_button_t, (CONF_MAIN,), lv_name="btn") + super().__init__( + CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA, lv_name="btn" + ) + + def validate(self, value): + if CONF_TEXT in value: + if CONF_WIDGETS in value: + raise cv.Invalid("Cannot use both text and widgets in a button") + add_lv_use("label") + return value def get_uses(self): return ("btn",) - async def to_code(self, w, config): - return [] + def on_create(self, var: MockObj, config: dict): + if CONF_TEXT in config: + lv.label_create(var) + return var + + async def to_code(self, w: Widget, config): + if text := config.get(CONF_TEXT): + label_widget = Widget.create( + None, lv_expr.obj_get_child(w.obj, 0), label_spec + ) + await label_widget.set_property(CONF_TEXT, await lv_text.process(text)) + + def final_validate(self, widget, update_config, widget_config, path): + if CONF_TEXT in update_config and CONF_TEXT not in widget_config: + raise cv.Invalid( + "Button must have 'text:' configured to allow updating text", path + ) button_spec = ButtonType() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index a5714d5639..65d629bcdf 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -426,6 +426,14 @@ lvgl: logger.log: Long pressed repeated - buttons: - id: button_e + - button: + id: button_with_text + text: Button + on_click: + lvgl.button.update: + id: button_with_text + text: Clicked + - button: layout: 2x1 id: button_button From b3955cd151d0f1b8ac488d75ab47dc468d9adde7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 27 Nov 2025 09:07:51 +1000 Subject: [PATCH 154/320] [epaper_spi] Add SSD1677 and Waveshare 4.26 (#11887) Co-authored-by: J. Nick Koston --- esphome/components/display/display.cpp | 128 +++++++++++++----- esphome/components/epaper_spi/display.py | 90 ++++++++++-- esphome/components/epaper_spi/epaper_spi.cpp | 107 ++++++++++++--- esphome/components/epaper_spi/epaper_spi.h | 80 ++++++++--- .../epaper_spi/epaper_spi_spectra_e6.cpp | 23 ++-- .../epaper_spi/epaper_spi_spectra_e6.h | 4 +- .../epaper_spi/epaper_spi_ssd1677.cpp | 86 ++++++++++++ .../epaper_spi/epaper_spi_ssd1677.h | 25 ++++ .../components/epaper_spi/models/ssd1677.py | 42 ++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 5 + 10 files changed, 488 insertions(+), 102 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1677.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ssd1677.h create mode 100644 esphome/components/epaper_spi/models/ssd1677.py diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 1451d14e2e..ebc3c0a9f6 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -7,7 +7,6 @@ namespace esphome { namespace display { - static const char *const TAG = "display"; const Color COLOR_OFF(0, 0, 0, 0); @@ -16,6 +15,7 @@ const Color COLOR_ON(255, 255, 255, 255); void Display::fill(Color color) { this->filled_rectangle(0, 0, this->get_width(), this->get_height(), color); } void Display::clear() { this->fill(COLOR_OFF); } void Display::set_rotation(DisplayRotation rotation) { this->rotation_ = rotation; } + void HOT Display::line(int x1, int y1, int x2, int y2, Color color) { const int32_t dx = abs(x2 - x1), sx = x1 < x2 ? 1 : -1; const int32_t dy = -abs(y2 - y1), sy = y1 < y2 ? 1 : -1; @@ -91,23 +91,27 @@ void HOT Display::horizontal_line(int x, int y, int width, Color color) { for (int i = x; i < x + width; i++) this->draw_pixel_at(i, y, color); } + void HOT Display::vertical_line(int x, int y, int height, Color color) { // Future: Could be made more efficient by manipulating buffer directly in certain rotations. for (int i = y; i < y + height; i++) this->draw_pixel_at(x, i, color); } + void Display::rectangle(int x1, int y1, int width, int height, Color color) { this->horizontal_line(x1, y1, width, color); this->horizontal_line(x1, y1 + height - 1, width, color); this->vertical_line(x1, y1, height, color); this->vertical_line(x1 + width - 1, y1, height, color); } + void Display::filled_rectangle(int x1, int y1, int width, int height, Color color) { // Future: Use vertical_line and horizontal_line methods depending on rotation to reduce memory accesses. for (int i = y1; i < y1 + height; i++) { this->horizontal_line(x1, i, width, color); } } + void HOT Display::circle(int center_x, int center_xy, int radius, Color color) { int dx = -radius; int dy = 0; @@ -131,6 +135,7 @@ void HOT Display::circle(int center_x, int center_xy, int radius, Color color) { } } while (dx <= 0); } + void Display::filled_circle(int center_x, int center_y, int radius, Color color) { int dx = -int32_t(radius); int dy = 0; @@ -157,6 +162,7 @@ void Display::filled_circle(int center_x, int center_y, int radius, Color color) } } while (dx <= 0); } + void Display::filled_ring(int center_x, int center_y, int radius1, int radius2, Color color) { int rmax = radius1 > radius2 ? radius1 : radius2; int rmin = radius1 < radius2 ? radius1 : radius2; @@ -213,6 +219,7 @@ void Display::filled_ring(int center_x, int center_y, int radius1, int radius2, } } while (dxmax <= 0); } + void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int progress, Color color) { int rmax = radius1 > radius2 ? radius1 : radius2; int rmin = radius1 < radius2 ? radius1 : radius2; @@ -228,7 +235,8 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, // outer dots this->draw_pixel_at(center_x + dxmax, center_y - dymax, color); this->draw_pixel_at(center_x - dxmax, center_y - dymax, color); - if (dymin < rmin) { // side parts + if (dymin < rmin) { + // side parts int lhline_width = -(dxmax - dxmin) + 1; if (progress >= 50) { if (float(dymax) < float(-dxmax) * tan_a) { @@ -239,7 +247,8 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, this->horizontal_line(center_x + dxmax, center_y - dymax, lhline_width, color); // left if (!dymax) this->horizontal_line(center_x - dxmin, center_y, lhline_width, color); // right horizontal border - if (upd_dxmax > -dxmin) { // right + if (upd_dxmax > -dxmin) { + // right int rhline_width = (upd_dxmax + dxmin) + 1; this->horizontal_line(center_x - dxmin, center_y - dymax, rhline_width > lhline_width ? lhline_width : rhline_width, color); @@ -256,7 +265,8 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, if (lhline_width > 0) this->horizontal_line(center_x + dxmax, center_y - dymax, lhline_width, color); } - } else { // top part + } else { + // top part int hline_width = 2 * (-dxmax) + 1; if (progress >= 50) { if (dymax < float(-dxmax) * tan_a) { @@ -300,11 +310,13 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, } } while (dxmax <= 0); } + void HOT Display::triangle(int x1, int y1, int x2, int y2, int x3, int y3, Color color) { this->line(x1, y1, x2, y2, color); this->line(x1, y1, x3, y3, color); this->line(x2, y2, x3, y3, color); } + void Display::sort_triangle_points_by_y_(int *x1, int *y1, int *x2, int *y2, int *x3, int *y3) { if (*y1 > *y2) { int x_temp = *x1, y_temp = *y1; @@ -322,6 +334,7 @@ void Display::sort_triangle_points_by_y_(int *x1, int *y1, int *x2, int *y2, int *x3 = x_temp, *y3 = y_temp; } } + void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, int y3, Color color) { // y2 must be equal to y3 (same horizontal line) @@ -333,7 +346,8 @@ void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, int s1_dy = abs(y2 - y1); int s1_sign_x = ((x2 - x1) >= 0) ? 1 : -1; int s1_sign_y = ((y2 - y1) >= 0) ? 1 : -1; - if (s1_dy > s1_dx) { // swap values + if (s1_dy > s1_dx) { + // swap values int tmp = s1_dx; s1_dx = s1_dy; s1_dy = tmp; @@ -349,7 +363,8 @@ void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, int s2_dy = abs(y3 - y1); int s2_sign_x = ((x3 - x1) >= 0) ? 1 : -1; int s2_sign_y = ((y3 - y1) >= 0) ? 1 : -1; - if (s2_dy > s2_dx) { // swap values + if (s2_dy > s2_dx) { + // swap values int tmp = s2_dx; s2_dx = s2_dy; s2_dy = tmp; @@ -402,20 +417,25 @@ void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, } } } + void Display::filled_triangle(int x1, int y1, int x2, int y2, int x3, int y3, Color color) { // Sort the three points by y-coordinate ascending, so [x1,y1] is the topmost point this->sort_triangle_points_by_y_(&x1, &y1, &x2, &y2, &x3, &y3); - if (y2 == y3) { // Check for special case of a bottom-flat triangle + if (y2 == y3) { + // Check for special case of a bottom-flat triangle this->filled_flat_side_triangle_(x1, y1, x2, y2, x3, y3, color); - } else if (y1 == y2) { // Check for special case of a top-flat triangle + } else if (y1 == y2) { + // Check for special case of a top-flat triangle this->filled_flat_side_triangle_(x3, y3, x1, y1, x2, y2, color); - } else { // General case: split the no-flat-side triangle in a top-flat triangle and bottom-flat triangle + } else { + // General case: split the no-flat-side triangle in a top-flat triangle and bottom-flat triangle int x_temp = (int) (x1 + ((float) (y2 - y1) / (float) (y3 - y1)) * (x3 - x1)), y_temp = y2; this->filled_flat_side_triangle_(x1, y1, x2, y2, x_temp, y_temp, color); this->filled_flat_side_triangle_(x3, y3, x2, y2, x_temp, y_temp, color); } } + void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int *vertex_y, int center_x, int center_y, int radius, int edges, RegularPolygonVariation variation, float rotation_degrees) { @@ -447,7 +467,8 @@ void HOT Display::regular_polygon(int x, int y, int radius, int edges, RegularPo int current_vertex_x, current_vertex_y; get_regular_polygon_vertex(current_vertex_id, ¤t_vertex_x, ¤t_vertex_y, x, y, radius, edges, variation, rotation_degrees); - if (current_vertex_id > 0) { // Start drawing after the 2nd vertex coordinates has been calculated + if (current_vertex_id > 0) { + // Start drawing after the 2nd vertex coordinates has been calculated if (drawing == DRAWING_FILLED) { this->filled_triangle(x, y, previous_vertex_x, previous_vertex_y, current_vertex_x, current_vertex_y, color); } else if (drawing == DRAWING_OUTLINE) { @@ -459,21 +480,26 @@ void HOT Display::regular_polygon(int x, int y, int radius, int edges, RegularPo } } } + void HOT Display::regular_polygon(int x, int y, int radius, int edges, RegularPolygonVariation variation, Color color, RegularPolygonDrawing drawing) { regular_polygon(x, y, radius, edges, variation, ROTATION_0_DEGREES, color, drawing); } + void HOT Display::regular_polygon(int x, int y, int radius, int edges, Color color, RegularPolygonDrawing drawing) { regular_polygon(x, y, radius, edges, VARIATION_POINTY_TOP, ROTATION_0_DEGREES, color, drawing); } + void Display::filled_regular_polygon(int x, int y, int radius, int edges, RegularPolygonVariation variation, float rotation_degrees, Color color) { regular_polygon(x, y, radius, edges, variation, rotation_degrees, color, DRAWING_FILLED); } + void Display::filled_regular_polygon(int x, int y, int radius, int edges, RegularPolygonVariation variation, Color color) { regular_polygon(x, y, radius, edges, variation, ROTATION_0_DEGREES, color, DRAWING_FILLED); } + void Display::filled_regular_polygon(int x, int y, int radius, int edges, Color color) { regular_polygon(x, y, radius, edges, VARIATION_POINTY_TOP, ROTATION_0_DEGREES, color, DRAWING_FILLED); } @@ -584,15 +610,19 @@ void Display::get_text_bounds(int x, int y, const char *text, BaseFont *font, Te break; } } + void Display::print(int x, int y, BaseFont *font, Color color, const char *text, Color background) { this->print(x, y, font, color, TextAlign::TOP_LEFT, text, background); } + void Display::print(int x, int y, BaseFont *font, TextAlign align, const char *text) { this->print(x, y, font, COLOR_ON, align, text); } + void Display::print(int x, int y, BaseFont *font, const char *text) { this->print(x, y, font, COLOR_ON, TextAlign::TOP_LEFT, text); } + void Display::printf(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ...) { va_list arg; @@ -600,31 +630,37 @@ void Display::printf(int x, int y, BaseFont *font, Color color, Color background this->vprintf_(x, y, font, color, background, align, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, Color color, TextAlign align, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, color, COLOR_OFF, align, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, Color color, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, color, COLOR_OFF, TextAlign::TOP_LEFT, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, TextAlign align, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, COLOR_ON, COLOR_OFF, align, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, COLOR_ON, COLOR_OFF, TextAlign::TOP_LEFT, format, arg); va_end(arg); } + void Display::set_writer(display_writer_t &&writer) { this->writer_ = writer; } + void Display::set_pages(std::vector pages) { for (auto *page : pages) page->set_parent(this); @@ -637,6 +673,7 @@ void Display::set_pages(std::vector pages) { pages[pages.size() - 1]->set_next(pages[0]); this->show_page(pages[0]); } + void Display::show_page(DisplayPage *page) { this->previous_page_ = this->page_; this->page_ = page; @@ -645,8 +682,10 @@ void Display::show_page(DisplayPage *page) { t->process(this->previous_page_, this->page_); } } + void Display::show_next_page() { this->page_->show_next(); } void Display::show_prev_page() { this->page_->show_prev(); } + void Display::do_update_() { if (this->auto_clear_enabled_) { this->clear(); @@ -660,10 +699,12 @@ void Display::do_update_() { } this->clear_clipping_(); } + void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) this->trigger(from, to); } + void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; @@ -671,15 +712,19 @@ void Display::strftime(int x, int y, BaseFont *font, Color color, Color backgrou if (ret > 0) this->print(x, y, font, color, align, buffer, background); } + void Display::strftime(int x, int y, BaseFont *font, Color color, TextAlign align, const char *format, ESPTime time) { this->strftime(x, y, font, color, COLOR_OFF, align, format, time); } + void Display::strftime(int x, int y, BaseFont *font, Color color, const char *format, ESPTime time) { this->strftime(x, y, font, color, COLOR_OFF, TextAlign::TOP_LEFT, format, time); } + void Display::strftime(int x, int y, BaseFont *font, TextAlign align, const char *format, ESPTime time) { this->strftime(x, y, font, COLOR_ON, COLOR_OFF, align, format, time); } + void Display::strftime(int x, int y, BaseFont *font, const char *format, ESPTime time) { this->strftime(x, y, font, COLOR_ON, COLOR_OFF, TextAlign::TOP_LEFT, format, time); } @@ -691,6 +736,7 @@ void Display::start_clipping(Rect rect) { } this->clipping_rectangle_.push_back(rect); } + void Display::end_clipping() { if (this->clipping_rectangle_.empty()) { ESP_LOGE(TAG, "clear: Clipping is not set."); @@ -698,6 +744,7 @@ void Display::end_clipping() { this->clipping_rectangle_.pop_back(); } } + void Display::extend_clipping(Rect add_rect) { if (this->clipping_rectangle_.empty()) { ESP_LOGE(TAG, "add: Clipping is not set."); @@ -705,6 +752,7 @@ void Display::extend_clipping(Rect add_rect) { this->clipping_rectangle_.back().extend(add_rect); } } + void Display::shrink_clipping(Rect add_rect) { if (this->clipping_rectangle_.empty()) { ESP_LOGE(TAG, "add: Clipping is not set."); @@ -712,6 +760,7 @@ void Display::shrink_clipping(Rect add_rect) { this->clipping_rectangle_.back().shrink(add_rect); } } + Rect Display::get_clipping() const { if (this->clipping_rectangle_.empty()) { return Rect(); @@ -719,7 +768,9 @@ Rect Display::get_clipping() const { return this->clipping_rectangle_.back(); } } + void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; @@ -727,6 +778,7 @@ bool Display::clip(int x, int y) { return false; return true; } + bool Display::clamp_x_(int x, int w, int &min_x, int &max_x) { min_x = std::max(x, 0); max_x = std::min(x + w, this->get_width()); @@ -742,6 +794,7 @@ bool Display::clamp_x_(int x, int w, int &min_x, int &max_x) { return min_x < max_x; } + bool Display::clamp_y_(int y, int h, int &min_y, int &max_y) { min_y = std::max(y, 0); max_y = std::min(y + h, this->get_height()); @@ -766,15 +819,15 @@ void Display::test_card() { int w = get_width(), h = get_height(), image_w, image_h; this->clear(); this->show_test_card_ = false; + image_w = std::min(w - 20, 310); + image_h = std::min(h - 20, 255); + int shift_x = (w - image_w) / 2; + int shift_y = (h - image_h) / 2; + int line_w = (image_w - 6) / 6; + int image_c = image_w / 2; if (this->get_display_type() == DISPLAY_TYPE_COLOR) { Color r(255, 0, 0), g(0, 255, 0), b(0, 0, 255); - image_w = std::min(w - 20, 310); - image_h = std::min(h - 20, 255); - int shift_x = (w - image_w) / 2; - int shift_y = (h - image_h) / 2; - int line_w = (image_w - 6) / 6; - int image_c = image_w / 2; for (auto i = 0; i != image_h; i++) { int c = esp_scale(i, image_h); this->horizontal_line(shift_x + 0, shift_y + i, line_w, r.fade_to_white(c)); @@ -786,26 +839,26 @@ void Display::test_card() { this->horizontal_line(shift_x + image_w - (line_w * 2), shift_y + i, line_w, b.fade_to_white(c)); this->horizontal_line(shift_x + image_w - line_w, shift_y + i, line_w, b.fade_to_black(c)); } - this->rectangle(shift_x, shift_y, image_w, image_h, Color(127, 127, 0)); + } + this->rectangle(shift_x, shift_y, image_w, image_h, Color(127, 127, 0)); - uint16_t shift_r = shift_x + line_w - (8 * 3); - uint16_t shift_g = shift_x + image_c - (8 * 3); - uint16_t shift_b = shift_x + image_w - line_w - (8 * 3); - shift_y = h / 2 - (8 * 3); - for (auto i = 0; i < 8; i++) { - uint8_t ftr = progmem_read_byte(&TESTCARD_FONT[0][i]); - uint8_t ftg = progmem_read_byte(&TESTCARD_FONT[1][i]); - uint8_t ftb = progmem_read_byte(&TESTCARD_FONT[2][i]); - for (auto k = 0; k < 8; k++) { - if ((ftr & (1 << k)) != 0) { - this->filled_rectangle(shift_r + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); - } - if ((ftg & (1 << k)) != 0) { - this->filled_rectangle(shift_g + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); - } - if ((ftb & (1 << k)) != 0) { - this->filled_rectangle(shift_b + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); - } + uint16_t shift_r = shift_x + line_w - (8 * 3); + uint16_t shift_g = shift_x + image_c - (8 * 3); + uint16_t shift_b = shift_x + image_w - line_w - (8 * 3); + shift_y = h / 2 - (8 * 3); + for (auto i = 0; i < 8; i++) { + uint8_t ftr = progmem_read_byte(&TESTCARD_FONT[0][i]); + uint8_t ftg = progmem_read_byte(&TESTCARD_FONT[1][i]); + uint8_t ftb = progmem_read_byte(&TESTCARD_FONT[2][i]); + for (auto k = 0; k < 8; k++) { + if ((ftr & (1 << k)) != 0) { + this->filled_rectangle(shift_r + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); + } + if ((ftg & (1 << k)) != 0) { + this->filled_rectangle(shift_g + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); + } + if ((ftb & (1 << k)) != 0) { + this->filled_rectangle(shift_b + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); } } } @@ -818,7 +871,9 @@ void Display::test_card() { } DisplayPage::DisplayPage(display_writer_t writer) : writer_(std::move(writer)) {} + void DisplayPage::show() { this->parent_->show_page(this); } + void DisplayPage::show_next() { if (this->next_ == nullptr) { ESP_LOGE(TAG, "no next page"); @@ -826,6 +881,7 @@ void DisplayPage::show_next() { } this->next_->show(); } + void DisplayPage::show_prev() { if (this->prev_ == nullptr) { ESP_LOGE(TAG, "no previous page"); @@ -833,6 +889,7 @@ void DisplayPage::show_prev() { } this->prev_->show(); } + void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; } void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; } void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; } @@ -868,6 +925,5 @@ const LogString *text_align_to_string(TextAlign textalign) { return LOG_STR("UNKNOWN"); } } - } // namespace display } // namespace esphome diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 182c37ba40..ff5693c206 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -4,8 +4,10 @@ import pkgutil from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation from esphome.components.mipi import flatten_sequence, map_sequence import esphome.config_validation as cv +from esphome.config_validation import update_interval from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -13,15 +15,25 @@ from esphome.const import ( CONF_DC_PIN, CONF_DIMENSIONS, CONF_ENABLE_PIN, + CONF_FULL_UPDATE_EVERY, CONF_HEIGHT, CONF_ID, CONF_INIT_SEQUENCE, CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, CONF_MODEL, + CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, + CONF_ROTATION, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_UPDATE_INTERVAL, CONF_WIDTH, ) +from esphome.cpp_generator import RawExpression +from esphome.final_validate import full_config from . import models @@ -32,8 +44,9 @@ CONF_INIT_SEQUENCE_ID = "init_sequence_id" epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi") EPaperBase = epaper_spi_ns.class_( - "EPaperBase", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer + "EPaperBase", cg.PollingComponent, spi.SPIDevice, display.Display ) +Transform = epaper_spi_ns.enum("Transform") EPaperSpectraE6 = epaper_spi_ns.class_("EPaperSpectraE6", EPaperBase) EPaper7p3InSpectraE6 = epaper_spi_ns.class_("EPaper7p3InSpectraE6", EPaperSpectraE6) @@ -52,6 +65,8 @@ DIMENSION_SCHEMA = cv.Schema( } ) +TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} + def model_schema(config): model = MODELS[config[CONF_MODEL]] @@ -73,7 +88,18 @@ def model_schema(config): ) .extend( { + cv.Optional(CONF_ROTATION, default=0): validate_rotation, cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), + cv.Optional( + CONF_UPDATE_INTERVAL, default=cv.UNDEFINED + ): update_interval, + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + } + ), + cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255), model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), @@ -111,9 +137,29 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( - "epaper_spi", require_miso=False, require_mosi=True -) + +def _final_validate(config): + spi.final_validate_device_schema( + "epaper_spi", require_miso=False, require_mosi=True + )(config) + + global_config = full_config.get() + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if CONF_LAMBDA not in config and CONF_PAGES not in config: + if LVGL_DOMAIN in global_config: + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("never") + else: + # If no drawing methods are configured, and LVGL is not enabled, show a test card + config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = core.TimePeriod( + seconds=60 + ).total_milliseconds + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): @@ -137,7 +183,9 @@ async def to_code(config): init_sequence_length, ) - await display.register_display(var, config) + # Rotation is handled by setting the transform + display_config = {k: v for k, v in config.items() if k != CONF_ROTATION} + await display.register_display(var, display_config) await spi.register_spi_device(var, config) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) @@ -148,11 +196,35 @@ async def to_code(config): config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + if reset_pin := config.get(CONF_RESET_PIN): + reset = await cg.gpio_pin_expression(reset_pin) cg.add(var.set_reset_pin(reset)) - if CONF_BUSY_PIN in config: - busy = await cg.gpio_pin_expression(config[CONF_BUSY_PIN]) + if busy_pin := config.get(CONF_BUSY_PIN): + busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) + if transform := config.get(CONF_TRANSFORM): + transform[CONF_SWAP_XY] = False + else: + transform = {x: model.get_default(x, False) for x in TRANSFORM_OPTIONS} + rotation = config[CONF_ROTATION] + if rotation == 180: + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + elif rotation == 90: + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + elif rotation == 270: + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + transform_str = "|".join( + { + str(getattr(Transform, x.upper())) + for x in TRANSFORM_OPTIONS + if transform.get(x) + } + ) + if transform_str: + cg.add(var.set_transform(RawExpression(transform_str))) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 39959cd743..f6313d33ef 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -9,9 +9,8 @@ namespace esphome::epaper_spi { static const char *const TAG = "epaper_spi"; static constexpr const char *const EPAPER_STATE_STRINGS[] = { - "IDLE", "UPDATE", "RESET", "RESET_END", - - "SHOULD_WAIT", "INITIALISE", "TRANSFER_DATA", "POWER_ON", "REFRESH_SCREEN", "POWER_OFF", "DEEP_SLEEP", + "IDLE", "UPDATE", "RESET", "RESET_END", "SHOULD_WAIT", "INITIALISE", + "TRANSFER_DATA", "POWER_ON", "REFRESH_SCREEN", "POWER_OFF", "DEEP_SLEEP", }; const char *EPaperBase::epaper_state_to_string_() { @@ -69,8 +68,8 @@ void EPaperBase::data(uint8_t value) { // The command is the first byte, length is the length of data only in the second byte, followed by the data. // [COMMAND, LENGTH, DATA...] void EPaperBase::cmd_data(uint8_t command, const uint8_t *ptr, size_t length) { - ESP_LOGVV(TAG, "Command: 0x%02X, Length: %d, Data: %s", command, length, - format_hex_pretty(ptr, length, '.', false).c_str()); + ESP_LOGV(TAG, "Command: 0x%02X, Length: %d, Data: %s", command, length, + format_hex_pretty(ptr, length, '.', false).c_str()); this->dc_pin_->digital_write(false); this->enable(); @@ -89,7 +88,7 @@ bool EPaperBase::is_idle_() const { return !this->busy_pin_->digital_read(); } -bool EPaperBase::reset_() const { +bool EPaperBase::reset() { if (this->reset_pin_ != nullptr) { if (this->state_ == EPaperState::RESET) { this->reset_pin_->digital_write(false); @@ -105,16 +104,16 @@ void EPaperBase::update() { ESP_LOGE(TAG, "Display already in state %s", epaper_state_to_string_()); return; } - this->set_state_(EPaperState::RESET); + this->set_state_(EPaperState::UPDATE); this->enable_loop(); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + this->update_start_time_ = millis(); +#endif } void EPaperBase::wait_for_idle_(bool should_wait) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - if (should_wait) { - this->waiting_for_idle_start_ = millis(); - this->waiting_for_idle_last_print_ = this->waiting_for_idle_start_; - } + this->waiting_for_idle_start_ = millis(); #endif this->waiting_for_idle_ = should_wait; } @@ -138,7 +137,9 @@ void EPaperBase::loop() { if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; - ESP_LOGV(TAG, "Screen now idle after %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); +#endif } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { @@ -164,23 +165,27 @@ void EPaperBase::process_state_() { ESP_LOGV(TAG, "Process state entered in state %s", epaper_state_to_string_()); switch (this->state_) { default: - ESP_LOGD(TAG, "Display is in unhandled state %s", epaper_state_to_string_()); - this->disable_loop(); + ESP_LOGE(TAG, "Display is in unhandled state %s", epaper_state_to_string_()); + this->set_state_(EPaperState::IDLE); break; case EPaperState::IDLE: this->disable_loop(); break; case EPaperState::RESET: case EPaperState::RESET_END: - if (this->reset_()) { - this->set_state_(EPaperState::UPDATE); + if (this->reset()) { + this->set_state_(EPaperState::INITIALISE); } else { - this->set_state_(EPaperState::RESET_END); + this->set_state_(EPaperState::RESET_END, this->reset_duration_); } break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda - this->set_state_(EPaperState::INITIALISE); + if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { + this->set_state_(EPaperState::IDLE); + return; + } + this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: this->initialise_(); @@ -190,6 +195,10 @@ void EPaperBase::process_state_() { if (!this->transfer_data()) { return; // Not done yet, come back next loop } + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; this->set_state_(EPaperState::POWER_ON); break; case EPaperState::POWER_ON: @@ -197,7 +206,8 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(); + this->refresh_screen(this->update_count_ != 0); + this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: @@ -207,6 +217,7 @@ void EPaperBase::process_state_() { case EPaperState::DEEP_SLEEP: this->deep_sleep(); this->set_state_(EPaperState::IDLE); + ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_); break; } } @@ -222,6 +233,9 @@ void EPaperBase::set_state_(EPaperState state, uint16_t delay) { } ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + if (state == EPaperState::IDLE) { + this->disable_loop(); + } } void EPaperBase::start_command_() { @@ -260,20 +274,73 @@ void EPaperBase::initialise_() { this->mark_failed(); return; } - ESP_LOGV(TAG, "Command %02X, length %d", cmd, num_args); this->cmd_data(cmd, sequence + index, num_args); index += num_args; } } } +/** + * Check and rotate coordinates based on the transform flags. + * @param x + * @param y + * @return false if the coordinates are out of bounds + */ +bool EPaperBase::rotate_coordinates_(int &x, int &y) const { + if (!this->get_clipping().inside(x, y)) + return false; + if (this->transform_ & SWAP_XY) + std::swap(x, y); + if (this->transform_ & MIRROR_X) + x = this->width_ - x - 1; + if (this->transform_ & MIRROR_Y) + y = this->height_ - y - 1; + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + return true; +} + +/** + * Default implementation for monochrome displays where 8 pixels are packed to a byte. + * @param x + * @param y + * @param color + */ +void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { + if (!rotate_coordinates_(x, y)) + return; + const size_t pixel_position = y * this->width_ + x; + const size_t byte_position = pixel_position / 8; + const uint8_t bit_position = pixel_position % 8; + const uint8_t pixel_bit = 0x80 >> bit_position; + const auto original = this->buffer_[byte_position]; + if ((color_to_bit(color) == 0)) { + this->buffer_[byte_position] = original & ~pixel_bit; + } else { + this->buffer_[byte_position] = original | pixel_bit; + } + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); +} + void EPaperBase::dump_config() { LOG_DISPLAY("", "E-Paper SPI", this); ESP_LOGCONFIG(TAG, " Model: %s", this->name_); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_PIN(" DC Pin: ", this->dc_pin_); LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " SPI Data Rate: %uMHz\n" + " Full update every: %d\n" + " Swap X/Y: %s\n" + " Mirror X: %s\n" + " Mirror Y: %s", + (unsigned) (this->data_rate_ / 1000000), this->full_update_every_, YESNO(this->transform_ & SWAP_XY), + YESNO(this->transform_ & MIRROR_X), YESNO(this->transform_ & MIRROR_Y)); } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 4745ec7339..544ea3e9ba 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,8 +5,6 @@ #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" -#include - namespace esphome::epaper_spi { using namespace display; @@ -25,10 +23,16 @@ enum class EPaperState : uint8_t { DEEP_SLEEP, // deep sleep the display }; -static constexpr uint8_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run +static constexpr uint8_t NONE = 0; +static constexpr uint8_t MIRROR_X = 1; +static constexpr uint8_t MIRROR_Y = 2; +static constexpr uint8_t SWAP_XY = 4; + +static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run +static constexpr size_t MAX_TRANSFER_SIZE = 128; static constexpr uint8_t DELAY_FLAG = 0xFF; -class EPaperBase : public DisplayBuffer, +class EPaperBase : public Display, public spi::SPIDevice { public: @@ -45,6 +49,8 @@ class EPaperBase : public DisplayBuffer, void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } + void set_transform(uint8_t transform) { this->transform_ = transform; } + void set_full_update_every(uint8_t full_update_every) { this->full_update_every_ = full_update_every; } void dump_config() override; void command(uint8_t value); @@ -60,20 +66,47 @@ class EPaperBase : public DisplayBuffer, DisplayType get_display_type() override { return this->display_type_; }; + // Default implementations for monochrome displays + static uint8_t color_to_bit(Color color) { + // It's always a shade of gray. Map to BLACK or WHITE. + // We split the luminance at a suitable point + if ((static_cast(color.r) + color.g + color.b) > 512) { + return 1; + } + return 0; + } + void fill(Color color) override { + auto pixel_color = color_to_bit(color) ? 0xFF : 0x00; + + // We store 8 pixels per byte + this->buffer_.fill(pixel_color); + this->x_high_ = this->width_; + this->y_high_ = this->height_; + this->x_low_ = 0; + this->y_low_ = 0; + } + + void clear() override { + // clear buffer to white, just like real paper. + this->fill(COLOR_ON); + } + protected: int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; + int get_width() override { return this->transform_ & SWAP_XY ? this->height_ : this->width_; } + int get_height() override { return this->transform_ & SWAP_XY ? this->width_ : this->height_; } + void draw_pixel_at(int x, int y, Color color) override; void process_state_(); const char *epaper_state_to_string_(); bool is_idle_() const; void setup_pins_() const; - bool reset_() const; + virtual bool reset(); void initialise_(); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); - - virtual int get_width_controller() { return this->get_width_internal(); }; + bool rotate_coordinates_(int &x, int &y) const; /** * Methods that must be implemented by concrete classes to control the display @@ -86,7 +119,7 @@ class EPaperBase : public DisplayBuffer, /** * Refresh the screen after data transfer */ - virtual void refresh_screen() = 0; + virtual void refresh_screen(bool partial) = 0; /** * Power the display on @@ -118,24 +151,31 @@ class EPaperBase : public DisplayBuffer, DisplayType display_type_; size_t buffer_length_{}; - size_t current_data_index_{0}; // used by data transfer to track progress - uint32_t reset_duration_{200}; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - uint32_t transfer_start_time_{}; - uint32_t waiting_for_idle_last_print_{0}; - uint32_t waiting_for_idle_start_{0}; -#endif - + size_t current_data_index_{}; // used by data transfer to track progress + split_buffer::SplitBuffer buffer_{}; GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + bool waiting_for_idle_{}; + uint32_t delay_until_{}; + uint8_t transform_{}; + uint8_t update_count_{}; + // these values represent the bounds of the updated buffer. Note that x_high and y_high + // point to the pixel past the last one updated, i.e. may range up to width/height. + uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; - bool waiting_for_idle_{false}; - uint32_t delay_until_{0}; - - split_buffer::SplitBuffer buffer_; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + uint32_t waiting_for_idle_last_print_{}; + uint32_t waiting_for_idle_start_{}; +#endif +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + uint32_t update_start_time_{}; +#endif + // properties with specific initialisers go last EPaperState state_{EPaperState::IDLE}; + uint32_t reset_duration_{10}; + uint8_t full_update_every_{1}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 8e4cbdde2a..d0e68595d0 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -6,7 +6,6 @@ namespace esphome::epaper_spi { static constexpr const char *const TAG = "epaper_spi.6c"; -static constexpr size_t MAX_TRANSFER_SIZE = 128; static constexpr unsigned char GRAY_THRESHOLD = 50; enum E6Color { @@ -75,24 +74,24 @@ static uint8_t color_to_hex(Color color) { } void EPaperSpectraE6::power_on() { - ESP_LOGD(TAG, "Power on"); + ESP_LOGV(TAG, "Power on"); this->command(0x04); } void EPaperSpectraE6::power_off() { - ESP_LOGD(TAG, "Power off"); + ESP_LOGV(TAG, "Power off"); this->command(0x02); this->data(0x00); } -void EPaperSpectraE6::refresh_screen() { - ESP_LOGD(TAG, "Refresh"); +void EPaperSpectraE6::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh"); this->command(0x12); this->data(0x00); } void EPaperSpectraE6::deep_sleep() { - ESP_LOGD(TAG, "Deep sleep"); + ESP_LOGV(TAG, "Deep sleep"); this->command(0x07); this->data(0xA5); } @@ -109,12 +108,11 @@ void EPaperSpectraE6::clear() { this->fill(COLOR_ON); } -void HOT EPaperSpectraE6::draw_absolute_pixel_internal(int x, int y, Color color) { - if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) +void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) return; - auto pixel_bits = color_to_hex(color); - uint32_t pixel_position = x + y * this->get_width_controller(); + uint32_t pixel_position = x + y * this->get_width_internal(); uint32_t byte_position = pixel_position / 2; auto original = this->buffer_[byte_position]; if ((pixel_position & 1) != 0) { @@ -128,10 +126,6 @@ bool HOT EPaperSpectraE6::transfer_data() { const uint32_t start_time = App.get_loop_component_start_time(); const size_t buffer_length = this->buffer_length_; if (this->current_data_index_ == 0) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->transfer_start_time_ = millis(); -#endif - ESP_LOGV(TAG, "Start sending data at %ums", (unsigned) millis()); this->command(0x10); } @@ -160,7 +154,6 @@ bool HOT EPaperSpectraE6::transfer_data() { this->end_data_(); } this->current_data_index_ = 0; - ESP_LOGV(TAG, "Sent data in %" PRIu32 " ms", millis() - this->transfer_start_time_); return true; } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index 48356ad74b..b8dbf0b0c5 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -16,11 +16,11 @@ class EPaperSpectraE6 : public EPaperBase { void clear() override; protected: - void refresh_screen() override; + void refresh_screen(bool partial) override; void power_on() override; void power_off() override; void deep_sleep() override; - void draw_absolute_pixel_internal(int x, int y, Color color) override; + void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; }; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp new file mode 100644 index 0000000000..e4f04657ad --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -0,0 +1,86 @@ +#include "epaper_spi_ssd1677.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.ssd1677"; + +void EPaperSSD1677::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + this->command(0x22); + this->data(partial ? 0xFF : 0xF7); + this->command(0x20); +} + +void EPaperSSD1677::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->command(0x10); +} + +bool EPaperSSD1677::reset() { + if (EPaperBase::reset()) { + this->command(0x12); + return true; + } + return false; +} + +bool HOT EPaperSSD1677::transfer_data() { + auto start_time = millis(); + if (this->current_data_index_ == 0) { + uint8_t data[4]{}; + // round to byte boundaries + this->x_low_ &= ~7; + this->y_low_ &= ~7; + this->x_high_ += 7; + this->x_high_ &= ~7; + this->y_high_ += 7; + this->y_high_ &= ~7; + data[0] = this->x_low_; + data[1] = this->x_low_ / 256; + data[2] = this->x_high_ - 1; + data[3] = (this->x_high_ - 1) / 256; + cmd_data(0x4E, data, 2); + cmd_data(0x44, data, sizeof(data)); + data[0] = this->y_low_; + data[1] = this->y_low_ / 256; + data[2] = this->y_high_ - 1; + data[3] = (this->y_high_ - 1) / 256; + cmd_data(0x4F, data, 2); + this->cmd_data(0x45, data, sizeof(data)); + // for monochrome, we still need to clear the red data buffer at least once to prevent it + // causing dirty pixels after partial refresh. + this->command(this->send_red_ ? 0x26 : 0x24); + this->current_data_index_ = this->y_low_; // actually current line + } + size_t row_length = (this->x_high_ - this->x_low_) / 8; + FixedVector bytes_to_send{}; + bytes_to_send.init(row_length); + ESP_LOGV(TAG, "Writing bytes at line %zu at %ums", this->current_data_index_, (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + size_t data_idx = (this->current_data_index_ * this->width_ + this->x_low_) / 8; + for (size_t i = 0; i != row_length; i++) { + bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; + } + ++this->current_data_index_; + this->write_array(&bytes_to_send.front(), row_length); // NOLINT + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->end_data_(); + return false; + } + } + + this->end_data_(); + this->current_data_index_ = 0; + if (this->send_red_) { + this->send_red_ = false; + return false; + } + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h new file mode 100644 index 0000000000..47584d24c0 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -0,0 +1,25 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +class EPaperSSD1677 : public EPaperBase { + public: + EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + this->buffer_length_ = width * height / 8; // 8 pixels per byte + } + + protected: + void refresh_screen(bool partial) override; + void power_on() override {} + void power_off() override{}; + void deep_sleep() override; + bool reset() override; + bool transfer_data() override; + bool send_red_{true}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py new file mode 100644 index 0000000000..3eb53d650e --- /dev/null +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -0,0 +1,42 @@ +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class SSD1677(EpaperModel): + def __init__(self, name, class_name="EPaperSSD1677", **kwargs): + if CONF_DATA_RATE not in kwargs: + kwargs[CONF_DATA_RATE] = "20MHz" + super().__init__(name, class_name, **kwargs) + + # fmt: off + def get_init_sequence(self, config: dict): + width, _height = self.get_dimensions(config) + return ( + (0x18, 0x80), # Select internal Temp sensor + (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 + (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x3C, 0x01), # Set border waveform + (0x11, 3), # Set transform + ) + + +ssd1677 = SSD1677("ssd1677") + +ssd1677.extend( + "seeed-ee04-mono-4.26", + width=800, + height=480, + mirror_x=True, + cs_pin=44, + dc_pin=10, + reset_pin=38, + busy_pin={ + "number": 4, + "inverted": False, + "mode": { + "input": True, + "pulldown": True, + }, + }, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index cff1f51897..d330b4127d 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -19,3 +19,8 @@ display: - platform: epaper_spi model: seeed-reterminal-e1002 + - platform: epaper_spi + model: seeed-ee04-mono-4.26 + # Override pins to avoid conflict with other display configs + busy_pin: 43 + dc_pin: 42 From 23e58c1c7b597b81f0634acfa3b907686de68333 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:08:40 +1300 Subject: [PATCH 155/320] [inkplate] Ignore strapping pin warnings on default pins (#12110) --- esphome/components/inkplate/display.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 89518dcfab..47c8c898e5 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -6,10 +6,12 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FULL_UPDATE_EVERY, CONF_ID, + CONF_IGNORE_STRAPPING_WARNING, CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_NUMBER, CONF_OE_PIN, CONF_PAGES, CONF_TRANSFORM, @@ -101,14 +103,21 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SPV_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_VCOM_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_CL_PIN, default=0): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_LE_PIN, default=2): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_CL_PIN, + default={CONF_NUMBER: 0, CONF_IGNORE_STRAPPING_WARNING: True}, + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_LE_PIN, + default={CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, + ): pins.internal_gpio_output_pin_schema, # Data pins cv.Optional( CONF_DISPLAY_DATA_0_PIN, default=4 ): pins.internal_gpio_output_pin_schema, cv.Optional( - CONF_DISPLAY_DATA_1_PIN, default=5 + CONF_DISPLAY_DATA_1_PIN, + default={CONF_NUMBER: 5, CONF_IGNORE_STRAPPING_WARNING: True}, ): pins.internal_gpio_output_pin_schema, cv.Optional( CONF_DISPLAY_DATA_2_PIN, default=18 From 9c85ec9182bbbbdf9ab057ec2ff931dbcffe4d7e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:01:35 -0500 Subject: [PATCH 156/320] [esp32] Fix hosted update when there is no wifi (#12123) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index f34a0ae10e..de130ca71f 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -22,6 +22,11 @@ constexpr size_t CHUNK_SIZE = 1500; void Esp32HostedUpdate::setup() { this->update_info_.title = "ESP32 Hosted Coprocessor"; + // if wifi is not present, connect to the coprocessor +#ifndef USE_WIFI + esp_hosted_connect_to_slave(); // NOLINT +#endif + // get coprocessor version esp_hosted_coprocessor_fwver_t ver_info; if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { From a7a5a0b9a23a048a63b8e40621f39c411b9db8c4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:46:17 -0500 Subject: [PATCH 157/320] [esp32] Improve IDF component support (#12127) --- esphome/components/esp32/__init__.py | 74 ++++++++++++++----- tests/components/esp32/test.esp32-p4-idf.yaml | 4 + 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d372af3e6a..35ef76634b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -37,6 +37,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, HexInt, TimePeriod +from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType @@ -262,15 +263,32 @@ def add_idf_component( "deprecated and will be removed in ESPHome 2026.1. If you are seeing this, report " "an issue to the external_component author and ask them to update it." ) + components_registry = CORE.data[KEY_ESP32][KEY_COMPONENTS] if components: for comp in components: - CORE.data[KEY_ESP32][KEY_COMPONENTS][comp] = { + existing = components_registry.get(comp) + if existing and existing.get(KEY_REF) != ref: + _LOGGER.warning( + "IDF component %s version conflict %s replaced by %s", + comp, + existing.get(KEY_REF), + ref, + ) + components_registry[comp] = { KEY_REPO: repo, KEY_REF: ref, KEY_PATH: f"{path}/{comp}" if path else comp, } else: - CORE.data[KEY_ESP32][KEY_COMPONENTS][name] = { + existing = components_registry.get(name) + if existing and existing.get(KEY_REF) != ref: + _LOGGER.warning( + "IDF component %s version conflict %s replaced by %s", + name, + existing.get(KEY_REF), + ref, + ) + components_registry[name] = { KEY_REPO: repo, KEY_REF: ref, KEY_PATH: path, @@ -592,6 +610,14 @@ def require_vfs_dir() -> None: CORE.data[KEY_VFS_DIR_REQUIRED] = True +def _parse_idf_component(value: str) -> ConfigType: + """Parse IDF component shorthand syntax like 'owner/component^version'""" + if "^" not in value: + raise cv.Invalid(f"Invalid IDF component shorthand '{value}'") + name, ref = value.split("^", 1) + return {CONF_NAME: name, CONF_REF: ref} + + def _validate_idf_component(config: ConfigType) -> ConfigType: """Validate IDF component config and warn about deprecated options.""" if CONF_REFRESH in config: @@ -659,14 +685,19 @@ FRAMEWORK_SCHEMA = cv.Schema( ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( cv.All( - cv.Schema( - { - cv.Required(CONF_NAME): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.git_ref, - cv.Optional(CONF_REF): cv.string, - cv.Optional(CONF_PATH): cv.string, - cv.Optional(CONF_REFRESH): cv.All(cv.string, cv.source_refresh), - } + cv.Any( + cv.All(cv.string_strict, _parse_idf_component), + cv.Schema( + { + cv.Required(CONF_NAME): cv.string_strict, + cv.Optional(CONF_SOURCE): cv.git_ref, + cv.Optional(CONF_REF): cv.string, + cv.Optional(CONF_PATH): cv.string, + cv.Optional(CONF_REFRESH): cv.All( + cv.string, cv.source_refresh + ), + } + ), ), _validate_idf_component, ) @@ -851,6 +882,18 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_yaml_idf_components(components: list[ConfigType]): + """Add IDF components from YAML config with final priority to override code-added components.""" + for component in components: + add_idf_component( + name=component[CONF_NAME], + repo=component.get(CONF_SOURCE), + ref=component.get(CONF_REF), + path=component.get(CONF_PATH), + ) + + async def to_code(config): cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) @@ -1097,13 +1140,10 @@ async def to_code(config): for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) - for component in conf[CONF_COMPONENTS]: - add_idf_component( - name=component[CONF_NAME], - repo=component.get(CONF_SOURCE), - ref=component.get(CONF_REF), - path=component.get(CONF_PATH), - ) + # Components from YAML are added in a separate coroutine with FINAL priority + # Schedule it to run after all other components + if conf[CONF_COMPONENTS]: + CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS]) APP_PARTITION_SIZES = { diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index a4c930f236..1c243ef459 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -4,6 +4,10 @@ esp32: cpu_frequency: 400MHz framework: type: esp-idf + components: + - espressif/mdns^1.8.2 + - name: espressif/esp_hosted + ref: 2.6.6 advanced: enable_idf_experimental_features: yes From 91df0548efc10423b5785d45d6c830178ed3dc56 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Fri, 28 Nov 2025 02:30:03 +1100 Subject: [PATCH 158/320] [wifi] Restore blocking setup until connected for RP2040 (#12142) --- esphome/components/wifi/wifi_component.cpp | 14 ++++++++++++++ esphome/components/wifi/wifi_component.h | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d53de83bd3..e67493aa4d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1612,6 +1612,20 @@ void WiFiComponent::retry_connect() { } } +#ifdef USE_RP2040 +// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart +// mDNS when the network interface reconnects. However, this callback is disabled +// in the arduino-pico framework. As a workaround, we block component setup until +// WiFi is connected, ensuring mDNS.begin() is called with an active connection. + +bool WiFiComponent::can_proceed() { + if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) { + return true; + } + return this->is_connected(); +} +#endif + void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool WiFiComponent::is_connected() { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index b6b956a12d..a9b03a8b8d 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -284,6 +284,10 @@ class WiFiComponent : public Component { void retry_connect(); +#ifdef USE_RP2040 + bool can_proceed() override; +#endif + void set_reboot_timeout(uint32_t reboot_timeout); bool is_connected(); From 1fadd1227d531ddb611ec58d9c189f0821cfe796 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Nov 2025 10:50:21 -0600 Subject: [PATCH 159/320] [scheduler] Fix use-after-move crash in heap operations (#12124) --- esphome/core/scheduler.cpp | 26 +++++++++++++------------- esphome/core/scheduler.h | 4 +++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 09d50ee7c8..352587bf10 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) { std::unique_ptr item; { LockGuard guard{this->lock_}; - item = std::move(this->items_[0]); - this->pop_raw_(); + item = this->pop_raw_locked_(); } const char *name = item->get_name(); @@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) { // Don't run on failed components if (item->component != nullptr && item->component->is_failed()) { LockGuard guard{this->lock_}; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); continue; } @@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) { // Single-threaded or multi-threaded with atomics: can check without lock if (is_item_removed_(item.get())) { LockGuard guard{this->lock_}; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; - auto executed_item = std::move(this->items_[0]); // Only pop after function call, this ensures we were reachable // during the function call and know if we were cancelled. - this->pop_raw_(); + auto executed_item = this->pop_raw_locked_(); if (executed_item->remove) { - // We were removed/cancelled in the function call, stop + // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; + this->recycle_item_(std::move(executed_item)); continue; } @@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() { return this->items_.size(); // We must hold the lock for the entire cleanup operation because: - // 1. We're modifying items_ (via pop_raw_) which requires exclusive access + // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access // 2. We're decrementing to_remove_ which is also modified by other threads // (though all modifications are already under lock) // 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_() @@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); } return this->items_.size(); } -void HOT Scheduler::pop_raw_() { +std::unique_ptr HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - // Instead of destroying, recycle the item - this->recycle_item_(std::move(this->items_.back())); + // Move the item out before popping - this is the item that was at the front of the heap + auto item = std::move(this->items_.back()); this->items_.pop_back(); + return item; } // Helper to execute a scheduler item diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index bea1503df0..08e003c9fb 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -219,7 +219,9 @@ class Scheduler { // Returns the number of items remaining after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). size_t cleanup_(); - void pop_raw_(); + // Remove and return the front item from the heap + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + std::unique_ptr pop_raw_locked_(); private: // Helper to cancel items by name - must be called with lock held From 4c549798bc9faca98036748b220d122fe55eccd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Nov 2025 16:33:08 -0600 Subject: [PATCH 160/320] [usb_uart] Wake main loop immediately when USB data arrives (#12148) --- esphome/components/usb_uart/__init__.py | 7 ++++++- esphome/components/usb_uart/usb_uart.cpp | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a852e1f78b..d9bb58ae3a 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components import socket from esphome.components.uart import ( CONF_DATA_BITS, CONF_PARITY, @@ -17,7 +18,7 @@ from esphome.const import ( ) from esphome.cpp_types import Component -AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] +AUTO_LOAD = ["uart", "usb_host", "bytebuffer", "socket"] CODEOWNERS = ["@clydebarrow"] usb_uart_ns = cg.esphome_ns.namespace("usb_uart") @@ -116,6 +117,10 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): + # Enable wake_loop_threadsafe for low-latency USB data processing + # The USB task queues data events that need immediate processing + socket.require_wake_loop_threadsafe() + for device in config: var = await register_usb_client(device) for index, channel in enumerate(device[CONF_CHANNELS]): diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 6720c1e690..fefccd3645 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_debugger.h" #include @@ -262,6 +263,11 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // Push to lock-free queue for main loop processing // Push always succeeds because pool size == queue size this->usb_data_queue_.push(chunk); + + // Wake main loop immediately to process USB data instead of waiting for select() timeout +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif } // On success, restart input immediately from USB task for performance From 71dc402a30190fa91af8d5d55d48f697a121ba64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Nov 2025 22:00:33 -0600 Subject: [PATCH 161/320] [logger] Replace std::function callbacks with LogListener interface (#12153) --- esphome/components/api/api_server.cpp | 29 ++++++----- esphome/components/api/api_server.h | 14 +++++- esphome/components/ble_nus/ble_nus.cpp | 18 ++++--- esphome/components/ble_nus/ble_nus.h | 13 ++++- esphome/components/logger/logger.cpp | 11 ++--- esphome/components/logger/logger.h | 51 ++++++++++++++------ esphome/components/mqtt/mqtt_client.cpp | 22 +++++---- esphome/components/mqtt/mqtt_client.h | 14 +++++- esphome/components/syslog/esphome_syslog.cpp | 9 ++-- esphome/components/syslog/esphome_syslog.h | 4 +- esphome/components/web_server/web_server.cpp | 17 ++++--- esphome/components/web_server/web_server.h | 16 +++++- 12 files changed, 153 insertions(+), 65 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 64f8751c35..de0c4b24c9 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -101,19 +101,7 @@ void APIServer::setup() { #ifdef USE_LOGGER if (logger::global_logger != nullptr) { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - if (this->shutting_down_) { - // Don't try to send logs during shutdown - // as it could result in a recursion and - // we would be filling a buffer we are trying to clear - return; - } - for (auto &c : this->clients_) { - if (!c->flags_.remove && c->get_log_subscription_level() >= level) - c->try_send_log_message(level, tag, message, message_len); - } - }); + logger::global_logger->add_log_listener(this); } #endif @@ -541,6 +529,21 @@ bool APIServer::is_connected(bool state_subscription_only) const { return false; } +#ifdef USE_LOGGER +void APIServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + if (this->shutting_down_) { + // Don't try to send logs during shutdown + // as it could result in a recursion and + // we would be filling a buffer we are trying to clear + return; + } + for (auto &c : this->clients_) { + if (!c->flags_.remove && c->get_log_subscription_level() >= level) + c->try_send_log_message(level, tag, message, message_len); + } +} +#endif + void APIServer::on_shutdown() { this->shutting_down_ = true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 428429418a..57aea6ad0e 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -15,6 +15,9 @@ #ifdef USE_API_USER_DEFINED_ACTIONS #include "user_services.h" #endif +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #include #include @@ -27,7 +30,13 @@ struct SavedNoisePsk { } PACKED; // NOLINT #endif -class APIServer : public Component, public Controller { +class APIServer : public Component, + public Controller +#ifdef USE_LOGGER + , + public logger::LogListener +#endif +{ public: APIServer(); void setup() override; @@ -37,6 +46,9 @@ class APIServer : public Component, public Controller { void dump_config() override; void on_shutdown() override; bool teardown() override; +#ifdef USE_LOGGER + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; +#endif #ifdef USE_API_PASSWORD bool check_password(const uint8_t *password_data, size_t password_len) const; void set_password(const std::string &password); diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index 9c4d0a3938..bd80592d89 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -87,17 +87,21 @@ void BLENUS::setup() { global_ble_nus = this; #ifdef USE_LOGGER if (logger::global_logger != nullptr && this->expose_log_) { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - this->write_array(reinterpret_cast(message), message_len); - const char c = '\n'; - this->write_array(reinterpret_cast(&c), 1); - }); + logger::global_logger->add_log_listener(this); } - #endif } +#ifdef USE_LOGGER +void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + (void) level; + (void) tag; + this->write_array(reinterpret_cast(message), message_len); + const char c = '\n'; + this->write_array(reinterpret_cast(&c), 1); +} +#endif + void BLENUS::dump_config() { ESP_LOGCONFIG(TAG, "ble nus:"); ESP_LOGCONFIG(TAG, " log: %s", YESNO(this->expose_log_)); diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index e8cba32b4c..ef20fc5e5b 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -2,12 +2,20 @@ #ifdef USE_ZEPHYR #include "esphome/core/defines.h" #include "esphome/core/component.h" +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #include #include namespace esphome::ble_nus { -class BLENUS : public Component { +class BLENUS : public Component +#ifdef USE_LOGGER + , + public logger::LogListener +#endif +{ enum TxStatus { TX_DISABLED, TX_ENABLED, @@ -20,6 +28,9 @@ class BLENUS : public Component { void loop() override; size_t write_array(const uint8_t *data, size_t len); void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } +#ifdef USE_LOGGER + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; +#endif protected: static void send_enabled_callback(bt_nus_send_status status); diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 9803bf528c..f925e85e11 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -140,8 +140,9 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas uint16_t msg_length = this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position - // Callbacks get message first (before console write) - this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start, msg_length); + // Listeners get message first (before console write) + for (auto *listener : this->log_listeners_) + listener->on_log(level, tag, this->tx_buffer_ + msg_start, msg_length); // Write to console starting at the msg_start this->write_tx_buffer_to_console_(msg_start, &msg_length); @@ -203,7 +204,8 @@ void Logger::process_messages_() { this->write_footer_to_buffer_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); this->tx_buffer_[this->tx_buffer_at_] = '\0'; size_t msg_len = this->tx_buffer_at_; // We already know the length from tx_buffer_at_ - this->log_callback_.call(message->level, message->tag, this->tx_buffer_, msg_len); + for (auto *listener : this->log_listeners_) + listener->on_log(message->level, message->tag, this->tx_buffer_, msg_len); // At this point all the data we need from message has been transferred to the tx_buffer // so we can release the message to allow other tasks to use it as soon as possible. this->log_buffer_->release_message_main_loop(received_token); @@ -231,9 +233,6 @@ void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_level UARTSelection Logger::get_uart() const { return this->uart_; } #endif -void Logger::add_on_log_callback(std::function &&callback) { - this->log_callback_.add(std::move(callback)); -} float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } #ifdef USE_STORE_LOG_STR_IN_FLASH diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 6a8b640331..a0024411d7 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -36,6 +36,28 @@ struct device; namespace esphome::logger { +/** Interface for receiving log messages without std::function overhead. + * + * Components can implement this interface instead of using lambdas with std::function + * to reduce flash usage from std::function type erasure machinery. + * + * Usage: + * class MyComponent : public Component, public LogListener { + * public: + * void setup() override { + * if (logger::global_logger != nullptr) + * logger::global_logger->add_log_listener(this); + * } + * void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override { + * // Handle log message + * } + * }; + */ +class LogListener { + public: + virtual void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) = 0; +}; + #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Comparison function for const char* keys in log_levels_ map struct CStrCompare { @@ -168,8 +190,8 @@ class Logger : public Component { inline uint8_t level_for(const char *tag); - /// Register a callback that will be called for every log message sent - void add_on_log_callback(std::function &&callback); + /// Register a log listener to receive log messages + void add_log_listener(LogListener *listener) { this->log_listeners_.push_back(listener); } // add a listener for log level changes void add_listener(std::function &&callback) { this->level_callback_.add(std::move(callback)); } @@ -240,7 +262,7 @@ class Logger : public Component { } } - // Helper to format and send a log message to both console and callbacks + // Helper to format and send a log message to both console and listeners inline void HOT log_message_to_buffer_and_send_(uint8_t level, const char *tag, int line, const char *format, va_list args) { // Format to tx_buffer and prepare for output @@ -248,8 +270,9 @@ class Logger : public Component { this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - // Callbacks get message WITHOUT newline (for API/MQTT/syslog) - this->log_callback_.call(level, tag, this->tx_buffer_, this->tx_buffer_at_); + // Listeners get message WITHOUT newline (for API/MQTT/syslog) + for (auto *listener : this->log_listeners_) + listener->on_log(level, tag, this->tx_buffer_, this->tx_buffer_at_); // Console gets message WITH newline (if platform needs it) this->write_tx_buffer_to_console_(); @@ -301,7 +324,7 @@ class Logger : public Component { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS std::map log_levels_{}; #endif - CallbackManager log_callback_{}; + std::vector log_listeners_; // Log message listeners (API, MQTT, syslog, etc.) CallbackManager level_callback_{}; #ifdef USE_ESPHOME_TASK_LOG_BUFFER std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer @@ -496,15 +519,15 @@ class Logger : public Component { }; extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class LoggerMessageTrigger : public Trigger { +class LoggerMessageTrigger : public Trigger, public LogListener { public: - explicit LoggerMessageTrigger(Logger *parent, uint8_t level) { - this->level_ = level; - parent->add_on_log_callback([this](uint8_t level, const char *tag, const char *message, size_t message_len) { - if (level <= this->level_) { - this->trigger(level, tag, message); - } - }); + explicit LoggerMessageTrigger(Logger *parent, uint8_t level) : level_(level) { parent->add_log_listener(this); } + + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override { + (void) message_len; + if (level <= this->level_) { + this->trigger(level, tag, message); + } } protected: diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index a810d98adf..ba701b90a3 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -57,15 +57,7 @@ void MQTTClientComponent::setup() { }); #ifdef USE_LOGGER if (this->is_log_message_enabled() && logger::global_logger != nullptr) { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - if (level <= this->log_level_ && this->is_connected()) { - this->publish({.topic = this->log_message_.topic, - .payload = std::string(message, message_len), - .qos = this->log_message_.qos, - .retain = this->log_message_.retain}); - } - }); + logger::global_logger->add_log_listener(this); } #endif @@ -148,6 +140,18 @@ void MQTTClientComponent::send_device_info_() { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } +#ifdef USE_LOGGER +void MQTTClientComponent::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + (void) tag; + if (level <= this->log_level_ && this->is_connected()) { + this->publish({.topic = this->log_message_.topic, + .payload = std::string(message, message_len), + .qos = this->log_message_.qos, + .retain = this->log_message_.retain}); + } +} +#endif + void MQTTClientComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT:\n" diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 79383ee857..8547fe337f 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -10,6 +10,9 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #if defined(USE_ESP32) #include "mqtt_backend_esp32.h" #elif defined(USE_ESP8266) @@ -97,7 +100,12 @@ enum MQTTClientState { class MQTTComponent; -class MQTTClientComponent : public Component { +class MQTTClientComponent : public Component +#ifdef USE_LOGGER + , + public logger::LogListener +#endif +{ public: MQTTClientComponent(); @@ -238,6 +246,10 @@ class MQTTClientComponent : public Component { /// MQTT client setup priority float get_setup_priority() const override; +#ifdef USE_LOGGER + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; +#endif + void on_message(const std::string &topic, const std::string &payload); bool can_proceed() override; diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 71468fa932..f5c20c891e 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -19,11 +19,10 @@ constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { 7 // VERY_VERBOSE }; -void Syslog::setup() { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - this->log_(level, tag, message, message_len); - }); +void Syslog::setup() { logger::global_logger->add_log_listener(this); } + +void Syslog::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + this->log_(level, tag, message, message_len); } void Syslog::log_(const int level, const char *tag, const char *message, size_t message_len) const { diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index e3b2f7dae5..1010993265 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -2,16 +2,18 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/components/logger/logger.h" #include "esphome/components/udp/udp_component.h" #include "esphome/components/time/real_time_clock.h" #ifdef USE_NETWORK namespace esphome { namespace syslog { -class Syslog : public Component, public Parented { +class Syslog : public Component, public Parented, public logger::LogListener { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} void setup() override; + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; void set_strip(bool strip) { this->strip_ = strip; } void set_facility(int facility) { this->facility_ = facility; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6bf6524fbc..f5ca674161 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -301,12 +301,7 @@ void WebServer::setup() { #ifdef USE_LOGGER if (logger::global_logger != nullptr && this->expose_log_) { - logger::global_logger->add_on_log_callback( - // logs are not deferred, the memory overhead would be too large - [this](int level, const char *tag, const char *message, size_t message_len) { - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); - }); + logger::global_logger->add_log_listener(this); } #endif @@ -322,6 +317,16 @@ void WebServer::setup() { this->set_interval(10000, [this]() { this->events_.try_send_nodefer("", "ping", millis(), 30000); }); } void WebServer::loop() { this->events_.loop(); } + +#ifdef USE_LOGGER +void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + (void) level; + (void) tag; + (void) message_len; + this->events_.try_send_nodefer(message, "log", millis()); +} +#endif + void WebServer::dump_config() { ESP_LOGCONFIG(TAG, "Web Server:\n" diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 7e1af88645..52cf0bedea 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -7,6 +7,9 @@ #include "esphome/core/component.h" #include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #include #include @@ -170,7 +173,14 @@ class DeferredUpdateEventSourceList : public std::list Date: Thu, 27 Nov 2025 22:09:27 -0600 Subject: [PATCH 162/320] [light] Replace sparse enum switch with linear search to save 156 bytes RAM (#12140) --- .../components/light/light_json_schema.cpp | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 1c9b92f504..41cb855630 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -7,30 +7,29 @@ namespace esphome::light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema -// Lookup table for color mode strings -static constexpr const char *get_color_mode_json_str(ColorMode mode) { - switch (mode) { - case ColorMode::ON_OFF: - return "onoff"; - case ColorMode::BRIGHTNESS: - return "brightness"; - case ColorMode::WHITE: - return "white"; // not supported by HA in MQTT - case ColorMode::COLOR_TEMPERATURE: - return "color_temp"; - case ColorMode::COLD_WARM_WHITE: - return "cwww"; // not supported by HA - case ColorMode::RGB: - return "rgb"; - case ColorMode::RGB_WHITE: - return "rgbw"; - case ColorMode::RGB_COLOR_TEMPERATURE: - return "rgbct"; // not supported by HA - case ColorMode::RGB_COLD_WARM_WHITE: - return "rgbww"; - default: - return nullptr; +// Get JSON string for color mode using linear search (avoids large switch jump table) +static const char *get_color_mode_json_str(ColorMode mode) { + // Parallel arrays: mode values and their corresponding strings + // Uses less RAM than a switch jump table on sparse enum values + static constexpr ColorMode MODES[] = { + ColorMode::ON_OFF, + ColorMode::BRIGHTNESS, + ColorMode::WHITE, + ColorMode::COLOR_TEMPERATURE, + ColorMode::COLD_WARM_WHITE, + ColorMode::RGB, + ColorMode::RGB_WHITE, + ColorMode::RGB_COLOR_TEMPERATURE, + ColorMode::RGB_COLD_WARM_WHITE, + }; + static constexpr const char *STRINGS[] = { + "onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct", "rgbww", + }; + for (size_t i = 0; i < sizeof(MODES) / sizeof(MODES[0]); i++) { + if (MODES[i] == mode) + return STRINGS[i]; } + return nullptr; } void LightJSONSchema::dump_json(LightState &state, JsonObject root) { From e1ec6146c0d19d7d4dc021ed2afdd96d8d492518 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Nov 2025 22:09:41 -0600 Subject: [PATCH 163/320] [wifi] Save 112 bytes BSS on ESP8266 by calling SDK directly for BSSID (#12137) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/wifi/wifi_component_esp8266.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 540ad3a585..2d1f979c83 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -878,10 +878,9 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; - uint8_t *raw_bssid = WiFi.BSSID(); - if (raw_bssid != nullptr) { - for (size_t i = 0; i < bssid.size(); i++) - bssid[i] = raw_bssid[i]; + struct station_config conf {}; + if (wifi_station_get_config(&conf)) { + std::copy_n(conf.bssid, bssid.size(), bssid.begin()); } return bssid; } From 60ffa0e52ef4a83c23052467e53c9d5d3ab20b29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 11:27:08 -0600 Subject: [PATCH 164/320] [esp32_ble_tracker] Replace scanner state callback with listener interface (#12156) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 12 +++++++----- .../bluetooth_proxy/bluetooth_proxy.h | 7 ++++++- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 4 +++- .../esp32_ble_tracker/esp32_ble_tracker.h | 19 +++++++++++++++---- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 71f8da75a7..d45377b3f6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -27,11 +27,13 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->parent_->get_scan_active(); - this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } - }); + this->parent_->add_scanner_state_listener(this); +} + +void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { + if (this->api_connection_ != nullptr) { + this->send_bluetooth_scanner_state_(state); + } } void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4363c508ec..ab9aee2d81 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -52,7 +52,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, + public esp32_ble_tracker::BLEScannerStateListener, + public Component { friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); @@ -108,6 +110,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } + /// BLEScannerStateListener interface + void on_scanner_state(esp32_ble_tracker::ScannerState state) override; + uint32_t get_legacy_version() const { if (this->active_) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8577f12a92..d3c5edfb94 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -373,7 +373,9 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; - this->scanner_state_callbacks_.call(state); + for (auto *listener : this->scanner_state_listeners_) { + listener->on_scanner_state(state); + } } #ifdef USE_ESP32_BLE_DEVICE diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index f80f3e2670..92d13a62ad 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -180,6 +180,16 @@ enum class ScannerState { STOPPING, }; +/** Listener interface for BLE scanner state changes. + * + * Components can implement this interface to receive scanner state updates + * without the overhead of std::function callbacks. + */ +class BLEScannerStateListener { + public: + virtual void on_scanner_state(ScannerState state) = 0; +}; + // Helper function to convert ClientState to string const char *client_state_to_string(ClientState state); @@ -264,8 +274,9 @@ class ESP32BLETracker : public Component, void gap_scan_event_handler(const BLEScanResult &scan_result) override; void ble_before_disabled_event_handler() override; - void add_scanner_state_callback(std::function &&callback) { - this->scanner_state_callbacks_.add(std::move(callback)); + /// Add a listener for scanner state changes + void add_scanner_state_listener(BLEScannerStateListener *listener) { + this->scanner_state_listeners_.push_back(listener); } ScannerState get_scanner_state() const { return this->scanner_state_; } @@ -322,14 +333,14 @@ class ESP32BLETracker : public Component, return counts; } - // Group 1: Large objects (12+ bytes) - vectors and callback manager + // Group 1: Large objects (12+ bytes) - vectors #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT StaticVector listeners_; #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - CallbackManager scanner_state_callbacks_; + std::vector scanner_state_listeners_; #ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; From 26e979d3d5a1d80c479ba4729a37875d8580ec64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 11:27:17 -0600 Subject: [PATCH 165/320] [wifi] Replace std::function callbacks with listener interfaces (#12155) --- esphome/components/wifi/__init__.py | 16 ++-- esphome/components/wifi/wifi_component.h | 73 +++++++++++++------ .../wifi/wifi_component_esp8266.cpp | 28 ++++--- .../wifi/wifi_component_esp_idf.cpp | 30 +++++--- .../wifi/wifi_component_libretiny.cpp | 30 +++++--- .../components/wifi/wifi_component_pico_w.cpp | 24 ++++-- esphome/components/wifi_info/text_sensor.py | 6 +- .../wifi_info/wifi_info_text_sensor.cpp | 46 ++++-------- .../wifi_info/wifi_info_text_sensor.h | 36 +++++---- esphome/core/defines.h | 2 +- 10 files changed, 172 insertions(+), 119 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 31d9ca0f70..2c10506011 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -608,7 +608,7 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" -WIFI_CALLBACKS_KEY = "wifi_callbacks" +WIFI_LISTENERS_KEY = "wifi_listeners" def request_wifi_scan_results(): @@ -634,15 +634,15 @@ def enable_runtime_power_save_control(): CORE.data[RUNTIME_POWER_SAVE_KEY] = True -def request_wifi_callbacks() -> None: - """Request that WiFi callbacks be compiled in. +def request_wifi_listeners() -> None: + """Request that WiFi state listeners be compiled in. Components that need to be notified about WiFi state changes (IP address changes, scan results, connection state) should call this function during their code generation. - This enables the add_on_ip_state_callback(), add_on_wifi_scan_state_callback(), - and add_on_wifi_connect_state_callback() APIs. + This enables the add_ip_state_listener(), add_scan_results_listener(), + and add_connect_state_listener() APIs. """ - CORE.data[WIFI_CALLBACKS_KEY] = True + CORE.data[WIFI_LISTENERS_KEY] = True @coroutine_with_priority(CoroPriority.FINAL) @@ -654,8 +654,8 @@ async def final_step(): ) if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") - if CORE.data.get(WIFI_CALLBACKS_KEY, False): - cg.add_define("USE_WIFI_CALLBACKS") + if CORE.data.get(WIFI_LISTENERS_KEY, False): + cg.add_define("USE_WIFI_LISTENERS") @automation.register_action( diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a9b03a8b8d..97cc3961fe 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -242,6 +242,37 @@ enum WifiMinAuthMode : uint8_t { struct IDFWiFiEvent; #endif +/** Listener interface for WiFi IP state changes. + * + * Components can implement this interface to receive IP address updates + * without the overhead of std::function callbacks. + */ +class WiFiIPStateListener { + public: + virtual void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) = 0; +}; + +/** Listener interface for WiFi scan results. + * + * Components can implement this interface to receive scan results + * without the overhead of std::function callbacks. + */ +class WiFiScanResultsListener { + public: + virtual void on_wifi_scan_results(const wifi_scan_vector_t &results) = 0; +}; + +/** Listener interface for WiFi connection state changes. + * + * Components can implement this interface to receive connection updates + * without the overhead of std::function callbacks. + */ +class WiFiConnectStateListener { + public: + virtual void on_wifi_connect_state(const std::string &ssid, const bssid_t &bssid) = 0; +}; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent : public Component { public: @@ -373,26 +404,22 @@ class WiFiComponent : public Component { int32_t get_wifi_channel(); -#ifdef USE_WIFI_CALLBACKS - /// Add a callback that will be called on configuration changes (IP change, SSID change, etc.) - /// @param callback The callback to be called; template arguments are: - /// - IP addresses - /// - DNS address 1 - /// - DNS address 2 - void add_on_ip_state_callback( - std::function &&callback) { - this->ip_state_callback_.add(std::move(callback)); +#ifdef USE_WIFI_LISTENERS + /** Add a listener for IP state changes. + * Listener receives: IP addresses, DNS address 1, DNS address 2 + */ + void add_ip_state_listener(WiFiIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } + /// Add a listener for WiFi scan results + void add_scan_results_listener(WiFiScanResultsListener *listener) { + this->scan_results_listeners_.push_back(listener); } - /// - Wi-Fi scan results - void add_on_wifi_scan_state_callback(std::function &)> &&callback) { - this->wifi_scan_state_callback_.add(std::move(callback)); + /** Add a listener for WiFi connection state changes. + * Listener receives: SSID, BSSID + */ + void add_connect_state_listener(WiFiConnectStateListener *listener) { + this->connect_state_listeners_.push_back(listener); } - /// - Wi-Fi SSID - /// - Wi-Fi BSSID - void add_on_wifi_connect_state_callback(std::function &&callback) { - this->wifi_connect_state_callback_.add(std::move(callback)); - } -#endif // USE_WIFI_CALLBACKS +#endif // USE_WIFI_LISTENERS #ifdef USE_WIFI_RUNTIME_POWER_SAVE /** Request high-performance mode (no power saving) for improved WiFi latency. @@ -550,11 +577,11 @@ class WiFiComponent : public Component { WiFiAP ap_; #endif optional output_power_; -#ifdef USE_WIFI_CALLBACKS - CallbackManager ip_state_callback_; - CallbackManager &)> wifi_scan_state_callback_; - CallbackManager wifi_connect_state_callback_; -#endif // USE_WIFI_CALLBACKS +#ifdef USE_WIFI_LISTENERS + std::vector ip_state_listeners_; + std::vector scan_results_listeners_; + std::vector connect_state_listeners_; +#endif // USE_WIFI_LISTENERS ESPPreferenceObject pref_; #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 2d1f979c83..701cae5f7c 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -513,9 +513,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=%s channel=%u", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel); s_sta_connected = true; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->wifi_connect_state_callback_.call(global_wifi_component->wifi_ssid(), - global_wifi_component->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->connect_state_listeners_) { + listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid()); + } #endif break; } @@ -536,8 +537,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { } s_sta_connected = false; s_sta_connecting = false; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif break; } @@ -561,10 +564,11 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", format_ip_addr(it.ip).c_str(), format_ip_addr(it.gw).c_str(), format_ip_addr(it.mask).c_str()); s_sta_got_ip = true; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->ip_state_callback_.call(global_wifi_component->wifi_sta_ip_addresses(), - global_wifi_component->get_dns_address(0), - global_wifi_component->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->ip_state_listeners_) { + listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0), + global_wifi_component->get_dns_address(1)); + } #endif break; } @@ -740,8 +744,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { it->is_hidden != 0); } this->scan_done_ = true; -#ifdef USE_WIFI_CALLBACKS - global_wifi_component->wifi_scan_state_callback_.call(global_wifi_component->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->scan_results_listeners_) { + listener->on_wifi_scan_results(global_wifi_component->scan_result_); + } #endif } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c20c96ced0..3d25d2890f 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -727,8 +727,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); s_sta_connected = true; -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { @@ -753,8 +755,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { @@ -764,8 +768,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif /* USE_NETWORK_IPV6 */ ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif #if USE_NETWORK_IPV6 @@ -773,8 +779,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip6; ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip)); this->num_ipv6_addresses_++; -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif #endif /* USE_NETWORK_IPV6 */ @@ -815,8 +823,10 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid.empty()); } -#ifdef USE_WIFI_CALLBACKS - this->wifi_scan_state_callback_.call(this->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 04d0d4fa85..f1405d3bef 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -287,8 +287,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ buf[it.ssid_len] = '\0'; ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } #endif break; } @@ -315,8 +317,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } s_sta_connecting = false; -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif break; } @@ -339,16 +343,20 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(WiFi.localIP()).c_str(), format_ip4_addr(WiFi.gatewayIP()).c_str()); s_sta_connecting = false; -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif break; } case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { // auto it = info.got_ip.ip_info; ESP_LOGV(TAG, "Got IPv6"); -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif break; } @@ -443,8 +451,10 @@ void WiFiComponent::wifi_scan_done_callback_() { } WiFi.scanDelete(); this->scan_done_ = true; -#ifdef USE_WIFI_CALLBACKS - this->wifi_scan_state_callback_.call(this->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } #endif } diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 326883c0c4..1a8b75213c 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -225,8 +225,10 @@ void WiFiComponent::wifi_loop_() { if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { this->scan_done_ = true; ESP_LOGV(TAG, "Scan done"); -#ifdef USE_WIFI_CALLBACKS - this->wifi_scan_state_callback_.call(this->scan_result_); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } #endif } @@ -241,16 +243,20 @@ void WiFiComponent::wifi_loop_() { // Just connected s_sta_was_connected = true; ESP_LOGV(TAG, "Connected"); -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call(this->wifi_ssid(), this->wifi_bssid()); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } #endif } else if (!is_connected && s_sta_was_connected) { // Just disconnected s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); -#ifdef USE_WIFI_CALLBACKS - this->wifi_connect_state_callback_.call("", bssid_t({0, 0, 0, 0, 0, 0})); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } #endif } @@ -267,8 +273,10 @@ void WiFiComponent::wifi_loop_() { // Just got IP address s_sta_had_ip = true; ESP_LOGV(TAG, "Got IP address"); -#ifdef USE_WIFI_CALLBACKS - this->ip_state_callback_.call(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } #endif } } diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index 0feee3d4a9..bc0c038f80 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.Schema( } ) -# Keys that require WiFi callbacks +# Keys that require WiFi listeners _NETWORK_INFO_KEYS = { CONF_SSID, CONF_BSSID, @@ -79,9 +79,9 @@ async def setup_conf(config, key): async def to_code(config): - # Request WiFi callbacks for any sensor that needs them + # Request WiFi listeners for any sensor that needs them if _NETWORK_INFO_KEYS.intersection(config): - wifi.request_wifi_callbacks() + wifi.request_wifi_listeners() await setup_conf(config, CONF_SSID) await setup_conf(config, CONF_BSSID) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index abd590b168..92d3ea29f5 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -12,16 +12,12 @@ static constexpr size_t MAX_STATE_LENGTH = 255; * IPAddressWiFiInfo *******************/ -void IPAddressWiFiInfo::setup() { - wifi::global_wifi_component->add_on_ip_state_callback( - [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { - this->state_callback_(ips); - }); -} +void IPAddressWiFiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); } void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this); } -void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { +void IPAddressWiFiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { this->publish_state(ips[0].str()); uint8_t sensor = 0; for (const auto &ip : ips) { @@ -38,17 +34,13 @@ void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { * DNSAddressWifiInfo ********************/ -void DNSAddressWifiInfo::setup() { - wifi::global_wifi_component->add_on_ip_state_callback( - [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { - this->state_callback_(dns1_ip, dns2_ip); - }); -} +void DNSAddressWifiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); } void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); } -void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { - std::string dns_results = dns1_ip.str() + " " + dns2_ip.str(); +void DNSAddressWifiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { + std::string dns_results = dns1.str() + " " + dns2.str(); this->publish_state(dns_results); } @@ -56,14 +48,11 @@ void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, cons * ScanResultsWiFiInfo *********************/ -void ScanResultsWiFiInfo::setup() { - wifi::global_wifi_component->add_on_wifi_scan_state_callback( - [this](const wifi::wifi_scan_vector_t &results) { this->state_callback_(results); }); -} +void ScanResultsWiFiInfo::setup() { wifi::global_wifi_component->add_scan_results_listener(this); } void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); } -void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_t &results) { +void ScanResultsWiFiInfo::on_wifi_scan_results(const wifi::wifi_scan_vector_t &results) { std::string scan_results; for (const auto &scan : results) { if (scan.get_is_hidden()) @@ -85,33 +74,30 @@ void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_tadd_on_wifi_connect_state_callback( - [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(ssid); }); -} +void SSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); } void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } -void SSIDWiFiInfo::state_callback_(const std::string &ssid) { this->publish_state(ssid); } +void SSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) { + this->publish_state(ssid); +} /**************** * BSSIDWiFiInfo ***************/ -void BSSIDWiFiInfo::setup() { - wifi::global_wifi_component->add_on_wifi_connect_state_callback( - [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(bssid); }); -} +void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); } void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } -void BSSIDWiFiInfo::state_callback_(const wifi::bssid_t &bssid) { +void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) { char buf[18] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); } this->publish_state(buf); } + /********************* * MacAddressWifiInfo ********************/ diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 12666b4059..74d951f922 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -9,55 +9,61 @@ namespace esphome::wifi_info { -class IPAddressWiFiInfo : public Component, public text_sensor::TextSensor { +class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } + // WiFiIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; + protected: - void state_callback_(const network::IPAddresses &ips); std::array ip_sensors_; }; -class DNSAddressWifiInfo : public Component, public text_sensor::TextSensor { +class DNSAddressWifiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: void setup() override; void dump_config() override; - protected: - void state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip); + // WiFiIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; }; -class ScanResultsWiFiInfo : public Component, public text_sensor::TextSensor { +class ScanResultsWiFiInfo final : public Component, + public text_sensor::TextSensor, + public wifi::WiFiScanResultsListener { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void dump_config() override; - protected: - void state_callback_(const wifi::wifi_scan_vector_t &results); + // WiFiScanResultsListener interface + void on_wifi_scan_results(const wifi::wifi_scan_vector_t &results) override; }; -class SSIDWiFiInfo : public Component, public text_sensor::TextSensor { +class SSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener { public: void setup() override; void dump_config() override; - protected: - void state_callback_(const std::string &ssid); + // WiFiConnectStateListener interface + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; -class BSSIDWiFiInfo : public Component, public text_sensor::TextSensor { +class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener { public: void setup() override; void dump_config() override; - protected: - void state_callback_(const wifi::bssid_t &bssid); + // WiFiConnectStateListener interface + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; -class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { +class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { char mac_s[18]; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1373ea6366..f4026aad96 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -210,7 +210,7 @@ #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT -#define USE_WIFI_CALLBACKS +#define USE_WIFI_LISTENERS #define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 From fb82362e9cbd97e09b5eb1cee5e4d5840e038ff0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 12:13:29 -0600 Subject: [PATCH 166/320] [api] Eliminate rx_buf heap churn and release buffers after initial sync (#12133) --- esphome/components/api/api_connection.cpp | 6 ++++-- esphome/components/api/api_connection.h | 15 +++++++++---- esphome/components/api/api_frame_helper.h | 21 ++++++++++++++++--- .../components/api/api_frame_helper_noise.cpp | 3 +-- .../api/api_frame_helper_plaintext.cpp | 3 +-- esphome/components/api/api_pb2_service.cpp | 4 ++-- esphome/components/api/api_pb2_service.h | 4 ++-- esphome/components/api/proto.h | 2 +- script/api_protobuf/api_protobuf.py | 8 +++---- 9 files changed, 44 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 12cbbb991d..9ad45dc6b7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -169,8 +169,7 @@ void APIConnection::loop() { } else { this->last_traffic_ = now; // read a packet - this->read_message(buffer.data_len, buffer.type, - buffer.data_len > 0 ? &buffer.container[buffer.data_offset] : nullptr); + this->read_message(buffer.data_len, buffer.type, buffer.data); if (this->flags_.remove) return; } @@ -195,6 +194,9 @@ void APIConnection::loop() { } // Now that everything is sent, enable immediate sending for future state changes this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); } } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index af3a19909f..05af0ccde7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -554,10 +554,8 @@ class APIConnection final : public APIServerConnection { std::vector items; uint32_t batch_start_time{0}; - DeferredBatch() { - // Pre-allocate capacity for typical batch sizes to avoid reallocation - items.reserve(8); - } + // No pre-allocation - log connections never use batching, and for + // connections that do, buffers are released after initial sync anyway // Add item to the batch void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); @@ -576,6 +574,15 @@ class APIConnection final : public APIServerConnection { bool empty() const { return items.empty(); } size_t size() const { return items.size(); } const BatchItem &operator[](size_t index) const { return items[index]; } + // Release excess capacity - only releases if items already empty + void release_buffer() { + // Safe to call: batch is processed before release_buffer is called, + // and if any items remain (partial processing), we must not clear them. + // Use swap trick since shrink_to_fit() is non-binding and may be ignored. + if (items.empty()) { + std::vector().swap(items); + } + } }; // DeferredBatch here (16 bytes, 4-byte aligned) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index d931a6e3a9..b582bcea9a 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -35,10 +35,9 @@ struct ClientInfo; class ProtoWriteBuffer; struct ReadPacketBuffer { - std::vector container; - uint16_t type; - uint16_t data_offset; + const uint8_t *data; // Points directly into frame helper's rx_buf_ (valid until next read_packet call) uint16_t data_len; + uint16_t type; }; // Packed packet info structure to minimize memory usage @@ -119,6 +118,22 @@ class APIFrameHelper { uint8_t frame_footer_size() const { return frame_footer_size_; } // Check if socket has data ready to read bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } + // Release excess memory from internal buffers after initial sync + void release_buffers() { + // rx_buf_: Safe to clear only if no partial read in progress. + // rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame + // and clearing would lose partially received data. + if (this->rx_buf_len_ == 0) { + // Use swap trick since shrink_to_fit() is non-binding and may be ignored + std::vector().swap(this->rx_buf_); + } + // reusable_iovs_: Safe to release unconditionally. + // Only used within write_protobuf_packets() calls - cleared at start, + // populated with pointers, used for writev(), then function returns. + // The iovecs contain stale pointers after the call (data was either sent + // or copied to tx_buf_), and are cleared on next write_protobuf_packets(). + std::vector().swap(this->reusable_iovs_); + } protected: // Buffer containing data to be sent diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index f1028fa299..ae69f0b673 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -407,8 +407,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::BAD_DATA_PACKET; } - buffer->container = std::move(this->rx_buf_); - buffer->data_offset = 4; + buffer->data = msg_data + 4; // Skip 4-byte header (type + length) buffer->data_len = data_len; buffer->type = type; return APIError::OK; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index dcbd35aa32..b5d90b2429 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -210,8 +210,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { return aerr; } - buffer->container = std::move(this->rx_buf_); - buffer->data_offset = 0; + buffer->data = this->rx_buf_.data(); buffer->data_len = this->rx_header_parsed_len_; buffer->type = this->rx_header_parsed_type_; return APIError::OK; diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 3d28a137c8..45f6ecd30e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -13,7 +13,7 @@ void APIServerConnectionBase::log_send_message_(const char *name, const std::str } #endif -void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { +void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { switch (msg_type) { case HelloRequest::MESSAGE_TYPE: { HelloRequest msg; @@ -827,7 +827,7 @@ void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { th void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } #endif -void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { +void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements for messages switch (msg_type) { case HelloRequest::MESSAGE_TYPE: // No setup required diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 827b89e23c..6d94046a23 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -218,7 +218,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif protected: - void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; + void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; class APIServerConnection : public APIServerConnectionBase { @@ -480,7 +480,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ZWAVE_PROXY void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif - void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; + void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; } // namespace esphome::api diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e7585924a5..83b6922be1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -846,7 +846,7 @@ class ProtoService { */ virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0; virtual bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) = 0; - virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0; + virtual void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) = 0; // Optimized method that pre-allocates buffer based on message size bool send_message_(const ProtoMessage &msg, uint8_t message_type) { diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index b07a249c8d..3412fac5db 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2769,8 +2769,8 @@ static const char *const TAG = "api.service"; cases = list(RECEIVE_CASES.items()) cases.sort() hpp += " protected:\n" - hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" - out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" + out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" out += " switch (msg_type) {\n" for i, (case, ifdef, message_name) in cases: if ifdef is not None: @@ -2878,9 +2878,9 @@ static const char *const TAG = "api.service"; result += "#endif\n" return result - hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" + hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" - cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" cpp += " // Check authentication/connection requirements for messages\n" cpp += " switch (msg_type) {\n" From e15f3a08aef0255b111db331c726128e4b68db8d Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Fri, 28 Nov 2025 19:15:55 +0100 Subject: [PATCH 167/320] [tests] Remote packages with substitutions (#12145) --- .../06-remote_packages.approved.yaml | 25 +++++++++++++ .../06-remote_packages.input.yaml | 37 +++++++++++++++++++ .../substitutions/remote_package_proxy.yaml | 6 +++ .../fixtures/substitutions/remotes/README.md | 3 ++ .../remotes/repo1/main/file1.yaml | 9 +++++ .../remotes/repo2/main/file2.yaml | 10 +++++ tests/unit_tests/test_substitutions.py | 33 ++++++++++++++++- 7 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/remotes/README.md create mode 100644 tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml new file mode 100644 index 0000000000..4b5315013c --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml @@ -0,0 +1,25 @@ +substitutions: + x: 10 + y: 20 + z: 30 +values_from_repo1_main: + - package_name: package1 + x: 3 + y: 4 + z: 5 + volume: 60 + - package_name: package2 + x: 6 + y: 7 + z: 8 + volume: 336 + - package_name: default + x: 10 + y: 20 + z: 5 + volume: 1000 + - package_name: package4_from_repo2 + x: 9 + y: 10 + z: 11 + volume: 990 diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml new file mode 100644 index 0000000000..a8128a7a07 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml @@ -0,0 +1,37 @@ +substitutions: + x: 10 + y: 20 + z: 30 +packages: + package1: + url: https://github.com/esphome/repo1 + files: + - path: file1.yaml + vars: + package_name: package1 + x: 3 + y: 4 + ref: main + package2: !include # a package that just includes the given remote package + file: remote_package_proxy.yaml + vars: + url: https://github.com/esphome/repo1 + ref: main + files: + - path: file1.yaml + vars: + package_name: package2 + x: 6 + y: 7 + z: 8 + package3: github://esphome/repo1/file1.yaml@main # a package that uses the shorthand syntax + package4: # include repo2, which itself includes repo1 + url: https://github.com/esphome/repo2 + files: + - path: file2.yaml + vars: + package_name: package4 + a: 9 + b: 10 + c: 11 + ref: main diff --git a/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml b/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml new file mode 100644 index 0000000000..05da30acb4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml @@ -0,0 +1,6 @@ +# acts as a proxy to be able to include a remote package +# in which the url/ref/files come from a substitution +packages: + - url: ${url} + ref: ${ref} + files: ${files} diff --git a/tests/unit_tests/fixtures/substitutions/remotes/README.md b/tests/unit_tests/fixtures/substitutions/remotes/README.md new file mode 100644 index 0000000000..09d9f38699 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/README.md @@ -0,0 +1,3 @@ +This folder contains fake repos for remote packages testing +These are used by `test_substitutions.py`. +To add repos, create a folder and add its path to the `REMOTES` constant in `test_substitutions.py`. diff --git a/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml b/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml new file mode 100644 index 0000000000..3830b1650f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml @@ -0,0 +1,9 @@ +defaults: + z: 5 + package_name: default +values_from_repo1_main: + - package_name: ${package_name} + x: ${x} + y: ${y} + z: ${z} + volume: ${x*y*z} diff --git a/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml b/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml new file mode 100644 index 0000000000..7f62ab8926 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml @@ -0,0 +1,10 @@ +packages: + - url: https://github.com/esphome/repo1 + ref: main + files: + - path: file1.yaml + vars: + package_name: ${package_name}_from_repo2 + x: ${a} + y: ${b} + z: ${c} diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 7d50b44506..c5e6618ea6 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -2,6 +2,7 @@ import glob import logging from pathlib import Path from typing import Any +from unittest.mock import patch from esphome import config as config_module, yaml_util from esphome.components import substitutions @@ -84,11 +85,41 @@ def verify_database(value: Any, path: str = "") -> str | None: return None -def test_substitutions_fixtures(fixture_path): +# Mapping of (url, ref) to local test repository path under fixtures/substitutions +REMOTES = { + ("https://github.com/esphome/repo1", "main"): "remotes/repo1/main", + ("https://github.com/esphome/repo2", "main"): "remotes/repo2/main", +} + + +@patch("esphome.git.clone_or_update") +def test_substitutions_fixtures(mock_clone_or_update, fixture_path): base_dir = fixture_path / "substitutions" sources = sorted(glob.glob(str(base_dir / "*.input.yaml"))) assert sources, f"No input YAML files found in {base_dir}" + def fake_clone_or_update( + *, + url: str, + ref: str | None = None, + refresh=None, + domain: str, + username: str | None = None, + password: str | None = None, + submodules: list[str] | None = None, + _recover_broken: bool = True, + ) -> tuple[Path, None]: + path = REMOTES.get((url, ref)) + if path is None: + path = REMOTES.get((url.rstrip(".git"), ref)) + if path is None: + raise RuntimeError( + f"Cannot find test repository for {url} @ {ref}. Check the REMOTES mapping in test_substitutions.py" + ) + return base_dir / path, None + + mock_clone_or_update.side_effect = fake_clone_or_update + failures = [] for source_path in sources: source_path = Path(source_path) From d6ca01775e5c19a48c58f7147c3dfc8e6cc00489 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Fri, 28 Nov 2025 19:24:09 +0100 Subject: [PATCH 168/320] [packages] Restore remote shorthand vars and !remove in early package contents validation (#12158) Co-authored-by: J. Nick Koston --- esphome/components/packages/__init__.py | 12 +++++++++--- .../substitutions/06-remote_packages.approved.yaml | 5 +++++ .../substitutions/06-remote_packages.input.yaml | 6 ++++++ .../substitutions/remote_package_shorthand.yaml | 4 ++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 41cde0391b..67fd2770e9 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -2,7 +2,8 @@ import logging from pathlib import Path from esphome import git, yaml_util -from esphome.config_helpers import merge_config +from esphome.components.substitutions.jinja import has_jinja +from esphome.config_helpers import Remove, merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, @@ -39,10 +40,15 @@ def valid_package_contents(package_config: dict): for k, v in package_config.items(): if not isinstance(k, str): raise cv.Invalid("Package content keys must be strings") - if isinstance(v, (dict, list)): - continue # e.g. script: [] or logger: {level: debug} + if isinstance(v, (dict, list, Remove)): + continue # e.g. script: [], psram: !remove, logger: {level: debug} if v is None: continue # e.g. web_server: + if isinstance(v, str) and has_jinja(v): + # e.g: remote package shorthand: + # package_name: github://esphome/repo/file.yaml@${ branch } + continue + raise cv.Invalid("Invalid component content in package definition") return package_config diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml index 4b5315013c..0fffbfb7cb 100644 --- a/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml @@ -23,3 +23,8 @@ values_from_repo1_main: y: 10 z: 11 volume: 990 + - package_name: default + x: 10 + y: 20 + z: 5 + volume: 1000 diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml index a8128a7a07..772860bf19 100644 --- a/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml @@ -35,3 +35,9 @@ packages: b: 10 c: 11 ref: main + package5: !include + file: remote_package_shorthand.yaml + vars: + repo: repo1 + file: file1.yaml + ref: main diff --git a/tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml b/tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml new file mode 100644 index 0000000000..f49e85e038 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml @@ -0,0 +1,4 @@ +# acts as a proxy to be able to include a remote package +# in which the shorthand comes from a substitution +packages: + - github://esphome/${repo}/${file}@${ref} From 2e5529664006c585692ba47310229e2fe3f3e52c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 14:43:11 -0600 Subject: [PATCH 169/320] [sensor] Replace timeout filter scheduler with loop-based implementation (#11922) --- esphome/components/sensor/__init__.py | 11 +++++-- esphome/components/sensor/filter.cpp | 43 ++++++++++++++++++++------ esphome/components/sensor/filter.h | 44 ++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index e8fec222a1..f83226d10f 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -270,7 +270,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) -TimeoutFilter = sensor_ns.class_("TimeoutFilter", Filter, cg.Component) +TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) +TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) +TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) @@ -681,11 +683,16 @@ TIMEOUT_SCHEMA = cv.maybe_simple_value( ) -@FILTER_REGISTRY.register("timeout", TimeoutFilter, TIMEOUT_SCHEMA) +@FILTER_REGISTRY.register("timeout", TimeoutFilterBase, TIMEOUT_SCHEMA) async def timeout_filter_to_code(config, filter_id): + filter_id = filter_id.copy() if config[CONF_VALUE] == "last": + # Use TimeoutFilterLast for "last" mode (smaller, more common - LD2450, LD2412, etc.) + filter_id.type = TimeoutFilterLast var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) else: + # Use TimeoutFilterConfigured for configured value mode + filter_id.type = TimeoutFilterConfigured template_ = await cg.templatable(config[CONF_VALUE], [], float) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) await cg.register_component(var, {}) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 65d8dea31c..c8c6540112 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -339,20 +339,43 @@ void OrFilter::initialize(Sensor *parent, Filter *next) { this->phi_.initialize(parent, nullptr); } -// TimeoutFilter -optional TimeoutFilter::new_value(float value) { - if (this->value_.has_value()) { - this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value().value()); }); - } else { - this->set_timeout("timeout", this->time_period_, [this, value]() { this->output(value); }); +// TimeoutFilterBase - shared loop logic +void TimeoutFilterBase::loop() { + // Check if timeout period has elapsed + // Use cached loop start time to avoid repeated millis() calls + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + // Timeout fired - get output value from derived class and output it + this->output(this->get_output_value()); + + // Disable loop until next value arrives + this->disable_loop(); } +} + +float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } + +// TimeoutFilterLast - "last" mode implementation +optional TimeoutFilterLast::new_value(float value) { + // Store the value to output when timeout fires + this->pending_value_ = value; + + // Record when timeout started and enable loop + this->timeout_start_time_ = millis(); + this->enable_loop(); + return value; } -TimeoutFilter::TimeoutFilter(uint32_t time_period) : time_period_(time_period) {} -TimeoutFilter::TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value) - : time_period_(time_period), value_(new_value) {} -float TimeoutFilter::get_setup_priority() const { return setup_priority::HARDWARE; } +// TimeoutFilterConfigured - configured value mode implementation +optional TimeoutFilterConfigured::new_value(float value) { + // Record when timeout started and enable loop + // Note: we don't store the incoming value since we have a configured value + this->timeout_start_time_ = millis(); + this->enable_loop(); + + return value; +} // DebounceFilter optional DebounceFilter::new_value(float value) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 75e28a1efe..92a9184c18 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -380,18 +380,46 @@ class ThrottleWithPriorityFilter : public ValueListFilter { uint32_t min_time_between_inputs_; }; -class TimeoutFilter : public Filter, public Component { +// Base class for timeout filters - contains common loop logic +class TimeoutFilterBase : public Filter, public Component { public: - explicit TimeoutFilter(uint32_t time_period); - explicit TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value); - - optional new_value(float value) override; - + void loop() override; float get_setup_priority() const override; protected: - uint32_t time_period_; - optional> value_; + explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); } + virtual float get_output_value() = 0; + + uint32_t time_period_; // 4 bytes (timeout duration in ms) + uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started) + // Total base: 8 bytes +}; + +// Timeout filter for "last" mode - outputs the last received value after timeout +class TimeoutFilterLast : public TimeoutFilterBase { + public: + explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {} + + optional new_value(float value) override; + + protected: + float get_output_value() override { return this->pending_value_; } + float pending_value_{0}; // 4 bytes (value to output when timeout fires) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead +}; + +// Timeout filter with configured value - evaluates TemplatableValue after timeout +class TimeoutFilterConfigured : public TimeoutFilterBase { + public: + explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableValue &new_value) + : TimeoutFilterBase(time_period), value_(new_value) {} + + optional new_value(float value) override; + + protected: + float get_output_value() override { return this->value_.value(); } + TemplatableValue value_; // 16 bytes (configured output value, can be lambda) + // Total: 8 (base) + 16 = 24 bytes + vtable ptr + Component overhead }; class DebounceFilter : public Filter, public Component { From ca599b25c2a817a963eb5f76de2a0fcd8cc73738 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 15:33:28 -0600 Subject: [PATCH 170/320] [espnow] Initialize LwIP stack when running without WiFi component (#12169) --- esphome/components/espnow/espnow_component.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index d2f136d1c7..bc05833709 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -157,6 +158,12 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { +#ifndef USE_WIFI + // Initialize LwIP stack for wake_loop_threadsafe() socket support + // When WiFi component is present, it handles esp_netif_init() + ESP_ERROR_CHECK(esp_netif_init()); +#endif + if (this->enable_on_boot_) { this->enable_(); } else { From bc50be6053493aa453ab68602be89c2c4c55ecc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 16:14:00 -0600 Subject: [PATCH 171/320] [logger] Conditionally compile log level change listener (#12168) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/logger/__init__.py | 23 +++++++++++++ esphome/components/logger/logger.cpp | 5 ++- esphome/components/logger/logger.h | 34 +++++++++++++++++-- esphome/components/logger/select/__init__.py | 9 ++++- .../logger/select/logger_level_select.cpp | 6 ++-- .../logger/select/logger_level_select.h | 9 +++-- esphome/core/defines.h | 1 + 7 files changed, 77 insertions(+), 10 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 39877030e9..d9ca44d3c9 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -406,6 +406,8 @@ async def to_code(config): conf, ) + CORE.add_job(final_step) + def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python @@ -506,3 +508,24 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, } ) + +# Keys for CORE.data storage +DOMAIN = "logger" +KEY_LEVEL_LISTENERS = "level_listeners" + + +def request_logger_level_listeners() -> None: + """Request that logger level listeners be compiled in. + + Components that need to be notified about log level changes should call this + function during their code generation. This enables the add_level_listener() + method and compiles in the listener vector. + """ + CORE.data.setdefault(DOMAIN, {})[KEY_LEVEL_LISTENERS] = True + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional logger features.""" + if CORE.data.get(DOMAIN, {}).get(KEY_LEVEL_LISTENERS, False): + cg.add_define("USE_LOGGER_LEVEL_LISTENERS") diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index f925e85e11..21e2b44808 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -288,7 +288,10 @@ void Logger::set_log_level(uint8_t level) { ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL])); } this->current_level_ = level; - this->level_callback_.call(level); +#ifdef USE_LOGGER_LEVEL_LISTENERS + for (auto *listener : this->level_listeners_) + listener->on_log_level_change(level); +#endif } Logger *global_logger = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index a0024411d7..8abc1196e1 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -58,6 +58,30 @@ class LogListener { virtual void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) = 0; }; +#ifdef USE_LOGGER_LEVEL_LISTENERS +/** Interface for receiving log level changes without std::function overhead. + * + * Components can implement this interface instead of using lambdas with std::function + * to reduce flash usage from std::function type erasure machinery. + * + * Usage: + * class MyComponent : public Component, public LoggerLevelListener { + * public: + * void setup() override { + * if (logger::global_logger != nullptr) + * logger::global_logger->add_logger_level_listener(this); + * } + * void on_log_level_change(uint8_t level) override { + * // Handle log level change + * } + * }; + */ +class LoggerLevelListener { + public: + virtual void on_log_level_change(uint8_t level) = 0; +}; +#endif + #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Comparison function for const char* keys in log_levels_ map struct CStrCompare { @@ -193,8 +217,10 @@ class Logger : public Component { /// Register a log listener to receive log messages void add_log_listener(LogListener *listener) { this->log_listeners_.push_back(listener); } - // add a listener for log level changes - void add_listener(std::function &&callback) { this->level_callback_.add(std::move(callback)); } +#ifdef USE_LOGGER_LEVEL_LISTENERS + /// Register a listener for log level changes + void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); } +#endif float get_setup_priority() const override; @@ -325,7 +351,9 @@ class Logger : public Component { std::map log_levels_{}; #endif std::vector log_listeners_; // Log message listeners (API, MQTT, syslog, etc.) - CallbackManager level_callback_{}; +#ifdef USE_LOGGER_LEVEL_LISTENERS + std::vector level_listeners_; // Log level change listeners +#endif #ifdef USE_ESPHOME_TASK_LOG_BUFFER std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer #endif diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 2e83599eb4..6ce663978e 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -5,7 +5,13 @@ from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_ from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented -from .. import CONF_LOGGER_ID, LOG_LEVELS, Logger, logger_ns +from .. import ( + CONF_LOGGER_ID, + LOG_LEVELS, + Logger, + logger_ns, + request_logger_level_listeners, +) CODEOWNERS = ["@clydebarrow"] @@ -21,6 +27,7 @@ CONFIG_SCHEMA = select.select_schema( async def to_code(config): + request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) index = levels.index(CORE.data[CONF_LOGGER][CONF_LEVEL]) diff --git a/esphome/components/logger/select/logger_level_select.cpp b/esphome/components/logger/select/logger_level_select.cpp index e2ec28a390..3091ca1851 100644 --- a/esphome/components/logger/select/logger_level_select.cpp +++ b/esphome/components/logger/select/logger_level_select.cpp @@ -2,7 +2,7 @@ namespace esphome::logger { -void LoggerLevelSelect::publish_state(int level) { +void LoggerLevelSelect::on_log_level_change(uint8_t level) { auto index = level_to_index(level); if (!this->has_index(index)) return; @@ -10,8 +10,8 @@ void LoggerLevelSelect::publish_state(int level) { } void LoggerLevelSelect::setup() { - this->parent_->add_listener([this](int level) { this->publish_state(level); }); - this->publish_state(this->parent_->get_log_level()); + this->parent_->add_level_listener(this); + this->on_log_level_change(this->parent_->get_log_level()); } void LoggerLevelSelect::control(size_t index) { this->parent_->set_log_level(index_to_level(index)); } diff --git a/esphome/components/logger/select/logger_level_select.h b/esphome/components/logger/select/logger_level_select.h index 950edd29ac..6482114943 100644 --- a/esphome/components/logger/select/logger_level_select.h +++ b/esphome/components/logger/select/logger_level_select.h @@ -5,12 +5,17 @@ #include "esphome/components/logger/logger.h" namespace esphome::logger { -class LoggerLevelSelect : public Component, public select::Select, public Parented { +class LoggerLevelSelect final : public Component, + public select::Select, + public Parented, + public LoggerLevelListener { public: - void publish_state(int level); void setup() override; void control(size_t index) override; + // LoggerLevelListener interface + void on_log_level_change(uint8_t level) override; + protected: // Convert log level to option index (skip CONFIG at level 4) static uint8_t level_to_index(uint8_t level) { return (level > ESPHOME_LOG_LEVEL_CONFIG) ? level - 1 : level; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f4026aad96..538d4e3d6e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -51,6 +51,7 @@ #define USE_LIGHT #define USE_LOCK #define USE_LOGGER +#define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL #define USE_LVGL_ANIMIMG From 5fa4ff754c4306083ddd320edc68c706d568e862 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 21:57:01 -0600 Subject: [PATCH 172/320] [ble_client] Convert to C++17 namespace style (#12176) --- esphome/components/ble_client/automation.cpp | 6 ++---- esphome/components/ble_client/automation.h | 6 ++---- esphome/components/ble_client/ble_client.cpp | 6 ++---- esphome/components/ble_client/ble_client.h | 6 ++---- esphome/components/ble_client/output/ble_binary_output.cpp | 6 ++---- esphome/components/ble_client/output/ble_binary_output.h | 6 ++---- esphome/components/ble_client/sensor/automation.h | 6 ++---- esphome/components/ble_client/sensor/ble_rssi_sensor.cpp | 6 ++---- esphome/components/ble_client/sensor/ble_rssi_sensor.h | 6 ++---- esphome/components/ble_client/sensor/ble_sensor.cpp | 6 ++---- esphome/components/ble_client/sensor/ble_sensor.h | 6 ++---- esphome/components/ble_client/switch/ble_switch.cpp | 6 ++---- esphome/components/ble_client/switch/ble_switch.h | 6 ++---- esphome/components/ble_client/text_sensor/automation.h | 6 ++---- .../components/ble_client/text_sensor/ble_text_sensor.cpp | 6 ++---- esphome/components/ble_client/text_sensor/ble_text_sensor.h | 6 ++---- 16 files changed, 32 insertions(+), 64 deletions(-) diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index 9a0233eb70..cd2802f617 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -2,12 +2,10 @@ #include "automation.h" -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { const char *const Automation::TAG = "ble_client.automation"; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 788eac4a57..ccda894509 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -9,8 +9,7 @@ #include "esphome/components/ble_client/ble_client.h" #include "esphome/core/log.h" -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { // placeholder class for static TAG . class Automation { @@ -391,7 +390,6 @@ template class BLEClientDisconnectAction : public Action, BLEClient *ble_client_; std::tuple var_{}; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index b8968fe4ba..d41fb17961 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_client"; @@ -82,7 +81,6 @@ bool BLEClient::all_nodes_established_() { return true; } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index e04f4a8042..ca523251ef 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -15,8 +15,7 @@ #include #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -75,7 +74,6 @@ class BLEClient : public BLEClientBase { std::vector nodes_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/output/ble_binary_output.cpp b/esphome/components/ble_client/output/ble_binary_output.cpp index 84558717f8..1d874a65e4 100644 --- a/esphome/components/ble_client/output/ble_binary_output.cpp +++ b/esphome/components/ble_client/output/ble_binary_output.cpp @@ -3,8 +3,7 @@ #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_binary_output"; @@ -75,6 +74,5 @@ void BLEBinaryOutput::write_state(bool state) { ESP_LOGW(TAG, "[%s] Write error, err=%d", this->char_uuid_.to_string().c_str(), err); } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 5e8bd6da62..299de9b860 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -36,7 +35,6 @@ class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, publi esp_gatt_write_type_t write_type_{}; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 56ab7ba4c9..84430cb7d9 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { class BLESensorNotifyTrigger : public Trigger, public BLESensor { public: @@ -35,7 +34,6 @@ class BLESensorNotifyTrigger : public Trigger, public BLESensor { BLESensor *sensor_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 4edcbd3877..dc032a7a98 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_rssi_sensor"; @@ -78,6 +77,5 @@ void BLEClientRSSISensor::get_rssi_() { } } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 76cd8345a6..570a5b423c 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -29,6 +28,5 @@ class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, publ bool should_update_{false}; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 8e3e483003..38d90faff0 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_sensor"; @@ -147,6 +146,5 @@ void BLESensor::update() { } } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_sensor.h b/esphome/components/ble_client/sensor/ble_sensor.h index c6335d5836..fe5b5ecd53 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.h +++ b/esphome/components/ble_client/sensor/ble_sensor.h @@ -10,8 +10,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -48,6 +47,5 @@ class BLESensor : public sensor::Sensor, public PollingComponent, public BLEClie espbt::ESPBTUUID descr_uuid_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/switch/ble_switch.cpp b/esphome/components/ble_client/switch/ble_switch.cpp index 9d92b1b2b5..5baca2adcf 100644 --- a/esphome/components/ble_client/switch/ble_switch.cpp +++ b/esphome/components/ble_client/switch/ble_switch.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_switch"; @@ -31,6 +30,5 @@ void BLEClientSwitch::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void BLEClientSwitch::dump_config() { LOG_SWITCH("", "BLE Client Switch", this); } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9809f904e7..9be6d06b1c 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -24,6 +23,5 @@ class BLEClientSwitch : public switch_::Switch, public Component, public BLEClie void write_state(bool state) override; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index c504c35a58..f7b077926b 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { public: @@ -33,7 +32,6 @@ class BLETextSensorNotifyTrigger : public Trigger, public BLETextSe BLETextSensor *sensor_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index bb771aed99..415981a1ba 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_text_sensor"; @@ -138,6 +137,5 @@ void BLETextSensor::update() { } } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.h b/esphome/components/ble_client/text_sensor/ble_text_sensor.h index c75a4df952..3fbd64389c 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.h +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -40,6 +39,5 @@ class BLETextSensor : public text_sensor::TextSensor, public PollingComponent, p espbt::ESPBTUUID descr_uuid_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif From 2174795b273ef3ad26f8606e1e7d2d9a28c41646 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 21:57:36 -0600 Subject: [PATCH 173/320] [number] Reduce NumberCall size by 4 bytes on 32-bit platforms (#12178) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/number/number_call.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/number/number_call.h b/esphome/components/number/number_call.h index 0f6889dcb6..584c13f413 100644 --- a/esphome/components/number/number_call.h +++ b/esphome/components/number/number_call.h @@ -8,7 +8,7 @@ namespace esphome::number { class Number; -enum NumberOperation { +enum NumberOperation : uint8_t { NUMBER_OP_NONE, NUMBER_OP_SET, NUMBER_OP_INCREMENT, @@ -38,9 +38,9 @@ class NumberCall { float limit); Number *const parent_; - NumberOperation operation_{NUMBER_OP_NONE}; optional value_; - bool cycle_; + NumberOperation operation_{NUMBER_OP_NONE}; + bool cycle_{false}; }; } // namespace esphome::number From b71d8010d25e0252262aff70b14a49857e4a4025 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Nov 2025 21:59:31 -0600 Subject: [PATCH 174/320] [light] Store log_percent parameter strings in flash on ESP8266 (#12174) --- esphome/components/light/light_call.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index b3bdb16c73..f523b4451b 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -74,11 +74,11 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { // Helper to log percentage values #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -static void log_percent(const char *name, const char *param, float value) { - ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f); +static void log_percent(const LogString *param, float value) { + ESP_LOGD(TAG, " %s: %.0f%%", LOG_STR_ARG(param), value * 100.0f); } #else -#define log_percent(name, param, value) +#define log_percent(param, value) #endif void LightCall::perform() { @@ -104,11 +104,11 @@ void LightCall::perform() { } if (this->has_brightness()) { - log_percent(name, "Brightness", v.get_brightness()); + log_percent(LOG_STR("Brightness"), v.get_brightness()); } if (this->has_color_brightness()) { - log_percent(name, "Color brightness", v.get_color_brightness()); + log_percent(LOG_STR("Color brightness"), v.get_color_brightness()); } if (this->has_red() || this->has_green() || this->has_blue()) { ESP_LOGD(TAG, " Red: %.0f%%, Green: %.0f%%, Blue: %.0f%%", v.get_red() * 100.0f, v.get_green() * 100.0f, @@ -116,7 +116,7 @@ void LightCall::perform() { } if (this->has_white()) { - log_percent(name, "White", v.get_white()); + log_percent(LOG_STR("White"), v.get_white()); } if (this->has_color_temperature()) { ESP_LOGD(TAG, " Color temperature: %.1f mireds", v.get_color_temperature()); From c40e8e7f5c722cb70e44bf5a3c61eb7a18a55bb9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:38:29 +1100 Subject: [PATCH 175/320] [helpers] Add conversion from FixedVector to std::vector (#12179) --- esphome/core/helpers.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a43c55e06b..83a12b9bf0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -242,6 +242,9 @@ template class FixedVector { other.reset_(); } + // Allow conversion to std::vector + operator std::vector() const { return {data_, data_ + size_}; } + FixedVector &operator=(FixedVector &&other) noexcept { if (this != &other) { // Delete our current data From cf444fc3b82e68e33715434f39f6b4762f2cf809 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sat, 29 Nov 2025 09:40:13 +0100 Subject: [PATCH 176/320] [mipi_spi] add guition JC4827W543 C/R (#12034) --- esphome/components/mipi_spi/models/jc.py | 105 +++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 5dbf049ded..5b936fd956 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -484,4 +484,109 @@ DriverChip( ), ) +DriverChip( + "JC4827W543", + height=272, + width=480, + offset_height=0, + offset_width=0, + cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, + invert_colors=True, + color_order=MODE_RGB, + bus_mode=TYPE_QUAD, + data_rate="20MHz", + initsequence=( + (0xFF, 0xA5), + (0x41, 0x03), + (0x44, 0x15), + (0x45, 0x15), + (0x7D, 0x03), + (0xC1, 0xBB), + (0xC2, 0x05), + (0xC3, 0x10), + (0xC6, 0x3E), + (0xC7, 0x25), + (0xC8, 0x11), + (0x7A, 0x5F), + (0x6F, 0x44), + (0x78, 0x70), + (0xC9, 0x00), + (0x67, 0x21), + (0x51, 0x0A), + (0x52, 0x76), + (0x53, 0x0A), + (0x54, 0x76), + (0x46, 0x0A), + (0x47, 0x2A), + (0x48, 0x0A), + (0x49, 0x1A), + (0x56, 0x43), + (0x57, 0x42), + (0x58, 0x3C), + (0x59, 0x64), + (0x5A, 0x41), + (0x5B, 0x3C), + (0x5C, 0x02), + (0x5D, 0x3C), + (0x5E, 0x1F), + (0x60, 0x80), + (0x61, 0x3F), + (0x62, 0x21), + (0x63, 0x07), + (0x64, 0xE0), + (0x65, 0x02), + (0xCA, 0x20), + (0xCB, 0x52), + (0xCC, 0x10), + (0xCD, 0x42), + (0xD0, 0x20), + (0xD1, 0x52), + (0xD2, 0x10), + (0xD3, 0x42), + (0xD4, 0x0A), + (0xD5, 0x32), + (0x80, 0x00), + (0xA0, 0x00), + (0x81, 0x07), + (0xA1, 0x06), + (0x82, 0x02), + (0xA2, 0x01), + (0x86, 0x11), + (0xA6, 0x10), + (0x87, 0x27), + (0xA7, 0x27), + (0x83, 0x37), + (0xA3, 0x37), + (0x84, 0x35), + (0xA4, 0x35), + (0x85, 0x3F), + (0xA5, 0x3F), + (0x88, 0x0B), + (0xA8, 0x0B), + (0x89, 0x14), + (0xA9, 0x14), + (0x8A, 0x1A), + (0xAA, 0x1A), + (0x8B, 0x0A), + (0xAB, 0x0A), + (0x8C, 0x14), + (0xAC, 0x08), + (0x8D, 0x17), + (0xAD, 0x07), + (0x8E, 0x16), + (0xAE, 0x06), + (0x8F, 0x1B), + (0xAF, 0x07), + (0x90, 0x04), + (0xB0, 0x04), + (0x91, 0x0A), + (0xB1, 0x0A), + (0x92, 0x16), + (0xB2, 0x15), + (0xFF, 0x00), + (0x11, 0x00), + (0x29, 0x00), + ), +) + models = {} From 1f47797007419e10046513b7eb49d18fa200d1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C4=8Cerm=C3=A1k?= Date: Sat, 29 Nov 2025 23:26:25 +0100 Subject: [PATCH 177/320] Add MEASUREMENT_ANGLE to SensorStateClass (#12085) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 2 ++ esphome/components/sensor/__init__.py | 1 + esphome/components/sensor/sensor.cpp | 2 ++ esphome/components/sensor/sensor.h | 1 + esphome/const.py | 3 +++ tests/components/sensor/common.yaml | 7 +++++++ 8 files changed, 18 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 74a8e8ff7f..5450c2536c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -589,6 +589,7 @@ enum SensorStateClass { STATE_CLASS_MEASUREMENT = 1; STATE_CLASS_TOTAL_INCREASING = 2; STATE_CLASS_TOTAL = 3; + STATE_CLASS_MEASUREMENT_ANGLE = 4; } // Deprecated in API version 1.5 diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 93ece74d85..74d3834bf5 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -51,6 +51,7 @@ enum SensorStateClass : uint32_t { STATE_CLASS_MEASUREMENT = 1, STATE_CLASS_TOTAL_INCREASING = 2, STATE_CLASS_TOTAL = 3, + STATE_CLASS_MEASUREMENT_ANGLE = 4, }; #endif enum LogLevel : uint32_t { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index a985e052ac..bea7fc53c4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -179,6 +179,8 @@ template<> const char *proto_enum_to_string(enums::Sens return "STATE_CLASS_TOTAL_INCREASING"; case enums::STATE_CLASS_TOTAL: return "STATE_CLASS_TOTAL"; + case enums::STATE_CLASS_MEASUREMENT_ANGLE: + return "STATE_CLASS_MEASUREMENT_ANGLE"; default: return "UNKNOWN"; } diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index f83226d10f..027d9a69b8 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -182,6 +182,7 @@ STATE_CLASSES = { "measurement": StateClasses.STATE_CLASS_MEASUREMENT, "total_increasing": StateClasses.STATE_CLASS_TOTAL_INCREASING, "total": StateClasses.STATE_CLASS_TOTAL, + "measurement_angle": StateClasses.STATE_CLASS_MEASUREMENT_ANGLE, } validate_state_class = cv.enum(STATE_CLASSES, lower=True, space="_") diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index df6bd644e8..49dc56edaa 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -44,6 +44,8 @@ const LogString *state_class_to_string(StateClass state_class) { return LOG_STR("total_increasing"); case STATE_CLASS_TOTAL: return LOG_STR("total"); + case STATE_CLASS_MEASUREMENT_ANGLE: + return LOG_STR("measurement_angle"); case STATE_CLASS_NONE: default: return LOG_STR(""); diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index a4210e5e6c..5d387a1ad7 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -31,6 +31,7 @@ enum StateClass : uint8_t { STATE_CLASS_MEASUREMENT = 1, STATE_CLASS_TOTAL_INCREASING = 2, STATE_CLASS_TOTAL = 3, + STATE_CLASS_MEASUREMENT_ANGLE = 4 }; const LogString *state_class_to_string(StateClass state_class); diff --git a/esphome/const.py b/esphome/const.py index 2b6b60d395..59bf0e8b8a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1346,6 +1346,9 @@ STATE_CLASS_NONE = "" # The state represents a measurement in present time STATE_CLASS_MEASUREMENT = "measurement" +# The state represents a measurement in present time for angles measured in degrees (°) +STATE_CLASS_MEASUREMENT_ANGLE = "measurement_angle" + # The state represents a total that only increases, a decrease is considered a reset. STATE_CLASS_TOTAL_INCREASING = "total_increasing" diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index 2180f66da8..1961c98685 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -236,3 +236,10 @@ sensor: - multiply: 2.0 - offset: 10.0 - lambda: return x * 3.0; + + # Testing measurement_angle state class + - platform: template + name: "Angle Sensor" + lambda: return 42.0; + update_interval: 1s + state_class: "measurement_angle" From 46567c47161a53ea48262c0ffde665ba3224885b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:55:27 +0000 Subject: [PATCH 178/320] Bump aioesphomeapi from 42.8.0 to 42.9.0 (#12189) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a5c919e95f..45ae3d5925 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.8.0 +aioesphomeapi==42.9.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From ec88bf0cb12be0159a1aa1a8519fd8a770fc1c6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 22:56:26 +0000 Subject: [PATCH 179/320] Bump ruff from 0.14.5 to 0.14.7 (#12190) 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 b86d00f2aa..412a678d02 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.14.5 + rev: v0.14.7 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 7f6d3f8e26..3aec877126 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.3 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.5 # also change in .pre-commit-config.yaml when updating +ruff==0.14.7 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From d82a92b406238162bbd01756cb301776f901a6d9 Mon Sep 17 00:00:00 2001 From: Darsey Litzenberger Date: Sat, 29 Nov 2025 16:41:47 -0700 Subject: [PATCH 180/320] [ade7953_base] Add missing CODEOWNERS (#12181) --- CODEOWNERS | 1 + esphome/components/ade7953_base/__init__.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index c6332e3933..7861871323 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -21,6 +21,7 @@ esphome/components/adc128s102/* @DeerMaximum esphome/components/addressable_light/* @justfalter esphome/components/ade7880/* @kpfleming esphome/components/ade7953/* @angelnu +esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_i2c/* @angelnu esphome/components/ade7953_spi/* @angelnu esphome/components/ads1118/* @solomondg1 diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 42b6c8ba24..4fc35352f9 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -24,6 +24,8 @@ from esphome.const import ( UNIT_WATT, ) +CODEOWNERS = ["@angelnu"] + CONF_CURRENT_A = "current_a" CONF_CURRENT_B = "current_b" CONF_ACTIVE_POWER_A = "active_power_a" From 77f5f2326f793c488dca9a190590d8d7e57e9484 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Nov 2025 18:36:12 -0600 Subject: [PATCH 181/320] [hlk_fm22x] Fix Action::play method signatures (#12192) --- esphome/components/hlk_fm22x/hlk_fm22x.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index 5ecc715ea1..9c981d3c44 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -189,7 +189,7 @@ template class EnrollmentAction : public Action, public P TEMPLATABLE_VALUE(std::string, name) TEMPLATABLE_VALUE(uint8_t, direction) - void play(Ts... x) override { + void play(const Ts &...x) override { auto name = this->name_.value(x...); auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...); this->parent_->enroll_face(name, direction); @@ -200,7 +200,7 @@ template class DeleteAction : public Action, public Paren public: TEMPLATABLE_VALUE(int16_t, face_id) - void play(Ts... x) override { + void play(const Ts &...x) override { auto face_id = this->face_id_.value(x...); this->parent_->delete_face(face_id); } @@ -208,17 +208,17 @@ template class DeleteAction : public Action, public Paren template class DeleteAllAction : public Action, public Parented { public: - void play(Ts... x) override { this->parent_->delete_all_faces(); } + void play(const Ts &...x) override { this->parent_->delete_all_faces(); } }; template class ScanAction : public Action, public Parented { public: - void play(Ts... x) override { this->parent_->scan_face(); } + void play(const Ts &...x) override { this->parent_->scan_face(); } }; template class ResetAction : public Action, public Parented { public: - void play(Ts... x) override { this->parent_->reset(); } + void play(const Ts &...x) override { this->parent_->reset(); } }; } // namespace esphome::hlk_fm22x From 042a08887f848e348c47b92055cd5957acf19807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Nov 2025 18:54:49 -0600 Subject: [PATCH 182/320] [climate] Use C++17 nested namespace syntax (#12194) --- esphome/components/climate/automation.h | 6 ++---- esphome/components/climate/climate.cpp | 6 ++---- esphome/components/climate/climate.h | 6 ++---- esphome/components/climate/climate_mode.cpp | 6 ++---- esphome/components/climate/climate_mode.h | 6 ++---- esphome/components/climate/climate_traits.cpp | 6 ++---- esphome/components/climate/climate_traits.h | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 36cc8f4f21..fac56d9d9e 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" #include "climate.h" -namespace esphome { -namespace climate { +namespace esphome::climate { template class ControlAction : public Action { public: @@ -58,5 +57,4 @@ class StateTrigger : public Trigger { } }; -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 82b75660ba..b0fba6aa62 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/macros.h" -namespace esphome { -namespace climate { +namespace esphome::climate { static const char *const TAG = "climate"; @@ -762,5 +761,4 @@ void Climate::dump_traits_(const char *tag) { } } -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index b277877c3e..28a73d8c05 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -8,8 +8,7 @@ #include "climate_mode.h" #include "climate_traits.h" -namespace esphome { -namespace climate { +namespace esphome::climate { #define LOG_CLIMATE(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -345,5 +344,4 @@ class Climate : public EntityBase { const char *custom_preset_{nullptr}; }; -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_mode.cpp b/esphome/components/climate/climate_mode.cpp index 794f45ccd6..b153ee0424 100644 --- a/esphome/components/climate/climate_mode.cpp +++ b/esphome/components/climate/climate_mode.cpp @@ -1,7 +1,6 @@ #include "climate_mode.h" -namespace esphome { -namespace climate { +namespace esphome::climate { const LogString *climate_mode_to_string(ClimateMode mode) { switch (mode) { @@ -107,5 +106,4 @@ const LogString *climate_preset_to_string(ClimatePreset preset) { } } -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_mode.h b/esphome/components/climate/climate_mode.h index 44423d2f22..c961c44248 100644 --- a/esphome/components/climate/climate_mode.h +++ b/esphome/components/climate/climate_mode.h @@ -3,8 +3,7 @@ #include #include "esphome/core/log.h" -namespace esphome { -namespace climate { +namespace esphome::climate { /// Enum for all modes a climate device can be in. /// NOTE: If adding values, update ClimateModeMask in climate_traits.h to use the new last value @@ -132,5 +131,4 @@ const LogString *climate_swing_mode_to_string(ClimateSwingMode mode); /// Convert the given PresetMode to a human-readable string. const LogString *climate_preset_to_string(ClimatePreset preset); -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 342dffaad6..9bf2d9acd3 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -1,7 +1,6 @@ #include "climate_traits.h" -namespace esphome { -namespace climate { +namespace esphome::climate { int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_target_temperature_step_); @@ -11,5 +10,4 @@ int8_t ClimateTraits::get_current_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_current_temperature_step_); } -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 0eecf9789f..d358293475 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -6,8 +6,7 @@ #include "esphome/core/finite_set_mask.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace climate { +namespace esphome::climate { // Type aliases for climate enum bitmasks // These replace std::set to eliminate red-black tree overhead @@ -292,5 +291,4 @@ class ClimateTraits { std::vector supported_custom_presets_; }; -} // namespace climate -} // namespace esphome +} // namespace esphome::climate From 7317bf4a5d0adbda61fd7078b00e35348a276ed1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 30 Nov 2025 08:04:19 -0500 Subject: [PATCH 183/320] [esp32_can] Add P4 support (#12201) --- esphome/components/esp32_can/canbus.py | 3 +++ esphome/components/esp32_can/esp32_can.cpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index dfa98b2eff..acc3785f22 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -10,6 +10,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -59,6 +60,7 @@ CAN_SPEEDS_ESP32_S3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C6 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_H2 = {**CAN_SPEEDS_ESP32_S2} +CAN_SPEEDS_ESP32_P4 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS = { VARIANT_ESP32: CAN_SPEEDS_ESP32, @@ -67,6 +69,7 @@ CAN_SPEEDS = { VARIANT_ESP32C3: CAN_SPEEDS_ESP32_C3, VARIANT_ESP32C6: CAN_SPEEDS_ESP32_C6, VARIANT_ESP32H2: CAN_SPEEDS_ESP32_H2, + VARIANT_ESP32P4: CAN_SPEEDS_ESP32_P4, } diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index cdef7b1930..f9b63b8ebc 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -17,7 +17,7 @@ static const char *const TAG = "esp32_can"; static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config) { switch (bitrate) { #if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) case canbus::CAN_1KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_1KBITS(); return true; From e95ceafc175bc60fde8edc672cd2bb686934ec7b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 30 Nov 2025 08:04:33 -0500 Subject: [PATCH 184/320] [mopeka_pro_check] Fix negative temperatures (#12198) Co-authored-by: Claude --- esphome/components/mopeka_pro_check/mopeka_pro_check.cpp | 4 ++-- esphome/components/mopeka_pro_check/mopeka_pro_check.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index 9527f09f59..42d61f81a3 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -116,7 +116,7 @@ bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) // Get temperature of sensor if (this->temperature_ != nullptr) { - uint8_t temp_in_c = this->parse_temperature_(manu_data.data); + int8_t temp_in_c = this->parse_temperature_(manu_data.data); this->temperature_->publish_state(temp_in_c); } @@ -145,7 +145,7 @@ uint32_t MopekaProCheck::parse_distance_(const std::vector &message) { (MOPEKA_LPG_COEF[0] + MOPEKA_LPG_COEF[1] * raw_t + MOPEKA_LPG_COEF[2] * raw_t * raw_t)); } -uint8_t MopekaProCheck::parse_temperature_(const std::vector &message) { return (message[2] & 0x7F) - 40; } +int8_t MopekaProCheck::parse_temperature_(const std::vector &message) { return (message[2] & 0x7F) - 40; } SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector &message) { // Since a 8 bit value is being shifted and truncated to 2 bits all possible values are defined as enumeration diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index 4cbe8f2afe..41fb312152 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -61,7 +61,7 @@ class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi uint8_t parse_battery_level_(const std::vector &message); uint32_t parse_distance_(const std::vector &message); - uint8_t parse_temperature_(const std::vector &message); + int8_t parse_temperature_(const std::vector &message); SensorReadQuality parse_read_quality_(const std::vector &message); }; From 47c767fa5e0a139e9b7634c6dd36102b9afa0331 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 30 Nov 2025 08:04:45 -0500 Subject: [PATCH 185/320] [openthread] Add C5 support (#12200) --- esphome/components/openthread/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index e3ad3ed76c..5b1abe4fb5 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, add_idf_sdkconfig_option, @@ -152,7 +153,7 @@ CONFIG_SCHEMA = cv.All( ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), cv.only_with_esp_idf, - only_on_variant(supported=[VARIANT_ESP32C6, VARIANT_ESP32H2]), + only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), _validate, _require_vfs_select, ) From 8308bc29111c47e83117e405887b9e5e6f51da99 Mon Sep 17 00:00:00 2001 From: Jimmy Hedman Date: Sun, 30 Nov 2025 14:06:06 +0100 Subject: [PATCH 186/320] [mdns] Bump mDNS component to 1.9.1 (#12207) --- esphome/components/mdns/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 4776bef22f..1daac93a2e 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -165,7 +165,7 @@ async def to_code(config): cg.add_library("LEAmDNS", None) if CORE.using_esp_idf: - add_idf_component(name="espressif/mdns", ref="1.8.2") + add_idf_component(name="espressif/mdns", ref="1.9.1") cg.add_define("USE_MDNS") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index fcb3a4f438..b27b6b8ed1 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: espressif/esp32-camera: version: 2.1.1 espressif/mdns: - version: 1.8.2 + version: 1.9.1 espressif/esp_wifi_remote: version: 1.1.5 rules: From 82e12383302f28af748a499ee2e79664e43254c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Nov 2025 17:09:02 -0600 Subject: [PATCH 187/320] [lock] Refactor trigger classes to template and add integration tests (#12193) --- esphome/components/lock/automation.h | 18 ++---- .../fixtures/lock_automations.yaml | 17 ++++++ tests/integration/test_lock_automations.py | 58 +++++++++++++++++++ 3 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 tests/integration/fixtures/lock_automations.yaml create mode 100644 tests/integration/test_lock_automations.py diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index cba2c3fdda..011c6cc6af 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -49,26 +49,18 @@ template class LockCondition : public Condition { bool state_; }; -class LockLockTrigger : public Trigger<> { +template class LockStateTrigger : public Trigger<> { public: - LockLockTrigger(Lock *a_lock) { + explicit LockStateTrigger(Lock *a_lock) { a_lock->add_on_state_callback([this, a_lock]() { - if (a_lock->state == LockState::LOCK_STATE_LOCKED) { + if (a_lock->state == State) { this->trigger(); } }); } }; -class LockUnlockTrigger : public Trigger<> { - public: - LockUnlockTrigger(Lock *a_lock) { - a_lock->add_on_state_callback([this, a_lock]() { - if (a_lock->state == LockState::LOCK_STATE_UNLOCKED) { - this->trigger(); - } - }); - } -}; +using LockLockTrigger = LockStateTrigger; +using LockUnlockTrigger = LockStateTrigger; } // namespace esphome::lock diff --git a/tests/integration/fixtures/lock_automations.yaml b/tests/integration/fixtures/lock_automations.yaml new file mode 100644 index 0000000000..fe11e656fa --- /dev/null +++ b/tests/integration/fixtures/lock_automations.yaml @@ -0,0 +1,17 @@ +esphome: + name: lock-automations-test + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +lock: + - platform: template + id: test_lock + name: "Test Lock" + optimistic: true + on_lock: + - logger.log: "TRIGGER: on_lock fired" + on_unlock: + - logger.log: "TRIGGER: on_unlock fired" diff --git a/tests/integration/test_lock_automations.py b/tests/integration/test_lock_automations.py new file mode 100644 index 0000000000..e200a2eacd --- /dev/null +++ b/tests/integration/test_lock_automations.py @@ -0,0 +1,58 @@ +"""Integration test for lock automation triggers. + +Tests that on_lock and on_unlock triggers work correctly. +""" + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_lock_automations( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test lock on_lock and on_unlock triggers.""" + loop = asyncio.get_running_loop() + + # Futures for log line detection + on_lock_future: asyncio.Future[bool] = loop.create_future() + on_unlock_future: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for trigger messages.""" + if "TRIGGER: on_lock fired" in line and not on_lock_future.done(): + on_lock_future.set_result(True) + elif "TRIGGER: on_unlock fired" in line and not on_unlock_future.done(): + on_unlock_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Import here to avoid import errors when aioesphomeapi is not installed + from aioesphomeapi import LockCommand + + # Get entities + entities = await client.list_entities_services() + lock = next(e for e in entities[0] if e.object_id == "test_lock") + + # Test 1: Lock - should trigger on_lock + client.lock_command(key=lock.key, command=LockCommand.LOCK) + + try: + await asyncio.wait_for(on_lock_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_lock trigger did not fire") + + # Test 2: Unlock - should trigger on_unlock + client.lock_command(key=lock.key, command=LockCommand.UNLOCK) + + try: + await asyncio.wait_for(on_unlock_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_unlock trigger did not fire") From 2ca118f3718f18d76f07d43c7c7437953b5bb819 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Nov 2025 17:25:46 -0600 Subject: [PATCH 188/320] [web_server] Replace routing table with if-else chain to save 116 bytes RAM (#12139) --- esphome/components/web_server/web_server.cpp | 107 ++++++++++++------- 1 file changed, 68 insertions(+), 39 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index f5ca674161..bc48793ba2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1686,6 +1686,7 @@ std::string WebServer::event_state_json_generator(WebServer *web_server, void *s auto *event = static_cast(source); return web_server->event_json(event, get_event_type(event), DETAIL_STATE); } +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson std::string WebServer::event_all_json_generator(WebServer *web_server, void *source) { auto *event = static_cast(source); return web_server->event_json(event, get_event_type(event), DETAIL_ALL); @@ -1709,6 +1710,7 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty return builder.serialize(); } +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) #endif #ifdef USE_UPDATE @@ -1945,83 +1947,110 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // Parse URL for component routing UrlMatch match = match_url(url.c_str(), url.length(), false); - // Component routing using minimal code repetition - struct ComponentRoute { - const char *domain; - void (WebServer::*handler)(AsyncWebServerRequest *, const UrlMatch &); - }; - - static const ComponentRoute ROUTES[] = { + // Route to appropriate handler based on domain + // NOLINTNEXTLINE(readability-simplify-boolean-expr) + if (false) { // Start chain for else-if macro pattern + } #ifdef USE_SENSOR - {"sensor", &WebServer::handle_sensor_request}, + else if (match.domain_equals("sensor")) { + this->handle_sensor_request(request, match); + } #endif #ifdef USE_SWITCH - {"switch", &WebServer::handle_switch_request}, + else if (match.domain_equals("switch")) { + this->handle_switch_request(request, match); + } #endif #ifdef USE_BUTTON - {"button", &WebServer::handle_button_request}, + else if (match.domain_equals("button")) { + this->handle_button_request(request, match); + } #endif #ifdef USE_BINARY_SENSOR - {"binary_sensor", &WebServer::handle_binary_sensor_request}, + else if (match.domain_equals("binary_sensor")) { + this->handle_binary_sensor_request(request, match); + } #endif #ifdef USE_FAN - {"fan", &WebServer::handle_fan_request}, + else if (match.domain_equals("fan")) { + this->handle_fan_request(request, match); + } #endif #ifdef USE_LIGHT - {"light", &WebServer::handle_light_request}, + else if (match.domain_equals("light")) { + this->handle_light_request(request, match); + } #endif #ifdef USE_TEXT_SENSOR - {"text_sensor", &WebServer::handle_text_sensor_request}, + else if (match.domain_equals("text_sensor")) { + this->handle_text_sensor_request(request, match); + } #endif #ifdef USE_COVER - {"cover", &WebServer::handle_cover_request}, + else if (match.domain_equals("cover")) { + this->handle_cover_request(request, match); + } #endif #ifdef USE_NUMBER - {"number", &WebServer::handle_number_request}, + else if (match.domain_equals("number")) { + this->handle_number_request(request, match); + } #endif #ifdef USE_DATETIME_DATE - {"date", &WebServer::handle_date_request}, + else if (match.domain_equals("date")) { + this->handle_date_request(request, match); + } #endif #ifdef USE_DATETIME_TIME - {"time", &WebServer::handle_time_request}, + else if (match.domain_equals("time")) { + this->handle_time_request(request, match); + } #endif #ifdef USE_DATETIME_DATETIME - {"datetime", &WebServer::handle_datetime_request}, + else if (match.domain_equals("datetime")) { + this->handle_datetime_request(request, match); + } #endif #ifdef USE_TEXT - {"text", &WebServer::handle_text_request}, + else if (match.domain_equals("text")) { + this->handle_text_request(request, match); + } #endif #ifdef USE_SELECT - {"select", &WebServer::handle_select_request}, + else if (match.domain_equals("select")) { + this->handle_select_request(request, match); + } #endif #ifdef USE_CLIMATE - {"climate", &WebServer::handle_climate_request}, + else if (match.domain_equals("climate")) { + this->handle_climate_request(request, match); + } #endif #ifdef USE_LOCK - {"lock", &WebServer::handle_lock_request}, + else if (match.domain_equals("lock")) { + this->handle_lock_request(request, match); + } #endif #ifdef USE_VALVE - {"valve", &WebServer::handle_valve_request}, + else if (match.domain_equals("valve")) { + this->handle_valve_request(request, match); + } #endif #ifdef USE_ALARM_CONTROL_PANEL - {"alarm_control_panel", &WebServer::handle_alarm_control_panel_request}, + else if (match.domain_equals("alarm_control_panel")) { + this->handle_alarm_control_panel_request(request, match); + } #endif #ifdef USE_UPDATE - {"update", &WebServer::handle_update_request}, -#endif - }; - - // Check each route - for (const auto &route : ROUTES) { - if (match.domain_equals(route.domain)) { - (this->*route.handler)(request, match); - return; - } + else if (match.domain_equals("update")) { + this->handle_update_request(request, match); + } +#endif + else { + // No matching handler found - send 404 + ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); + request->send(404, "text/plain", "Not Found"); } - - // No matching handler found - send 404 - ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); - request->send(404, "text/plain", "Not Found"); } bool WebServer::isRequestHandlerTrivial() const { return false; } From bf4ef36c3a7875716ff6a4301e2e6eeb415ce62c Mon Sep 17 00:00:00 2001 From: Darsey Litzenberger Date: Sun, 30 Nov 2025 17:17:50 -0700 Subject: [PATCH 189/320] [ade7953] Apply voltage_gain setting to both channels (#12180) --- esphome/components/ade7953_base/ade7953_base.cpp | 13 ++++++++----- esphome/components/ade7953_base/ade7953_base.h | 10 ++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 5f5fdd27ee..821e4a3105 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -25,7 +25,8 @@ void ADE7953::setup() { this->ade_write_8(PGA_V_8, pga_v_); this->ade_write_8(PGA_IA_8, pga_ia_); this->ade_write_8(PGA_IB_8, pga_ib_); - this->ade_write_32(AVGAIN_32, vgain_); + this->ade_write_32(AVGAIN_32, avgain_); + this->ade_write_32(BVGAIN_32, bvgain_); this->ade_write_32(AIGAIN_32, aigain_); this->ade_write_32(BIGAIN_32, bigain_); this->ade_write_32(AWGAIN_32, awgain_); @@ -34,7 +35,8 @@ void ADE7953::setup() { this->ade_read_8(PGA_V_8, &pga_v_); this->ade_read_8(PGA_IA_8, &pga_ia_); this->ade_read_8(PGA_IB_8, &pga_ib_); - this->ade_read_32(AVGAIN_32, &vgain_); + this->ade_read_32(AVGAIN_32, &avgain_); + this->ade_read_32(BVGAIN_32, &bvgain_); this->ade_read_32(AIGAIN_32, &aigain_); this->ade_read_32(BIGAIN_32, &bigain_); this->ade_read_32(AWGAIN_32, &awgain_); @@ -63,13 +65,14 @@ void ADE7953::dump_config() { " PGA_V_8: 0x%X\n" " PGA_IA_8: 0x%X\n" " PGA_IB_8: 0x%X\n" - " VGAIN_32: 0x%08jX\n" + " AVGAIN_32: 0x%08jX\n" + " BVGAIN_32: 0x%08jX\n" " AIGAIN_32: 0x%08jX\n" " BIGAIN_32: 0x%08jX\n" " AWGAIN_32: 0x%08jX\n" " BWGAIN_32: 0x%08jX", - this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) vgain_, (uintmax_t) aigain_, - (uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_); + this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) avgain_, (uintmax_t) bvgain_, + (uintmax_t) aigain_, (uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_); } #define ADE_PUBLISH_(name, val, factor) \ diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h index d711a5c6be..bcafddca4e 100644 --- a/esphome/components/ade7953_base/ade7953_base.h +++ b/esphome/components/ade7953_base/ade7953_base.h @@ -46,7 +46,12 @@ class ADE7953 : public PollingComponent, public sensor::Sensor { void set_pga_ib(uint8_t pga_ib) { pga_ib_ = pga_ib; } // Set input gains - void set_vgain(uint32_t vgain) { vgain_ = vgain; } + void set_vgain(uint32_t vgain) { + // Datasheet says: "to avoid discrepancies in other registers, + // if AVGAIN is set then BVGAIN should be set to the same value." + avgain_ = vgain; + bvgain_ = vgain; + } void set_aigain(uint32_t aigain) { aigain_ = aigain; } void set_bigain(uint32_t bigain) { bigain_ = bigain; } void set_awgain(uint32_t awgain) { awgain_ = awgain; } @@ -100,7 +105,8 @@ class ADE7953 : public PollingComponent, public sensor::Sensor { uint8_t pga_v_; uint8_t pga_ia_; uint8_t pga_ib_; - uint32_t vgain_; + uint32_t avgain_; + uint32_t bvgain_; uint32_t aigain_; uint32_t bigain_; uint32_t awgain_; From 4335fcdb72cddb820b75e6ba20dc41b01739cce2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 30 Nov 2025 23:27:10 -0500 Subject: [PATCH 190/320] [psram] Add C5 support (#12215) Co-authored-by: Claude --- esphome/components/psram/__init__.py | 3 +++ tests/component_tests/psram/test_psram.py | 7 ++++--- tests/components/psram/test.esp32-c5-idf.yaml | 8 ++++++++ .../build_components_base.esp32-c5-idf.yaml | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 tests/components/psram/test.esp32-c5-idf.yaml create mode 100644 tests/test_build_components/build_components_base.esp32-c5-idf.yaml diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index c50c599855..4ee4e97696 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( get_esp32_variant, ) from esphome.components.esp32.const import ( + VARIANT_ESP32C5, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -55,6 +56,7 @@ SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), + VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32P4: (TYPE_HEX,), } @@ -63,6 +65,7 @@ SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), + VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32P4: (20, 100, 200), } diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index f8ad013689..86bc29cc84 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -23,22 +23,23 @@ from tests.component_tests.types import SetCoreConfigCallable UNSUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32C2, VARIANT_ESP32C3, - VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, ] SUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32, + VARIANT_ESP32C5, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANT_ESP32P4, ] SUPPORTED_PSRAM_MODES = { VARIANT_ESP32: ["quad"], + VARIANT_ESP32C5: ["quad"], + VARIANT_ESP32P4: ["hex"], VARIANT_ESP32S2: ["quad"], VARIANT_ESP32S3: ["quad", "octal"], - VARIANT_ESP32P4: ["hex"], } diff --git a/tests/components/psram/test.esp32-c5-idf.yaml b/tests/components/psram/test.esp32-c5-idf.yaml new file mode 100644 index 0000000000..fbd0132e2d --- /dev/null +++ b/tests/components/psram/test.esp32-c5-idf.yaml @@ -0,0 +1,8 @@ +esp32: + cpu_frequency: 240MHz + framework: + type: esp-idf + +psram: + speed: 120MHz + ignore_not_found: false diff --git a/tests/test_build_components/build_components_base.esp32-c5-idf.yaml b/tests/test_build_components/build_components_base.esp32-c5-idf.yaml new file mode 100644 index 0000000000..6468297e9a --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c5-idf.yaml @@ -0,0 +1,17 @@ +esphome: + name: componenttestesp32c5idf + friendly_name: $component_name + +esp32: + board: esp32-c5-devkitc-1 + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From 161a18b3269032e95c883dc67bf0928a77b7581e Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Dec 2025 00:33:23 -0600 Subject: [PATCH 191/320] [uart] Add `wake_loop_on_rx` flag for low latency processing (#12172) Co-authored-by: J. Nick Koston --- esphome/components/uart/__init__.py | 39 ++++++++++- .../uart/uart_component_esp_idf.cpp | 70 ++++++++++++++++++- .../components/uart/uart_component_esp_idf.h | 8 +++ esphome/core/defines.h | 1 + 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 7b0d9726b8..a1c78dd45c 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from logging import getLogger import math import re @@ -32,13 +33,15 @@ from esphome.const import ( PLATFORM_HOST, PlatformFramework, ) -from esphome.core import CORE, ID +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.yaml_util import make_data_base _LOGGER = getLogger(__name__) CODEOWNERS = ["@esphome/core"] +DOMAIN = "uart" + uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") @@ -52,6 +55,7 @@ LibreTinyUARTComponent = uart_ns.class_( ) HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Component) + NATIVE_UART_CLASSES = ( str(IDFUARTComponent), str(ESP8266UartComponent), @@ -100,6 +104,30 @@ MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True +@dataclass +class UARTData: + """State data for UART component configuration generation.""" + + wake_loop_on_rx: bool = False + + +def _get_data() -> UARTData: + """Get UART component data from CORE.data.""" + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +def request_wake_loop_on_rx() -> None: + """Request that the UART wake the main loop when data is received. + + Components that need low-latency notification of incoming UART data + should call this function during their code generation. + This enables the RX event task which wakes the main loop when data arrives. + """ + _get_data().wake_loop_on_rx = True + + def validate_raw_data(value): if isinstance(value, str): return value.encode("utf-8") @@ -335,6 +363,8 @@ async def to_code(config): if CONF_DEBUG in config: await debug_to_code(config[CONF_DEBUG], var) + CORE.add_job(final_step) + # A schema to use for all UART devices, all UART integrations must extend this! UART_DEVICE_SCHEMA = cv.Schema( @@ -472,6 +502,13 @@ async def uart_write_to_code(config, action_id, template_arg, args): return var +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional UART features.""" + if _get_data().wake_loop_on_rx: + cg.add_define("USE_UART_WAKE_LOOP_ON_RX") + + FILTER_SOURCE_FILES = filter_source_files_from_platform( { "uart_component_esp_idf.cpp": { diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 61ca8c1c0c..c6efe862c4 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -112,6 +112,12 @@ void IDFUARTComponent::load_settings(bool dump_config) { esp_err_t err; if (uart_is_driver_installed(this->uart_num_)) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (this->rx_event_task_handle_ != nullptr) { + vTaskDelete(this->rx_event_task_handle_); + this->rx_event_task_handle_ = nullptr; + } +#endif err = uart_driver_delete(this->uart_num_); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); @@ -204,6 +210,11 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Start the RX event task to enable low-latency data notifications + this->start_rx_event_task_(); +#endif // USE_UART_WAKE_LOOP_ON_RX + if (dump_config) { ESP_LOGCONFIG(TAG, "Reloaded UART %u", this->uart_num_); this->dump_config(); @@ -226,7 +237,11 @@ void IDFUARTComponent::dump_config() { " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" - " Stop bits: %u", + " Stop bits: %u" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + , this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); this->check_logger_conflict(); } @@ -337,6 +352,59 @@ void IDFUARTComponent::flush() { void IDFUARTComponent::check_logger_conflict() {} +#ifdef USE_UART_WAKE_LOOP_ON_RX +void IDFUARTComponent::start_rx_event_task_() { + // Create FreeRTOS task to monitor UART events + BaseType_t result = xTaskCreate(rx_event_task_func, // Task function + "uart_rx_evt", // Task name (max 16 chars) + 2240, // Stack size in bytes (~2.2KB); increase if needed for logging + this, // Task parameter (this pointer) + tskIDLE_PRIORITY + 1, // Priority (low, just above idle) + &this->rx_event_task_handle_ // Task handle + ); + + if (result != pdPASS) { + ESP_LOGE(TAG, "Failed to create RX event task"); + return; + } + + ESP_LOGV(TAG, "RX event task started"); +} + +void IDFUARTComponent::rx_event_task_func(void *param) { + auto *self = static_cast(param); + uart_event_t event; + + ESP_LOGV(TAG, "RX event task running"); + + // Run forever - task lifecycle matches component lifecycle + while (true) { + // Wait for UART events (blocks efficiently) + if (xQueueReceive(self->uart_event_queue_, &event, portMAX_DELAY) == pdTRUE) { + switch (event.type) { + case UART_DATA: + // Data available in UART RX buffer - wake the main loop + ESP_LOGVV(TAG, "Data event: %d bytes", event.size); + App.wake_loop_threadsafe(); + break; + + case UART_FIFO_OVF: + case UART_BUFFER_FULL: + ESP_LOGW(TAG, "FIFO overflow or ring buffer full - clearing"); + uart_flush_input(self->uart_num_); + App.wake_loop_threadsafe(); + break; + + default: + // Ignore other event types + ESP_LOGVV(TAG, "Event type: %d", event.type); + break; + } + } + } +} +#endif // USE_UART_WAKE_LOOP_ON_RX + } // namespace uart } // namespace esphome diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index a2ba2aa968..e47a323979 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -53,6 +53,14 @@ class IDFUARTComponent : public UARTComponent, public Component { bool has_peek_{false}; uint8_t peek_byte_; + +#ifdef USE_UART_WAKE_LOOP_ON_RX + // RX notification support + void start_rx_event_task_(); + static void rx_event_task_func(void *param); + + TaskHandle_t rx_event_task_handle_{nullptr}; +#endif // USE_UART_WAKE_LOOP_ON_RX }; } // namespace uart diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 538d4e3d6e..12dfdba5ce 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -107,6 +107,7 @@ #define USE_TIME #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER +#define USE_UART_WAKE_LOOP_ON_RX #define USE_UPDATE #define USE_VALVE #define USE_ZWAVE_PROXY From dbc16ce46884dd54e5f62a8f37d15394eb6b8613 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Dec 2025 02:48:47 -0600 Subject: [PATCH 192/320] [wifi_info] Fix compilation error when using only mac_address sensor, add tests (#12222) --- esphome/components/wifi_info/wifi_info_text_sensor.cpp | 4 ++++ esphome/components/wifi_info/wifi_info_text_sensor.h | 2 ++ tests/components/wifi_info/common-mac.yaml | 8 ++++++++ tests/components/wifi_info/common.yaml | 2 +- tests/components/wifi_info/test-mac.esp32-idf.yaml | 4 ++++ tests/components/wifi_info/test-mac.esp8266-ard.yaml | 4 ++++ tests/components/wifi_info/test-mac.rp2040-ard.yaml | 4 ++++ 7 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/components/wifi_info/common-mac.yaml create mode 100644 tests/components/wifi_info/test-mac.esp32-idf.yaml create mode 100644 tests/components/wifi_info/test-mac.esp8266-ard.yaml create mode 100644 tests/components/wifi_info/test-mac.rp2040-ard.yaml diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 92d3ea29f5..6c9d0c00e5 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -6,6 +6,8 @@ namespace esphome::wifi_info { static const char *const TAG = "wifi_info"; +#ifdef USE_WIFI_LISTENERS + static constexpr size_t MAX_STATE_LENGTH = 255; /******************** @@ -98,6 +100,8 @@ void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::b this->publish_state(buf); } +#endif + /********************* * MacAddressWifiInfo ********************/ diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 74d951f922..f1f85c114f 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -9,6 +9,7 @@ namespace esphome::wifi_info { +#ifdef USE_WIFI_LISTENERS class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: void setup() override; @@ -62,6 +63,7 @@ class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, pu // WiFiConnectStateListener interface void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; +#endif class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: diff --git a/tests/components/wifi_info/common-mac.yaml b/tests/components/wifi_info/common-mac.yaml new file mode 100644 index 0000000000..3571cd08c4 --- /dev/null +++ b/tests/components/wifi_info/common-mac.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +text_sensor: + - platform: wifi_info + mac_address: + name: MAC Address diff --git a/tests/components/wifi_info/common.yaml b/tests/components/wifi_info/common.yaml index cf5ea563ba..f87d381d0c 100644 --- a/tests/components/wifi_info/common.yaml +++ b/tests/components/wifi_info/common.yaml @@ -13,6 +13,6 @@ text_sensor: bssid: name: BSSID mac_address: - name: Mac Address + name: MAC Address dns_address: name: DNS ADdress diff --git a/tests/components/wifi_info/test-mac.esp32-idf.yaml b/tests/components/wifi_info/test-mac.esp32-idf.yaml new file mode 100644 index 0000000000..9d561ca2ce --- /dev/null +++ b/tests/components/wifi_info/test-mac.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common-mac.yaml diff --git a/tests/components/wifi_info/test-mac.esp8266-ard.yaml b/tests/components/wifi_info/test-mac.esp8266-ard.yaml new file mode 100644 index 0000000000..05f6344fb6 --- /dev/null +++ b/tests/components/wifi_info/test-mac.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common-mac.yaml diff --git a/tests/components/wifi_info/test-mac.rp2040-ard.yaml b/tests/components/wifi_info/test-mac.rp2040-ard.yaml new file mode 100644 index 0000000000..d2d54def9d --- /dev/null +++ b/tests/components/wifi_info/test-mac.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common-mac.yaml From 664881bc130f070d84cc88f59ed9a2baf7dc2654 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Dec 2025 06:57:18 -0600 Subject: [PATCH 193/320] [uart] Convert to C++17 namespace style (#12220) --- esphome/components/uart/automation.h | 6 ++---- esphome/components/uart/button/uart_button.cpp | 6 ++---- esphome/components/uart/button/uart_button.h | 6 ++---- .../components/uart/packet_transport/uart_transport.cpp | 7 +++---- esphome/components/uart/packet_transport/uart_transport.h | 6 ++---- esphome/components/uart/switch/uart_switch.cpp | 6 ++---- esphome/components/uart/switch/uart_switch.h | 6 ++---- esphome/components/uart/uart.cpp | 6 ++---- esphome/components/uart/uart.h | 6 ++---- esphome/components/uart/uart_component.cpp | 6 ++---- esphome/components/uart/uart_component.h | 6 ++---- esphome/components/uart/uart_component_esp8266.cpp | 6 ++---- esphome/components/uart/uart_component_esp8266.h | 7 ++----- esphome/components/uart/uart_component_esp_idf.cpp | 8 +++----- esphome/components/uart/uart_component_esp_idf.h | 7 ++----- esphome/components/uart/uart_component_host.cpp | 7 ++----- esphome/components/uart/uart_component_host.h | 7 ++----- esphome/components/uart/uart_component_libretiny.cpp | 7 ++----- esphome/components/uart/uart_component_libretiny.h | 7 ++----- esphome/components/uart/uart_component_rp2040.cpp | 7 ++----- esphome/components/uart/uart_component_rp2040.h | 7 ++----- esphome/components/uart/uart_debugger.cpp | 6 ++---- esphome/components/uart/uart_debugger.h | 6 ++---- 23 files changed, 48 insertions(+), 101 deletions(-) diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index c2eb308eb8..c99caac97b 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace uart { +namespace esphome::uart { template class UARTWriteAction : public Action, public Parented { public: @@ -41,5 +40,4 @@ template class UARTWriteAction : public Action, public Pa } code_; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index dd228b9bb7..809ceaabb0 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -1,8 +1,7 @@ #include "uart_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.button"; @@ -13,5 +12,4 @@ void UARTButton::press_action() { void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 8c7d762a05..2b530d3c4b 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace uart { +namespace esphome::uart { class UARTButton : public button::Button, public UARTDevice, public Component { public: @@ -21,5 +20,4 @@ class UARTButton : public button::Button, public UARTDevice, public Component { std::vector data_; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 423b657532..4a9aa0fe47 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -2,8 +2,7 @@ #include "esphome/core/application.h" #include "uart_transport.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart_transport"; @@ -84,5 +83,5 @@ void UARTTransport::send_packet(const std::vector &buf) const { this->write_byte_(crc >> 8); this->parent_->write_byte(FLAG_BYTE); } -} // namespace uart -} // namespace esphome + +} // namespace esphome::uart diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index f1431e948c..e84bed95e6 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -5,8 +5,7 @@ #include #include "../uart.h" -namespace esphome { -namespace uart { +namespace esphome::uart { /** * A transport protocol for sending and receiving packets over a UART connection. @@ -37,5 +36,4 @@ class UARTTransport : public packet_transport::PacketTransport, public UARTDevic bool rx_control_{}; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 4f5ff9fc99..642bd19772 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.switch"; @@ -58,5 +57,4 @@ void UARTSwitch::dump_config() { } } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 909307d57e..5730fc9b4b 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -7,8 +7,7 @@ #include #include -namespace esphome { -namespace uart { +namespace esphome::uart { class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { public: @@ -33,5 +32,4 @@ class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { uint32_t last_transmission_; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index b18454bf9d..6cfd6537a5 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart"; @@ -43,5 +42,4 @@ const LogString *parity_to_str(UARTParityOptions parity) { } } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index e2912db122..72c282f1c4 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class UARTDevice { public: @@ -74,5 +73,4 @@ class UARTDevice { UARTComponent *parent_{nullptr}; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 8f670275d4..30fc208fc9 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -1,7 +1,6 @@ #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart"; @@ -28,5 +27,4 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 452688b3e9..fd528e228f 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -11,8 +11,7 @@ #include "esphome/core/automation.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { enum UARTParityOptions { UART_CONFIG_PARITY_NONE, @@ -199,5 +198,4 @@ class UARTComponent { #endif }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index c84a877ef4..c78daa7462 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -9,8 +9,7 @@ #include "esphome/components/logger/logger.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.arduino_esp8266"; bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -331,6 +330,5 @@ int ESP8266SoftwareSerial::available() { return avail; } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart #endif // USE_ESP8266 diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 749dd4c61e..e33dd00644 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -9,8 +9,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class ESP8266SoftwareSerial { public: @@ -88,7 +87,5 @@ class ESP8266UartComponent : public UARTComponent, public Component { static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_ESP8266 diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index c6efe862c4..b438e4f7a6 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -14,8 +14,8 @@ #include "esphome/components/logger/logger.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { + static const char *const TAG = "uart.idf"; uart_config_t IDFUARTComponent::get_config_() { @@ -405,7 +405,5 @@ void IDFUARTComponent::rx_event_task_func(void *param) { } #endif // USE_UART_WAKE_LOOP_ON_RX -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index e47a323979..bd6d0c792e 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class IDFUARTComponent : public UARTComponent, public Component { public: @@ -63,7 +62,5 @@ class IDFUARTComponent : public UARTComponent, public Component { #endif // USE_UART_WAKE_LOOP_ON_RX }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index adb11266c5..69b24607d1 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -96,8 +96,7 @@ speed_t get_baud(int baud) { } // namespace -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.host"; @@ -296,7 +295,5 @@ void HostUartComponent::update_error_(const std::string &error) { ESP_LOGE(TAG, "Port error: %s", error.c_str()); } -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_HOST diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index c1f1dd0d2c..a4a6946c0c 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class HostUartComponent : public UARTComponent, public Component { public: @@ -32,7 +31,5 @@ class HostUartComponent : public UARTComponent, public Component { uint8_t peek_byte_; }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_HOST diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 1e408b169b..01c7063fe8 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -14,8 +14,7 @@ #include #endif -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.lt"; @@ -187,7 +186,5 @@ void LibreTinyUARTComponent::check_logger_conflict() { #endif } -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_LIBRETINY diff --git a/esphome/components/uart/uart_component_libretiny.h b/esphome/components/uart/uart_component_libretiny.h index 00982fd297..ec13e7da5a 100644 --- a/esphome/components/uart/uart_component_libretiny.h +++ b/esphome/components/uart/uart_component_libretiny.h @@ -8,8 +8,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class LibreTinyUARTComponent : public UARTComponent, public Component { public: @@ -37,7 +36,5 @@ class LibreTinyUARTComponent : public UARTComponent, public Component { int8_t hardware_idx_{-1}; }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_LIBRETINY diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index cd3905b5c1..5799d26a54 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -11,8 +11,7 @@ #include "esphome/components/logger/logger.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.arduino_rp2040"; @@ -193,7 +192,5 @@ void RP2040UartComponent::flush() { this->serial_->flush(); } -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_RP2040 diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2040.h index f26c913cff..d626d11a2e 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2040.h @@ -11,8 +11,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class RP2040UartComponent : public UARTComponent, public Component { public: @@ -40,7 +39,5 @@ class RP2040UartComponent : public UARTComponent, public Component { HardwareSerial *serial_{nullptr}; }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_RP2040 diff --git a/esphome/components/uart/uart_debugger.cpp b/esphome/components/uart/uart_debugger.cpp index e2d92eac60..b51a57d68e 100644 --- a/esphome/components/uart/uart_debugger.cpp +++ b/esphome/components/uart/uart_debugger.cpp @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart_debug"; @@ -197,6 +196,5 @@ void UARTDebug::log_binary(UARTDirection direction, std::vector bytes, delay(10); } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart #endif diff --git a/esphome/components/uart/uart_debugger.h b/esphome/components/uart/uart_debugger.h index 4f9b6d09df..df87655962 100644 --- a/esphome/components/uart/uart_debugger.h +++ b/esphome/components/uart/uart_debugger.h @@ -8,8 +8,7 @@ #include "uart.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { /// The UARTDebugger class adds debugging support to a UART bus. /// @@ -96,6 +95,5 @@ class UARTDebug { static void log_binary(UARTDirection direction, std::vector bytes, uint8_t separator); }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart #endif From 065c1bfc6a438230d10d4595a79570c1bb414f96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 08:34:07 -0600 Subject: [PATCH 194/320] [core] Fix status_momentary API misuse and optimize parameter type (#12216) --- .../components/demo/demo_alarm_control_panel.h | 4 ++-- esphome/components/es8388/es8388.cpp | 4 ++-- esphome/components/esphome/ota/ota_esphome.cpp | 2 +- .../micro_wake_word/micro_wake_word.cpp | 5 ++--- esphome/components/sound_level/sound_level.cpp | 4 ++-- esphome/core/component.cpp | 4 ++-- esphome/core/component.h | 18 ++++++++++++++++-- 7 files changed, 27 insertions(+), 14 deletions(-) diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 9902d27882..f59434830b 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -33,7 +33,7 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm() && call.get_code().has_value()) { if (call.get_code().value() != "1234") { - this->status_momentary_error("Invalid code", 5000); + this->status_momentary_error("invalid_code", 5000); return; } } @@ -42,7 +42,7 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { case ACP_STATE_DISARMED: if (this->get_requires_code() && call.get_code().has_value()) { if (call.get_code().value() != "1234") { - this->status_momentary_error("Invalid code", 5000); + this->status_momentary_error("invalid_code", 5000); return; } } diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 69c16a9615..5abe7a5e5f 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -225,7 +225,7 @@ bool ES8388::set_dac_output(DacOutputLine line) { optional ES8388::get_dac_power() { uint8_t dac_power; if (!this->read_byte(ES8388_DACPOWER, &dac_power)) { - this->status_momentary_warning("Failed to read ES8388_DACPOWER"); + this->status_momentary_warning("dacpower_read"); return {}; } switch (dac_power) { @@ -268,7 +268,7 @@ bool ES8388::set_adc_input_mic(AdcInputMicLine line) { optional ES8388::get_mic_input() { uint8_t mic_input; if (!this->read_byte(ES8388_ADCCONTROL2, &mic_input)) { - this->status_momentary_warning("Failed to read ES8388_ADCCONTROL2"); + this->status_momentary_warning("adccontrol2_read"); return {}; } switch (mic_input) { diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index eb6c61a69b..852a50cc22 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -402,7 +402,7 @@ error: this->backend_->abort(); } - this->status_momentary_error("onerror", 5000); + this->status_momentary_error("err", 5000); #ifdef USE_OTA_STATE_CALLBACK this->state_callback_.call(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index a0547b158e..ec8fa34da4 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -298,8 +298,7 @@ void MicroWakeWord::loop() { // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, this->microphone_source_->get_audio_stream_info().get_sample_rate())) { - this->status_momentary_error( - "Failed to allocate buffers for spectrogram feature processor, attempting again in 1 second", 1000); + this->status_momentary_error("frontend_alloc", 1000); return; } @@ -308,7 +307,7 @@ void MicroWakeWord::loop() { if (this->inference_task_handle_ == nullptr) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state - this->status_momentary_error("Task failed to start, attempting again in 1 second", 1000); + this->status_momentary_error("task_start", 1000); } } break; diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index db6b168bbc..2719172409 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -167,7 +167,7 @@ bool SoundLevelComponent::start_() { this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("Failed to allocate transfer buffer", 15000); + this->status_momentary_error("transfer_buffer", 15000); return false; } @@ -176,7 +176,7 @@ bool SoundLevelComponent::start_() { std::shared_ptr temp_ring_buffer = RingBuffer::create(this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("Failed to allocate ring buffer", 15000); + this->status_momentary_error("ring_buffer", 15000); this->stop_(); return false; } else { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 5e6ace8873..b7c0cedb76 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -369,11 +369,11 @@ void Component::status_clear_error() { this->component_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } -void Component::status_momentary_warning(const std::string &name, uint32_t length) { +void Component::status_momentary_warning(const char *name, uint32_t length) { this->status_set_warning(); this->set_timeout(name, length, [this]() { this->status_clear_warning(); }); } -void Component::status_momentary_error(const std::string &name, uint32_t length) { +void Component::status_momentary_error(const char *name, uint32_t length) { this->status_set_error(); this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 51a9290e8b..3d45a020c4 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -241,9 +241,23 @@ class Component { void status_clear_error(); - void status_momentary_warning(const std::string &name, uint32_t length = 5000); + /** Set warning status flag and automatically clear it after a timeout. + * + * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). + * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. + * This is NOT a message to display - use status_set_warning() with a message if logging is needed. + * @param length Duration in milliseconds before the warning is automatically cleared. + */ + void status_momentary_warning(const char *name, uint32_t length = 5000); - void status_momentary_error(const std::string &name, uint32_t length = 5000); + /** Set error status flag and automatically clear it after a timeout. + * + * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). + * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. + * This is NOT a message to display - use status_set_error() with a message if logging is needed. + * @param length Duration in milliseconds before the error is automatically cleared. + */ + void status_momentary_error(const char *name, uint32_t length = 5000); bool has_overridden_loop() const; From b322622ef17a08fe40b8d2b84ef57c9c3766982f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 1 Dec 2025 16:47:00 +0100 Subject: [PATCH 195/320] [micronova] Convert to C++17 namespace style (#12229) --- esphome/components/micronova/button/micronova_button.cpp | 6 ++---- esphome/components/micronova/button/micronova_button.h | 6 ++---- esphome/components/micronova/micronova.cpp | 6 ++---- esphome/components/micronova/micronova.h | 6 ++---- esphome/components/micronova/number/micronova_number.cpp | 6 ++---- esphome/components/micronova/number/micronova_number.h | 6 ++---- esphome/components/micronova/sensor/micronova_sensor.cpp | 6 ++---- esphome/components/micronova/sensor/micronova_sensor.h | 6 ++---- esphome/components/micronova/switch/micronova_switch.cpp | 6 ++---- esphome/components/micronova/switch/micronova_switch.h | 6 ++---- .../micronova/text_sensor/micronova_text_sensor.cpp | 6 ++---- .../micronova/text_sensor/micronova_text_sensor.h | 6 ++---- 12 files changed, 24 insertions(+), 48 deletions(-) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index c1903fd878..147fef37bd 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -1,7 +1,6 @@ #include "micronova_button.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaButton::press_action() { switch (this->get_function()) { @@ -14,5 +13,4 @@ void MicroNovaButton::press_action() { this->micronova_->update(); } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 77649051d6..5c1d7d8455 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/button/button.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { public: @@ -19,5 +18,4 @@ class MicroNovaButton : public Component, public button::Button, public MicroNov void press_action() override; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index b96798ed12..52b719bff2 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -1,8 +1,7 @@ #include "micronova.h" #include "esphome/core/log.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNova::setup() { if (this->enable_rx_pin_ != nullptr) { @@ -144,5 +143,4 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { } } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index fc68d941cf..acb85fad3c 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace micronova { +namespace esphome::micronova { static const char *const TAG = "micronova"; static const int STOVE_REPLY_DELAY = 60; @@ -160,5 +159,4 @@ class MicroNova : public PollingComponent, public uart::UARTDevice { MicroNovaSwitchListener *stove_switch_{nullptr}; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 244eb7ee9f..b311c85b99 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -1,7 +1,6 @@ #include "micronova_number.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaNumber::process_value_from_stove(int value_from_stove) { float new_sensor_value = 0; @@ -41,5 +40,4 @@ void MicroNovaNumber::control(float value) { this->micronova_->update(); } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 49c6358255..79e59dbc28 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -3,8 +3,7 @@ #include "esphome/components/micronova/micronova.h" #include "esphome/components/number/number.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaNumber : public number::Number, public MicroNovaSensorListener { public: @@ -24,5 +23,4 @@ class MicroNovaNumber : public number::Number, public MicroNovaSensorListener { uint8_t memory_write_location_ = 0; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 3f0c0feaf8..9fd8832f29 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -1,7 +1,6 @@ #include "micronova_sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { @@ -31,5 +30,4 @@ void MicroNovaSensor::process_value_from_stove(int value_from_stove) { this->publish_state(new_sensor_value); } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index 9d5ae96b87..081e68b09d 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -3,8 +3,7 @@ #include "esphome/components/micronova/micronova.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener { public: @@ -23,5 +22,4 @@ class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener { int fan_speed_offset_ = 0; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 28674acd96..81d36adccb 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -1,7 +1,6 @@ #include "micronova_switch.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { switch (this->get_function()) { @@ -31,5 +30,4 @@ void MicroNovaSwitch::write_state(bool state) { } } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index b0ca33b497..7019084355 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener { public: @@ -25,5 +24,4 @@ class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNo void write_state(bool state) override; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 03b192ffd1..b62fb1afce 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -1,7 +1,6 @@ #include "micronova_text_sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { @@ -27,5 +26,4 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { } } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index b4e5de9bb3..352f049654 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -3,8 +3,7 @@ #include "esphome/components/micronova/micronova.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSensorListener { public: @@ -16,5 +15,4 @@ class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSens void process_value_from_stove(int value_from_stove) override; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova From 6d336676a2390491827ea0920376cff73955f71f Mon Sep 17 00:00:00 2001 From: Juri Berlanda Date: Mon, 1 Dec 2025 18:09:58 +0100 Subject: [PATCH 196/320] [remote_transmitter, remote_receiver] Add RP2040 support (#12048) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/remote_receiver/__init__.py | 2 ++ .../components/remote_receiver/remote_receiver.cpp | 2 +- esphome/components/remote_receiver/remote_receiver.h | 4 ++-- esphome/components/remote_transmitter/__init__.py | 1 + .../remote_transmitter/remote_transmitter.cpp | 6 +++--- .../remote_transmitter/remote_transmitter.h | 2 +- .../components/remote_receiver/test.rp2040-ard.yaml | 12 ++++++++++++ .../remote_transmitter/test.rp2040-ard.yaml | 7 +++++++ 8 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 tests/components/remote_receiver/test.rp2040-ard.yaml create mode 100644 tests/components/remote_transmitter/test.rp2040-ard.yaml diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index cd2b440645..e79b3f91ed 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -114,6 +114,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", + rp2040="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, @@ -213,6 +214,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2040_ARDUINO, }, } ) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index a8438e20d7..53bfb0890f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) namespace esphome { namespace remote_receiver { diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 3ddcf353c7..3d2f7f0ef9 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -12,7 +12,7 @@ namespace esphome { namespace remote_receiver { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); @@ -84,7 +84,7 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, std::string error_string_{""}; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_ESP32) || defined(USE_RP2040) RemoteReceiverComponentStore store_; HighFrequencyLoopRequester high_freq_; #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index faa6c827f7..ff055b959b 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -156,6 +156,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2040_ARDUINO, }, } ) diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 347e9d9d33..576143bcbc 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) namespace esphome { namespace remote_transmitter { @@ -40,8 +40,8 @@ void RemoteTransmitterComponent::await_target_time_() { if (this->target_time_ == 0) { this->target_time_ = current_time; } else if ((int32_t) (this->target_time_ - current_time) > 0) { -#if defined(USE_LIBRETINY) - // busy loop for libretiny is required (see the comment inside micros() in wiring.c) +#if defined(USE_LIBRETINY) || defined(USE_RP2040) + // busy loop is required for libretiny and rp2040 as interrupts are disabled while ((int32_t) (this->target_time_ - micros()) > 0) ; #else diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index cc3b82ad61..dd6a849e4c 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -62,7 +62,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); diff --git a/tests/components/remote_receiver/test.rp2040-ard.yaml b/tests/components/remote_receiver/test.rp2040-ard.yaml new file mode 100644 index 0000000000..c9784ae003 --- /dev/null +++ b/tests/components/remote_receiver/test.rp2040-ard.yaml @@ -0,0 +1,12 @@ +remote_receiver: + id: rcvr + pin: GPIO5 + dump: all + <<: !include common-actions.yaml + +binary_sensor: + - platform: remote_receiver + name: Panasonic Remote Input + panasonic: + address: 0x4004 + command: 0x100BCBD diff --git a/tests/components/remote_transmitter/test.rp2040-ard.yaml b/tests/components/remote_transmitter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..19759360f4 --- /dev/null +++ b/tests/components/remote_transmitter/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO5 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml From 2b7695ba3fea894d9ff2787150e87c119ac42e46 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:40:56 -0500 Subject: [PATCH 197/320] [core] Fix clean all windows (#12217) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/writer.py | 35 ++++++++--- tests/unit_tests/test_writer.py | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 9 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index 3124e9e12c..721db07f96 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,8 +1,12 @@ +from collections.abc import Callable import importlib import logging import os from pathlib import Path import re +import shutil +import stat +from types import TracebackType from esphome import loader from esphome.config import iter_component_configs, iter_components @@ -301,9 +305,24 @@ def clean_cmake_cache(): pioenvs_cmake_path.unlink() -def clean_build(clear_pio_cache: bool = True): - import shutil +def _rmtree_error_handler( + func: Callable[[str], object], + path: str, + exc_info: tuple[type[BaseException], BaseException, TracebackType | None], +) -> None: + """Error handler for shutil.rmtree to handle read-only files on Windows. + On Windows, git pack files and other files may be marked read-only, + causing shutil.rmtree to fail with "Access is denied". This handler + removes the read-only flag and retries the deletion. + """ + if os.access(path, os.W_OK): + raise exc_info[1].with_traceback(exc_info[2]) + os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) + func(path) + + +def clean_build(clear_pio_cache: bool = True): # Allow skipping cache cleaning for integration tests if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): _LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)") @@ -312,11 +331,11 @@ def clean_build(clear_pio_cache: bool = True): pioenvs = CORE.relative_pioenvs_path() if pioenvs.is_dir(): _LOGGER.info("Deleting %s", pioenvs) - shutil.rmtree(pioenvs) + shutil.rmtree(pioenvs, onerror=_rmtree_error_handler) piolibdeps = CORE.relative_piolibdeps_path() if piolibdeps.is_dir(): _LOGGER.info("Deleting %s", piolibdeps) - shutil.rmtree(piolibdeps) + shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler) dependencies_lock = CORE.relative_build_path("dependencies.lock") if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) @@ -337,12 +356,10 @@ def clean_build(clear_pio_cache: bool = True): cache_dir = Path(config.get("platformio", "cache_dir")) if cache_dir.is_dir(): _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir) + shutil.rmtree(cache_dir, onerror=_rmtree_error_handler) def clean_all(configuration: list[str]): - import shutil - data_dirs = [] for config in configuration: item = Path(config) @@ -364,7 +381,7 @@ def clean_all(configuration: list[str]): if item.is_file() and not item.name.endswith(".json"): item.unlink() elif item.is_dir() and item.name != "storage": - shutil.rmtree(item) + shutil.rmtree(item, onerror=_rmtree_error_handler) # Clean PlatformIO project files try: @@ -378,7 +395,7 @@ def clean_all(configuration: list[str]): path = Path(config.get("platformio", pio_dir)) if path.is_dir(): _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) - shutil.rmtree(path) + shutil.rmtree(path, onerror=_rmtree_error_handler) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a2a358f4d3..9fa60c06ec 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,7 +1,9 @@ """Test writer module functionality.""" from collections.abc import Callable +import os from pathlib import Path +import stat from typing import Any from unittest.mock import MagicMock, patch @@ -15,6 +17,7 @@ from esphome.writer import ( CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, + clean_all, clean_build, clean_cmake_cache, storage_should_clean, @@ -1062,3 +1065,103 @@ def test_clean_all_preserves_json_files( # Verify logging mentions cleaning assert "Cleaning" in caplog.text assert str(build_dir) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_handles_readonly_files( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_build handles read-only files (e.g., git pack files on Windows).""" + # Create directory structure with read-only files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + git_dir = pioenvs_dir / ".git" / "objects" / "pack" + git_dir.mkdir(parents=True) + + # Create a read-only file (simulating git pack files on Windows) + readonly_file = git_dir / "pack-abc123.pack" + readonly_file.write_text("pack data") + os.chmod(readonly_file, stat.S_IRUSR) # Read-only + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + + # Verify file is read-only + assert not os.access(readonly_file, os.W_OK) + + # Call the function - should not crash + clean_build() + + # Verify directory was removed despite read-only files + assert not pioenvs_dir.exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_handles_readonly_files( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_all handles read-only files.""" + # Create config directory + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + + # Create a subdirectory with read-only files + subdir = build_dir / "subdir" + subdir.mkdir() + readonly_file = subdir / "readonly.txt" + readonly_file.write_text("content") + os.chmod(readonly_file, stat.S_IRUSR) # Read-only + + # Verify file is read-only + assert not os.access(readonly_file, os.W_OK) + + # Call the function - should not crash + clean_all([str(config_dir)]) + + # Verify directory was removed despite read-only files + assert not subdir.exists() + assert build_dir.exists() # .esphome dir itself is preserved + + +@patch("esphome.writer.CORE") +def test_clean_build_reraises_for_other_errors( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_build re-raises errors that are not read-only permission issues.""" + # Create directory structure with a read-only subdirectory + # This prevents file deletion and triggers the error handler + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + subdir = pioenvs_dir / "subdir" + subdir.mkdir() + test_file = subdir / "test.txt" + test_file.write_text("content") + + # Make subdir read-only so files inside can't be deleted + os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR) + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + + try: + # Mock os.access in writer module to return True (writable) + # This simulates a case where the error is NOT due to read-only permissions + # so the error handler should re-raise instead of trying to fix permissions + with ( + patch("esphome.writer.os.access", return_value=True), + pytest.raises(PermissionError), + ): + clean_build() + finally: + # Cleanup - restore write permission so tmp_path cleanup works + os.chmod(subdir, stat.S_IRWXU) From 6a79ce8eff9cf6312ee4d0333df732ab948af518 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Dec 2025 14:16:39 -0600 Subject: [PATCH 198/320] [uart] Automatically enable the socket wake infrastructure when RX wake requested (#12221) --- esphome/components/uart/__init__.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index a1c78dd45c..6494aaa286 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -42,6 +42,17 @@ _LOGGER = getLogger(__name__) CODEOWNERS = ["@esphome/core"] DOMAIN = "uart" + +def AUTO_LOAD() -> list[str]: + """Ideally, we would only auto-load socket only when wake_loop_on_rx is requested; + however, AUTO_LOAD is examined before wake_loop_on_rx is set, so instead, since ESP32 + always uses socket select support in the main app, we'll just ensure it's loaded here. + """ + if CORE.is_esp32: + return ["socket"] + return [] + + uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") @@ -125,7 +136,15 @@ def request_wake_loop_on_rx() -> None: should call this function during their code generation. This enables the RX event task which wakes the main loop when data arrives. """ - _get_data().wake_loop_on_rx = True + data = _get_data() + if not data.wake_loop_on_rx: + data.wake_loop_on_rx = True + + # UART RX event task uses wake_loop_threadsafe() to notify the main loop + # Automatically enable the socket wake infrastructure when RX wake is requested + from esphome.components import socket + + socket.require_wake_loop_threadsafe() def validate_raw_data(value): From 52fe3de78f81bea1d44fb4f87ed90e51ddc8ac92 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Dec 2025 14:27:20 -0600 Subject: [PATCH 199/320] [zwave_proxy] Use new socket wake infrastructure to reduce latency, convert to C++17 namespace style (#12135) Co-authored-by: J. Nick Koston --- esphome/components/zwave_proxy/__init__.py | 3 +++ esphome/components/zwave_proxy/zwave_proxy.cpp | 8 ++++---- esphome/components/zwave_proxy/zwave_proxy.h | 6 ++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index d88f9f7041..5be05bb464 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -41,3 +41,6 @@ async def to_code(config): await cg.register_component(var, config) await uart.register_uart_device(var, config) cg.add_define("USE_ZWAVE_PROXY") + + # Request UART to wake the main loop when data arrives for low-latency processing + uart.request_wake_loop_on_rx() diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a26a9b2335..e0ca5529b8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace zwave_proxy { +namespace esphome::zwave_proxy { static const char *const TAG = "zwave_proxy"; @@ -144,6 +143,7 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; + case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: if (this->api_connection_ != api_connection) { ESP_LOGV(TAG, "API connection is not subscribed"); @@ -151,6 +151,7 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en } this->api_connection_ = nullptr; break; + default: ESP_LOGW(TAG, "Unknown request type: %d", type); break; @@ -342,5 +343,4 @@ bool ZWaveProxy::response_handler_() { ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace zwave_proxy -} // namespace esphome +} // namespace esphome::zwave_proxy diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 20d9090d98..e23e202bea 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace zwave_proxy { +namespace esphome::zwave_proxy { static constexpr size_t MAX_ZWAVE_FRAME_SIZE = 257; // Maximum Z-Wave frame size @@ -89,5 +88,4 @@ class ZWaveProxy : public uart::UARTDevice, public Component { extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace zwave_proxy -} // namespace esphome +} // namespace esphome::zwave_proxy From 78df884bb54feacb181ca66ac7907de551d08ab8 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:03:00 -0500 Subject: [PATCH 200/320] [rtl87xx] Fix AsyncTCP compilation by upgrading FreeRTOS to 8.2.3 (#12230) Co-authored-by: Claude --- esphome/components/rtl87xx/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 109c986f75..d24ffcea3d 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -6,6 +6,7 @@ # in schema.py file in this directory. from esphome import pins +import esphome.codegen as cg from esphome.components import libretiny from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, @@ -45,6 +46,9 @@ CONFIG_SCHEMA.prepend_extra(_set_core_data) async def to_code(config): + # Use FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake required by AsyncTCP 3.4.3+ + # https://github.com/esphome/esphome/issues/10220 + cg.add_platformio_option("custom_versions.freertos", "8.2.3") return await libretiny.component_to_code(config) From d4bd282bb4edc3f2f5c2d28e4e65b496edd0ef83 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Dec 2025 16:08:49 -0600 Subject: [PATCH 201/320] [helpers] Fix unit tests following #12135 (#12237) --- script/helpers.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/script/helpers.py b/script/helpers.py index 1039ef39ac..06a50a3092 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -630,7 +630,12 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: Returns: Set of all components including dependencies and auto-loaded components """ - from esphome.const import KEY_CORE + from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + ) from esphome.core import CORE from esphome.loader import get_component @@ -642,7 +647,10 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: # Set up fake config path for component loading root = Path(__file__).parent.parent CORE.config_path = root - CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_HOST, + KEY_TARGET_FRAMEWORK: "host-native", + } # Keep finding dependencies until no new ones are found while True: From d332edfacadc08f8b3d232a323dbe5cad824ae15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 16:50:03 -0600 Subject: [PATCH 202/320] [datetime] Convert to C++17 nested namespace style (#12235) --- esphome/components/datetime/date_entity.cpp | 6 ++---- esphome/components/datetime/date_entity.h | 6 ++---- esphome/components/datetime/datetime_base.h | 6 ++---- esphome/components/datetime/datetime_entity.cpp | 6 ++---- esphome/components/datetime/datetime_entity.h | 6 ++---- esphome/components/datetime/time_entity.cpp | 6 ++---- esphome/components/datetime/time_entity.h | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 2c2775ecf4..c061bc81f7 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { static const char *const TAG = "datetime.date_entity"; @@ -129,7 +128,6 @@ void DateEntityRestoreState::apply(DateEntity *date) { date->publish_state(); } -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index ba2edb127a..069116d162 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -10,8 +10,7 @@ #include "datetime_base.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { #define LOG_DATETIME_DATE(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -111,7 +110,6 @@ template class DateSetAction : public Action, public Pare } }; -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index b5f54ac96f..7b9b281ea4 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -8,8 +8,7 @@ #include "esphome/components/time/real_time_clock.h" #endif -namespace esphome { -namespace datetime { +namespace esphome::datetime { class DateTimeBase : public EntityBase { public: @@ -37,5 +36,4 @@ class DateTimeStateTrigger : public Trigger { } }; -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 8606a47fa7..694f9c5721 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { static const char *const TAG = "datetime.datetime_entity"; @@ -250,7 +249,6 @@ bool OnDateTimeTrigger::matches_(const ESPTime &time) const { } #endif -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime #endif // USE_DATETIME_TIME diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 43bff5a181..018346b34b 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -10,8 +10,7 @@ #include "datetime_base.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { #define LOG_DATETIME_DATETIME(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -146,7 +145,6 @@ class OnDateTimeTrigger : public Trigger<>, public Component, public Parented, public Component, public Parented Date: Mon, 1 Dec 2025 16:50:29 -0600 Subject: [PATCH 203/320] [button] Convert to C++17 nested namespace style (#12233) Co-authored-by: Keith Burzinski --- esphome/components/button/automation.h | 6 ++---- esphome/components/button/button.cpp | 6 ++---- esphome/components/button/button.h | 6 ++---- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index 3b792eb5d7..6a54b141a3 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace button { +namespace esphome::button { template class PressAction : public Action { public: @@ -24,5 +23,4 @@ class ButtonPressTrigger : public Trigger<> { } }; -} // namespace button -} // namespace esphome +} // namespace esphome::button diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index c968d31088..87a222776e 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -1,8 +1,7 @@ #include "button.h" #include "esphome/core/log.h" -namespace esphome { -namespace button { +namespace esphome::button { static const char *const TAG = "button"; @@ -26,5 +25,4 @@ void Button::press() { } void Button::add_on_press_callback(std::function &&callback) { this->press_callback_.add(std::move(callback)); } -} // namespace button -} // namespace esphome +} // namespace esphome::button diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 75b76f9dcf..18122f6f2f 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -4,8 +4,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace button { +namespace esphome::button { class Button; void log_button(const char *tag, const char *prefix, const char *type, Button *obj); @@ -45,5 +44,4 @@ class Button : public EntityBase, public EntityBase_DeviceClass { CallbackManager press_callback_{}; }; -} // namespace button -} // namespace esphome +} // namespace esphome::button From e42cf9a4f452ed80ec43782b11fd73543d41c164 Mon Sep 17 00:00:00 2001 From: Peter Popovec Date: Tue, 2 Dec 2025 00:06:47 +0100 Subject: [PATCH 204/320] [mqtt] Enable support for the RTL87XX platform (#7697) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/mqtt/__init__.py | 3 ++- tests/components/mqtt/test.rtl87xx-ard.yaml | 2 ++ .../build_components_base.rtl87xx-ard.yaml | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/components/mqtt/test.rtl87xx-ard.yaml create mode 100644 tests/test_build_components/build_components_base.rtl87xx-ard.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 1fc0c30db1..237ed2ce38 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -55,6 +55,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_RTL87XX, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -316,7 +317,7 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), _consume_mqtt_sockets, ) diff --git a/tests/components/mqtt/test.rtl87xx-ard.yaml b/tests/components/mqtt/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/mqtt/test.rtl87xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/test_build_components/build_components_base.rtl87xx-ard.yaml b/tests/test_build_components/build_components_base.rtl87xx-ard.yaml new file mode 100644 index 0000000000..1720ef700d --- /dev/null +++ b/tests/test_build_components/build_components_base.rtl87xx-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestesprtl87xx + friendly_name: $component_name + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file From df58e832e56dbae50baa2a6218166c92bef5c631 Mon Sep 17 00:00:00 2001 From: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Date: Tue, 2 Dec 2025 00:44:33 +0100 Subject: [PATCH 205/320] [esp8266] Allow IN&OUT pin config for ESP8266 (#12238) --- esphome/components/esp8266/gpio.cpp | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index 17a495bc1d..124df39ce3 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -8,11 +8,13 @@ namespace esphome::esp8266 { static const char *const TAG = "esp8266"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { - if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) - return INPUT; - } else if (flags == gpio::FLAG_OUTPUT) { + if (flags == gpio::FLAG_OUTPUT || flags == (gpio::FLAG_OUTPUT | gpio::FLAG_INPUT)) { return OUTPUT; - } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { + } + if (flags == gpio::FLAG_INPUT) { + return INPUT; + } + if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { if (pin == 16) { // GPIO16 doesn't have a pullup, so pinMode would fail. // However, sometimes this method is called with pullup mode anyway @@ -21,13 +23,14 @@ static int flags_to_mode(gpio::Flags flags, uint8_t pin) { return INPUT; } return INPUT_PULLUP; - } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN)) { - return INPUT_PULLDOWN_16; - } else if (flags == (gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN)) { - return OUTPUT_OPEN_DRAIN; - } else { - return 0; } + if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN)) { + return INPUT_PULLDOWN_16; + } + if (flags == (gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN)) { + return OUTPUT_OPEN_DRAIN; + } + return INPUT; } struct ISRPinArg { From 6dafc5137e074fe09484fa7df821d125e117ed41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 21:24:08 -0600 Subject: [PATCH 206/320] [esp32] Place FreeRTOS functions in flash by default (prep for IDF 6.0) (#12182) --- esphome/components/esp32/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 35ef76634b..c49fc89fbd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -584,6 +584,7 @@ CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios" CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select" CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir" +CONF_FREERTOS_IN_IRAM = "freertos_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" # VFS requirement tracking @@ -677,6 +678,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, + cv.Optional(CONF_FREERTOS_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 @@ -1003,6 +1005,22 @@ async def to_code(config): # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + # Place non-ISR FreeRTOS functions into flash instead of IRAM + # This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM. + # In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH + # is removed (replaced by CONFIG_FREERTOS_IN_IRAM to restore old behavior). + # We enable this now to match IDF 6.0 behavior and catch any issues early. + # Users can set freertos_in_iram: true as an escape hatch if they encounter problems + # with code that incorrectly calls FreeRTOS functions from ISRs with cache disabled. + if conf[CONF_ADVANCED][CONF_FREERTOS_IN_IRAM]: + # IDF 5.x: don't set the flash option (keeps functions in IRAM) + # IDF 6.0+: will need CONFIG_FREERTOS_IN_IRAM=y to restore IRAM placement + add_idf_sdkconfig_option("CONFIG_FREERTOS_IN_IRAM", True) + else: + # IDF 5.x: explicitly place functions in flash + # IDF 6.0+: this is the default, option no longer exists + add_idf_sdkconfig_option("CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH", True) + # Setup watchdog add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) From 69438031761225923e6e31546b18c856cec3864d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 21:26:13 -0600 Subject: [PATCH 207/320] [cover] Store cover state strings in flash on ESP8266 (#12196) --- esphome/components/cover/cover.cpp | 22 ++++++++++---------- esphome/components/cover/cover.h | 3 ++- esphome/components/web_server/web_server.cpp | 10 +++++---- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 8f735982f1..feac9823b9 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -13,25 +13,25 @@ static const char *const TAG = "cover"; const float COVER_OPEN = 1.0f; const float COVER_CLOSED = 0.0f; -const char *cover_command_to_str(float pos) { +const LogString *cover_command_to_str(float pos) { if (pos == COVER_OPEN) { - return "OPEN"; + return LOG_STR("OPEN"); } else if (pos == COVER_CLOSED) { - return "CLOSE"; + return LOG_STR("CLOSE"); } else { - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -const char *cover_operation_to_str(CoverOperation op) { +const LogString *cover_operation_to_str(CoverOperation op) { switch (op) { case COVER_OPERATION_IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case COVER_OPERATION_OPENING: - return "OPENING"; + return LOG_STR("OPENING"); case COVER_OPERATION_CLOSING: - return "CLOSING"; + return LOG_STR("CLOSING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -87,7 +87,7 @@ void CoverCall::perform() { if (traits.get_supports_position()) { ESP_LOGD(TAG, " Position: %.0f%%", *this->position_ * 100.0f); } else { - ESP_LOGD(TAG, " Command: %s", cover_command_to_str(*this->position_)); + ESP_LOGD(TAG, " Command: %s", LOG_STR_ARG(cover_command_to_str(*this->position_))); } } if (this->tilt_.has_value()) { @@ -169,7 +169,7 @@ void Cover::publish_state(bool save) { if (traits.get_supports_tilt()) { ESP_LOGD(TAG, " Tilt: %.0f%%", this->tilt * 100.0f); } - ESP_LOGD(TAG, " Current Operation: %s", cover_operation_to_str(this->current_operation)); + ESP_LOGD(TAG, " Current Operation: %s", LOG_STR_ARG(cover_operation_to_str(this->current_operation))); this->state_callback_.call(); #if defined(USE_COVER) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 6c69c05e71..d8c45ab2bd 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "cover_traits.h" @@ -86,7 +87,7 @@ enum CoverOperation : uint8_t { COVER_OPERATION_CLOSING, }; -const char *cover_operation_to_str(CoverOperation op); +const LogString *cover_operation_to_str(CoverOperation op); /** Base class for all cover devices. * diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index bc48793ba2..c752c00899 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -41,6 +41,10 @@ namespace web_server { static const char *const TAG = "web_server"; +// Longest: HORIZONTAL (10 chars + null terminator, rounded up) +static constexpr size_t PSTR_LOCAL_SIZE = 16; +#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS static const char *const HEADER_PNA_NAME = "Private-Network-Access-Name"; static const char *const HEADER_PNA_ID = "Private-Network-Access-ID"; @@ -908,7 +912,8 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + char buf[PSTR_LOCAL_SIZE]; + root["current_operation"] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root["position"] = obj->position; @@ -1272,9 +1277,6 @@ std::string WebServer::select_json(select::Select *obj, const char *value, JsonD } #endif -// Longest: HORIZONTAL -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), 15) - #ifdef USE_CLIMATE void WebServer::on_climate_update(climate::Climate *obj) { if (!this->include_internal_ && obj->is_internal()) From 2903a4aa92fa8c81ba79ae5eb96a484de4457da6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:41:34 -0500 Subject: [PATCH 208/320] [ota] Use ESP-IDF OTA backend for all ESP32 builds (#12244) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- .../components/esphome/ota/ota_esphome.cpp | 1 - .../http_request/ota/ota_http_request.cpp | 1 - esphome/components/ota/__init__.py | 9 ++- .../ota/ota_backend_arduino_esp32.cpp | 72 ------------------- .../ota/ota_backend_arduino_esp32.h | 27 ------- .../components/ota/ota_backend_esp_idf.cpp | 4 +- esphome/components/ota/ota_backend_esp_idf.h | 4 +- 7 files changed, 8 insertions(+), 110 deletions(-) delete mode 100644 esphome/components/ota/ota_backend_arduino_esp32.cpp delete mode 100644 esphome/components/ota/ota_backend_arduino_esp32.h diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 852a50cc22..6cfd543553 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -10,7 +10,6 @@ #endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_arduino_esp32.h" #include "esphome/components/ota/ota_backend_arduino_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_libretiny.h" #include "esphome/components/ota/ota_backend_arduino_rp2040.h" diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4d9e868c74..4552fcc9df 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -7,7 +7,6 @@ #include "esphome/components/md5/md5.h" #include "esphome/components/watchdog/watchdog.h" #include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_arduino_esp32.h" #include "esphome/components/ota/ota_backend_arduino_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_rp2040.h" #include "esphome/components/ota/ota_backend_esp_idf.h" diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index eec39668db..be1b6da241 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -87,9 +87,6 @@ BASE_OTA_SCHEMA = cv.Schema( async def to_code(config): cg.add_define("USE_OTA") - if CORE.is_esp32 and CORE.using_arduino: - cg.add_library("Update", None) - if CORE.is_rp2040 and CORE.using_arduino: cg.add_library("Updater", None) @@ -127,8 +124,10 @@ async def ota_to_code(var, config): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "ota_backend_arduino_esp32.cpp": {PlatformFramework.ESP32_ARDUINO}, - "ota_backend_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "ota_backend_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, "ota_backend_arduino_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { diff --git a/esphome/components/ota/ota_backend_arduino_esp32.cpp b/esphome/components/ota/ota_backend_arduino_esp32.cpp deleted file mode 100644 index 5c6230f2ce..0000000000 --- a/esphome/components/ota/ota_backend_arduino_esp32.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "esphome/core/defines.h" -#include "esphome/core/log.h" - -#include "ota_backend.h" -#include "ota_backend_arduino_esp32.h" - -#include - -namespace esphome { -namespace ota { - -static const char *const TAG = "ota.arduino_esp32"; - -std::unique_ptr make_ota_backend() { return make_unique(); } - -OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) { - // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA - // where the exact firmware size is unknown due to multipart encoding - if (image_size == 0) { - image_size = UPDATE_SIZE_UNKNOWN; - } - bool ret = Update.begin(image_size, U_FLASH); - if (ret) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - if (error == UPDATE_ERROR_SIZE) - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - - ESP_LOGE(TAG, "Begin error: %d", error); - - return OTA_RESPONSE_ERROR_UNKNOWN; -} - -void ArduinoESP32OTABackend::set_update_md5(const char *md5) { - Update.setMD5(md5); - this->md5_set_ = true; -} - -OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) { - size_t written = Update.write(data, len); - if (written == len) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "Write error: %d", error); - - return OTA_RESPONSE_ERROR_WRITING_FLASH; -} - -OTAResponseTypes ArduinoESP32OTABackend::end() { - // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 - // This matches the behavior of the old web_server OTA implementation - if (Update.end(!this->md5_set_)) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "End error: %d", error); - - return OTA_RESPONSE_ERROR_UPDATE_END; -} - -void ArduinoESP32OTABackend::abort() { Update.abort(); } - -} // namespace ota -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_esp32.h b/esphome/components/ota/ota_backend_arduino_esp32.h deleted file mode 100644 index 6615cf3dc0..0000000000 --- a/esphome/components/ota/ota_backend_arduino_esp32.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "ota_backend.h" - -#include "esphome/core/defines.h" -#include "esphome/core/helpers.h" - -namespace esphome { -namespace ota { - -class ArduinoESP32OTABackend : public OTABackend { - public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } - - private: - bool md5_set_{false}; -}; - -} // namespace ota -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 97aae09bd9..f278c3741f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "ota_backend_esp_idf.h" #include "esphome/components/md5/md5.h" @@ -107,4 +107,4 @@ void IDFOTABackend::abort() { } // namespace ota } // namespace esphome -#endif +#endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 6e93982131..764010e614 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -1,5 +1,5 @@ #pragma once -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "ota_backend.h" #include "esphome/components/md5/md5.h" @@ -29,4 +29,4 @@ class IDFOTABackend : public OTABackend { } // namespace ota } // namespace esphome -#endif +#endif // USE_ESP32 From c45cd44bb897d6926cbf97d3e615a84ee76ee28a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:49:25 -0600 Subject: [PATCH 209/320] Bump github/codeql-action from 4.31.5 to 4.31.6 (#12234) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d10c8bf267..33f587a748 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: category: "/language:${{matrix.language}}" From 82a06c697e705199073568e5952dc75fe5c91460 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 21:57:41 -0600 Subject: [PATCH 210/320] [esp32] Place ring buffer functions in flash by default (prep for IDF 6.0) (#12184) --- esphome/components/esp32/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c49fc89fbd..14db25fd46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -585,6 +585,7 @@ CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios" CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select" CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir" CONF_FREERTOS_IN_IRAM = "freertos_in_iram" +CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" # VFS requirement tracking @@ -679,6 +680,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, cv.Optional(CONF_FREERTOS_IN_IRAM, default=False): cv.boolean, + cv.Optional(CONF_RINGBUF_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 @@ -1021,6 +1023,17 @@ async def to_code(config): # IDF 6.0+: this is the default, option no longer exists add_idf_sdkconfig_option("CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH", True) + # Place ring buffer functions into flash instead of IRAM by default + # This saves IRAM. In ESP-IDF 6.0 flash placement becomes the default. + # Users can set ringbuf_in_iram: true as an escape hatch if they encounter issues. + if conf[CONF_ADVANCED][CONF_RINGBUF_IN_IRAM]: + # User requests ring buffer in IRAM + # IDF 6.0+: will need CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH=n + add_idf_sdkconfig_option("CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH", False) + else: + # Place in flash to save IRAM (default) + add_idf_sdkconfig_option("CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH", True) + # Setup watchdog add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) From 9a0731437a94c3fb9122249fa7e9154a2a3d2c28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:11:33 -0600 Subject: [PATCH 211/320] Bump aioesphomeapi from 42.9.0 to 42.10.0 (#12245) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 45ae3d5925..5d824a6859 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.9.0 +aioesphomeapi==42.10.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From 10ddebc737644c44124a9164953dcdaa190c07ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Dec 2025 22:17:31 -0600 Subject: [PATCH 212/320] [text_sensor] Avoid duplicate string storage when no filters configured (#12205) --- .../components/text_sensor/text_sensor.cpp | 12 +- esphome/components/text_sensor/text_sensor.h | 5 +- .../fixtures/text_sensor_raw_state.yaml | 54 +++++++++ .../integration/test_text_sensor_raw_state.py | 114 ++++++++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/integration/fixtures/text_sensor_raw_state.yaml create mode 100644 tests/integration/test_text_sensor_raw_state.py diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index a7bcf19967..d984e78b2a 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -25,7 +25,11 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text } void TextSensor::publish_state(const std::string &state) { - this->raw_state = state; + // Only store raw_state_ separately when filters exist + // When no filters, raw_state == state, so we avoid the duplicate storage + if (this->filter_list_ != nullptr) { + this->raw_state_ = state; + } if (this->raw_callback_) { this->raw_callback_->call(state); } @@ -80,7 +84,11 @@ void TextSensor::add_on_raw_state_callback(std::function call } std::string TextSensor::get_state() const { return this->state; } -std::string TextSensor::get_raw_state() const { return this->raw_state; } +std::string TextSensor::get_raw_state() const { + // When no filters exist, raw_state == state, so return state to avoid + // requiring separate storage + return this->filter_list_ != nullptr ? this->raw_state_ : this->state; +} void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; this->set_has_state(true); diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index db2e857ae3..fcfbed2fbc 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -50,7 +50,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void add_on_raw_state_callback(std::function callback); std::string state; - std::string raw_state; // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -63,6 +62,10 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. + + /// Raw state (before filters). Only populated when filters are configured. + /// When no filters exist, get_raw_state() returns state directly. + std::string raw_state_; }; } // namespace text_sensor diff --git a/tests/integration/fixtures/text_sensor_raw_state.yaml b/tests/integration/fixtures/text_sensor_raw_state.yaml new file mode 100644 index 0000000000..03aece0a04 --- /dev/null +++ b/tests/integration/fixtures/text_sensor_raw_state.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-text-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Text sensor WITHOUT filters - get_raw_state() should return same as state +text_sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + + # Text sensor WITH filter - get_raw_state() should return original value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + filters: + - to_upper + +# Button to publish values and log raw_state vs state +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - text_sensor.template.publish: + id: no_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify they match + - logger.log: + format: "NO_FILTER: state='%s' raw_state='%s'" + args: + - id(no_filter_sensor).state.c_str() + - id(no_filter_sensor).get_raw_state().c_str() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - text_sensor.template.publish: + id: with_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify filter works + # state should be "HELLO WORLD" (filtered), raw_state should be "hello world" (original) + - logger.log: + format: "WITH_FILTER: state='%s' raw_state='%s'" + args: + - id(with_filter_sensor).state.c_str() + - id(with_filter_sensor).get_raw_state().c_str() diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py new file mode 100644 index 0000000000..a53ec8c963 --- /dev/null +++ b/tests/integration/test_text_sensor_raw_state.py @@ -0,0 +1,114 @@ +"""Integration test for TextSensor get_raw_state() functionality. + +This tests the optimization in PR #12205 where raw_state is only stored +when filters are configured. When no filters exist, get_raw_state() should +return state directly. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that get_raw_state() works correctly with and without filters. + + Without filters: get_raw_state() should return the same value as state + With filters: get_raw_state() should return the original (unfiltered) value + """ + loop = asyncio.get_running_loop() + + # Futures to track log messages + no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + + # Patterns to match log output + # NO_FILTER: state='hello world' raw_state='hello world' + no_filter_pattern = re.compile(r"NO_FILTER: state='([^']*)' raw_state='([^']*)'") + # WITH_FILTER: state='HELLO WORLD' raw_state='hello world' + with_filter_pattern = re.compile( + r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not no_filter_future.done(): + match = no_filter_pattern.search(line) + if match: + no_filter_future.set_result((match.group(1), match.group(2))) + + if not with_filter_future.done(): + match = with_filter_pattern.search(line) + if match: + with_filter_future.set_result((match.group(1), match.group(2))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-text-sensor-raw-state" + + # Get entities to find our buttons + entities, _ = await client.list_entities_services() + + # Find the test buttons + no_filter_button = next( + (e for e in entities if "test_no_filter_button" in e.object_id.lower()), + None, + ) + assert no_filter_button is not None, "Test No Filter Button not found" + + with_filter_button = next( + (e for e in entities if "test_with_filter_button" in e.object_id.lower()), + None, + ) + assert with_filter_button is not None, "Test With Filter Button not found" + + # Test 1: Text sensor without filters + # get_raw_state() should return the same as state + client.button_command(no_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(no_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for NO_FILTER log message") + + assert state == "hello world", f"Expected state='hello world', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state == raw_state, ( + f"Without filters, state and raw_state should be equal. " + f"state='{state}', raw_state='{raw_state}'" + ) + + # Test 2: Text sensor with to_upper filter + # state should be filtered (uppercase), raw_state should be original + client.button_command(with_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(with_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for WITH_FILTER log message") + + assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state != raw_state, ( + f"With filters, state and raw_state should differ. " + f"state='{state}', raw_state='{raw_state}'" + ) From 29be1423f55a0d1502835d10ed2784b0f0839042 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:59:50 -0500 Subject: [PATCH 213/320] [core] Filter noisy platformio log messages (#12218) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/platformio_api.py | 28 ++++++- tests/unit_tests/test_platformio_api.py | 98 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index d59523a74a..4d795ea5d9 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -107,9 +107,24 @@ FILTER_PLATFORMIO_LINES = [ r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", r"Warning: esp-idf-size exited with code 2", r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", ] +class PlatformioLogFilter(logging.Filter): + """Filter to suppress noisy platformio log messages.""" + + _PATTERN = re.compile( + r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES) + ) + + def filter(self, record: logging.LogRecord) -> bool: + # Only filter messages from platformio-related loggers + if "platformio" not in record.name.lower(): + return True + return self._PATTERN.match(record.getMessage()) is None + + def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -130,7 +145,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: patch_structhash() patch_file_downloader() - return run_external_command(platformio.__main__.main, *cmd, **kwargs) + + # Add log filter to suppress noisy platformio messages + log_filter = PlatformioLogFilter() if not CORE.verbose else None + if log_filter: + for handler in logging.getLogger().handlers: + handler.addFilter(log_filter) + try: + return run_external_command(platformio.__main__.main, *cmd, **kwargs) + finally: + if log_filter: + for handler in logging.getLogger().handlers: + handler.removeFilter(log_filter) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 13ef3516e4..4d7b635e59 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,6 +1,7 @@ """Tests for platformio_api.py path functions.""" import json +import logging import os from pathlib import Path import shutil @@ -670,3 +671,100 @@ def test_process_stacktrace_bad_alloc( assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text mock_decode_pc.assert_called_once_with(config, "40201234") assert state is False + + +def test_platformio_log_filter_allows_non_platformio_messages() -> None: + """Test that non-platformio logger messages are allowed through.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="esphome.core", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some esphome message", + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is True + + +@pytest.mark.parametrize( + "msg", + [ + "Verbose mode can be enabled via `-v, --verbose` option", + "Found 5 compatible libraries", + "Found 123 compatible libraries", + "Building in release mode", + "Building in debug mode", + "Merged 2 ELF section", + "esptool.py v4.7.0", + "esptool v4.8.1", + "PLATFORM: espressif32 @ 6.4.0", + "Using cache: /path/to/cache", + "Package configuration completed successfully", + "Scanning dependencies...", + "Installing dependencies", + "Library Manager: Already installed, built-in library", + "Memory Usage -> https://bit.ly/pio-memory-usage", + ], +) +def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: + """Test that noisy platformio messages are filtered out.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="platformio.builder", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is False + + +@pytest.mark.parametrize( + "msg", + [ + "Compiling .pio/build/test/src/main.cpp.o", + "Linking .pio/build/test/firmware.elf", + "Error: something went wrong", + "warning: unused variable", + ], +) +def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None: + """Test that non-noisy platformio messages are allowed through.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="platformio.builder", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is True + + +@pytest.mark.parametrize( + "logger_name", + [ + "PLATFORMIO.builder", + "PlatformIO.core", + "platformio.run", + ], +) +def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None: + """Test that platformio logger name matching is case insensitive.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name=logger_name, + level=logging.INFO, + pathname="", + lineno=0, + msg="Found 5 compatible libraries", + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is False From deda7a1bf395a7b51ecafd86303cb31c3aa3179c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 09:59:05 -0600 Subject: [PATCH 214/320] [lock] Store lock state strings in flash on ESP8266 (#12163) --- esphome/components/lock/lock.cpp | 21 ++++++++++---------- esphome/components/lock/lock.h | 5 ++++- esphome/components/mqtt/mqtt_lock.cpp | 10 ++++++++-- esphome/components/web_server/web_server.cpp | 7 ++++--- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index b8f0fbe011..018f5113e3 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -7,21 +7,21 @@ namespace esphome::lock { static const char *const TAG = "lock"; -const char *lock_state_to_string(LockState state) { +const LogString *lock_state_to_string(LockState state) { switch (state) { case LOCK_STATE_LOCKED: - return "LOCKED"; + return LOG_STR("LOCKED"); case LOCK_STATE_UNLOCKED: - return "UNLOCKED"; + return LOG_STR("UNLOCKED"); case LOCK_STATE_JAMMED: - return "JAMMED"; + return LOG_STR("JAMMED"); case LOCK_STATE_LOCKING: - return "LOCKING"; + return LOG_STR("LOCKING"); case LOCK_STATE_UNLOCKING: - return "UNLOCKING"; + return LOG_STR("UNLOCKING"); case LOCK_STATE_NONE: default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -52,7 +52,7 @@ void Lock::publish_state(LockState state) { this->state = state; this->rtc_.save(&this->state); - ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), lock_state_to_string(state)); + ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state))); this->state_callback_.call(); #if defined(USE_LOCK) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_lock_update(this); @@ -65,8 +65,7 @@ void LockCall::perform() { ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); this->validate_(); if (this->state_.has_value()) { - const char *state_s = lock_state_to_string(*this->state_); - ESP_LOGD(TAG, " State: %s", state_s); + ESP_LOGD(TAG, " State: %s", LOG_STR_ARG(lock_state_to_string(*this->state_))); } this->parent_->control(*this); } @@ -74,7 +73,7 @@ void LockCall::validate_() { if (this->state_.has_value()) { auto state = *this->state_; if (!this->parent_->traits.supports_state(state)) { - ESP_LOGW(TAG, " State %s is not supported by this device!", lock_state_to_string(*this->state_)); + ESP_LOGW(TAG, " State %s is not supported by this device!", LOG_STR_ARG(lock_state_to_string(*this->state_))); this->state_.reset(); } } diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 8a906ef9fc..4001a182b8 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -30,7 +30,10 @@ enum LockState : uint8_t { LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5 }; -const char *lock_state_to_string(LockState state); +const LogString *lock_state_to_string(LockState state); + +/// Maximum length of lock state string (including null terminator): "UNLOCKING" = 10 +static constexpr size_t LOCK_STATE_STR_SIZE = 10; class LockTraits { public: diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 0e15377ba4..95efbf60e1 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -48,8 +48,14 @@ void MQTTLockComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfi bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { - std::string payload = lock_state_to_string(this->lock_->state); - return this->publish(this->get_state_topic_(), payload); +#ifdef USE_STORE_LOG_STR_IN_FLASH + char buf[LOCK_STATE_STR_SIZE]; + strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + return this->publish(this->get_state_topic_(), buf); +#else + return this->publish(this->get_state_topic_(), LOG_STR_ARG(lock_state_to_string(this->lock_->state))); +#endif } } // namespace mqtt diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index c752c00899..38fa54704a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1334,7 +1334,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[16]; + char buf[PSTR_LOCAL_SIZE]; if (start_config == DETAIL_ALL) { JsonArray opt = root["modes"].to(); @@ -1484,7 +1484,8 @@ std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDet json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "lock", lock::lock_state_to_string(value), value, start_config); + char buf[PSTR_LOCAL_SIZE]; + set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1645,7 +1646,7 @@ std::string WebServer::alarm_control_panel_json(alarm_control_panel::AlarmContro json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[16]; + char buf[PSTR_LOCAL_SIZE]; set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { From f9ad832e7bef3c685273ccc971942814b4eda2dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 09:59:32 -0600 Subject: [PATCH 215/320] [esp32_camera] Replace std::function callbacks with CameraListener interface (#12165) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 16 ++++--- esphome/components/api/api_server.h | 10 +++++ esphome/components/camera/camera.h | 25 ++++++++--- .../components/esp32_camera/esp32_camera.cpp | 21 ++++----- .../components/esp32_camera/esp32_camera.h | 43 ++++++++----------- .../camera_web_server.cpp | 14 +++--- .../camera_web_server.h | 5 ++- 7 files changed, 78 insertions(+), 56 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index de0c4b24c9..4168761c74 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -107,12 +107,7 @@ void APIServer::setup() { #ifdef USE_CAMERA if (camera::Camera::instance() != nullptr && !camera::Camera::instance()->is_internal()) { - camera::Camera::instance()->add_image_callback([this](const std::shared_ptr &image) { - for (auto &c : this->clients_) { - if (!c->flags_.remove) - c->set_camera_state(image); - } - }); + camera::Camera::instance()->add_listener(this); } #endif } @@ -544,6 +539,15 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size } #endif +#ifdef USE_CAMERA +void APIServer::on_camera_image(const std::shared_ptr &image) { + for (auto &c : this->clients_) { + if (!c->flags_.remove) + c->set_camera_state(image); + } +} +#endif + void APIServer::on_shutdown() { this->shutting_down_ = true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 57aea6ad0e..3089bb1d35 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -18,6 +18,9 @@ #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif +#ifdef USE_CAMERA +#include "esphome/components/camera/camera.h" +#endif #include #include @@ -36,6 +39,10 @@ class APIServer : public Component, , public logger::LogListener #endif +#ifdef USE_CAMERA + , + public camera::CameraListener +#endif { public: APIServer(); @@ -49,6 +56,9 @@ class APIServer : public Component, #ifdef USE_LOGGER void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; #endif +#ifdef USE_CAMERA + void on_camera_image(const std::shared_ptr &image) override; +#endif #ifdef USE_API_PASSWORD bool check_password(const uint8_t *password_data, size_t password_len) const; void set_password(const std::string &password); diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index c28a756a06..6e1fc8cc06 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -35,6 +35,21 @@ inline const char *to_string(PixelFormat format) { return "PIXEL_FORMAT_UNKNOWN"; } +// Forward declaration +class CameraImage; + +/** Listener interface for camera events. + * + * Components can implement this interface to receive camera notifications + * (new images, stream start/stop) without the overhead of std::function callbacks. + */ +class CameraListener { + public: + virtual void on_camera_image(const std::shared_ptr &image) {} + virtual void on_stream_start() {} + virtual void on_stream_stop() {} +}; + /** Abstract camera image base class. * Encapsulates the JPEG encoded data and it is shared among * all connected clients. @@ -87,12 +102,12 @@ struct CameraImageSpec { }; /** Abstract camera base class. Collaborates with API. - * 1) API server starts and installs callback (add_image_callback) - * which is called by the camera when a new image is available. + * 1) API server starts and registers as a listener (add_listener) + * to receive new images from the camera. * 2) New API client connects and creates a new image reader (create_image_reader). * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. - * 4) Camera implementation provides JPEG data in the CameraImage and calls callback. + * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. * 5) API connection sets the image in the image reader. * 6) API connection consumes data from the image reader and returns the image when finished. * 7.a) Camera captures a new image and continues with 4) until start_stream is called. @@ -100,8 +115,8 @@ struct CameraImageSpec { class Camera : public EntityBase, public Component { public: Camera(); - // Camera implementation invokes callback to publish a new image. - virtual void add_image_callback(std::function)> &&callback) = 0; + /// Add a listener to receive camera events + virtual void add_listener(CameraListener *listener) = 0; /// Returns a new camera image reader that keeps track of the JPEG data in the camera image. virtual CameraImageReader *create_image_reader() = 0; // Connection, camera or web server requests one new JPEG image. diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 38bd8d5822..5080a6f32d 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -205,7 +205,9 @@ void ESP32Camera::loop() { this->current_image_ = std::make_shared(fb, this->single_requesters_ | this->stream_requesters_); ESP_LOGD(TAG, "Got Image: len=%u", fb->len); - this->new_image_callback_.call(this->current_image_); + for (auto *listener : this->listeners_) { + listener->on_camera_image(this->current_image_); + } this->last_update_ = now; this->single_requesters_ = 0; } @@ -357,21 +359,16 @@ void ESP32Camera::set_frame_buffer_location(camera_fb_location_t fb_location) { } /* ---------------- public API (specific) ---------------- */ -void ESP32Camera::add_image_callback(std::function)> &&callback) { - this->new_image_callback_.add(std::move(callback)); -} -void ESP32Camera::add_stream_start_callback(std::function &&callback) { - this->stream_start_callback_.add(std::move(callback)); -} -void ESP32Camera::add_stream_stop_callback(std::function &&callback) { - this->stream_stop_callback_.add(std::move(callback)); -} void ESP32Camera::start_stream(camera::CameraRequester requester) { - this->stream_start_callback_.call(); + for (auto *listener : this->listeners_) { + listener->on_stream_start(); + } this->stream_requesters_ |= (1U << requester); } void ESP32Camera::stop_stream(camera::CameraRequester requester) { - this->stream_stop_callback_.call(); + for (auto *listener : this->listeners_) { + listener->on_stream_stop(); + } this->stream_requesters_ &= ~(1U << requester); } void ESP32Camera::request_image(camera::CameraRequester requester) { this->single_requesters_ |= (1U << requester); } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 0e7f7c0ea6..54a7d6064a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -165,9 +165,8 @@ class ESP32Camera : public camera::Camera { void request_image(camera::CameraRequester requester) override; void update_camera_parameters(); - void add_image_callback(std::function)> &&callback) override; - void add_stream_start_callback(std::function &&callback); - void add_stream_stop_callback(std::function &&callback); + /// Add a listener to receive camera events + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } camera::CameraImageReader *create_image_reader() override; protected: @@ -210,9 +209,7 @@ class ESP32Camera : public camera::Camera { uint8_t stream_requesters_{0}; QueueHandle_t framebuffer_get_queue_; QueueHandle_t framebuffer_return_queue_; - CallbackManager)> new_image_callback_{}; - CallbackManager stream_start_callback_{}; - CallbackManager stream_stop_callback_{}; + std::vector listeners_; uint32_t last_idle_request_{0}; uint32_t last_update_{0}; @@ -221,33 +218,27 @@ class ESP32Camera : public camera::Camera { #endif // USE_I2C }; -class ESP32CameraImageTrigger : public Trigger { +class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { public: - explicit ESP32CameraImageTrigger(ESP32Camera *parent) { - parent->add_image_callback([this](const std::shared_ptr &image) { - CameraImageData camera_image_data{}; - camera_image_data.length = image->get_data_length(); - camera_image_data.data = image->get_data_buffer(); - this->trigger(camera_image_data); - }); + explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_camera_image(const std::shared_ptr &image) override { + CameraImageData camera_image_data{}; + camera_image_data.length = image->get_data_length(); + camera_image_data.data = image->get_data_buffer(); + this->trigger(camera_image_data); } }; -class ESP32CameraStreamStartTrigger : public Trigger<> { +class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { public: - explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { - parent->add_stream_start_callback([this]() { this->trigger(); }); - } - - protected: + explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<> { - public: - explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { - parent->add_stream_stop_callback([this]() { this->trigger(); }); - } - protected: +class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { + public: + explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_stream_stop() override { this->trigger(); } }; } // namespace esp32_camera diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 1b81989296..f49578c425 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -67,12 +67,14 @@ void CameraWebServer::setup() { httpd_register_uri_handler(this->httpd_, &uri); - camera::Camera::instance()->add_image_callback([this](std::shared_ptr image) { - if (this->running_ && image->was_requested_by(camera::WEB_REQUESTER)) { - this->image_ = std::move(image); - xSemaphoreGive(this->semaphore_); - } - }); + camera::Camera::instance()->add_listener(this); +} + +void CameraWebServer::on_camera_image(const std::shared_ptr &image) { + if (this->running_ && image->was_requested_by(camera::WEB_REQUESTER)) { + this->image_ = image; + xSemaphoreGive(this->semaphore_); + } } void CameraWebServer::on_shutdown() { diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index e70246745c..ad7b29fb11 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -18,7 +18,7 @@ namespace esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component { +class CameraWebServer : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); @@ -31,6 +31,9 @@ class CameraWebServer : public Component { void set_mode(Mode mode) { this->mode_ = mode; } void loop() override; + /// CameraListener interface + void on_camera_image(const std::shared_ptr &image) override; + protected: std::shared_ptr wait_for_image_(); esp_err_t handler_(struct httpd_req *req); From 5142ff372bb389c3df9d73830930db325272037c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:01:54 -0600 Subject: [PATCH 216/320] [light] Use listener pattern for state callbacks with lazy allocation (#12166) --- esphome/components/light/automation.h | 62 ++++++----- esphome/components/light/light_call.cpp | 6 +- esphome/components/light/light_state.cpp | 26 +++-- esphome/components/light/light_state.h | 58 +++++++--- esphome/components/mqtt/mqtt_light.cpp | 7 +- esphome/components/mqtt/mqtt_light.h | 5 +- .../fixtures/light_automations.yaml | 26 +++++ tests/integration/test_light_automations.py | 101 ++++++++++++++++++ 8 files changed, 236 insertions(+), 55 deletions(-) create mode 100644 tests/integration/fixtures/light_automations.yaml create mode 100644 tests/integration/test_light_automations.py diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 9893c15e0c..c90d71c5df 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -120,46 +120,54 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<> { +class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { public: - LightTurnOnTrigger(LightState *a_light) { - a_light->add_new_remote_values_callback([this, a_light]() { - // using the remote value because of transitions we need to trigger as early as possible - auto is_on = a_light->remote_values.is_on(); - // only trigger when going from off to on - auto should_trigger = is_on && !this->last_on_; - // Set new state immediately so that trigger() doesn't devolve - // into infinite loop - this->last_on_ = is_on; - if (should_trigger) { - this->trigger(); - } - }); + explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { + a_light->add_remote_values_listener(this); this->last_on_ = a_light->current_values.is_on(); } + void on_light_remote_values_update() override { + // using the remote value because of transitions we need to trigger as early as possible + auto is_on = this->light_->remote_values.is_on(); + // only trigger when going from off to on + auto should_trigger = is_on && !this->last_on_; + // Set new state immediately so that trigger() doesn't devolve + // into infinite loop + this->last_on_ = is_on; + if (should_trigger) { + this->trigger(); + } + } + protected: + LightState *light_; bool last_on_; }; -class LightTurnOffTrigger : public Trigger<> { +class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { public: - LightTurnOffTrigger(LightState *a_light) { - a_light->add_new_target_state_reached_callback([this, a_light]() { - auto is_on = a_light->current_values.is_on(); - // only trigger when going from on to off - if (!is_on) { - this->trigger(); - } - }); + explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { + a_light->add_target_state_reached_listener(this); } + + void on_light_target_state_reached() override { + auto is_on = this->light_->current_values.is_on(); + // only trigger when going from on to off + if (!is_on) { + this->trigger(); + } + } + + protected: + LightState *light_; }; -class LightStateTrigger : public Trigger<> { +class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { public: - LightStateTrigger(LightState *a_light) { - a_light->add_new_remote_values_callback([this]() { this->trigger(); }); - } + explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } + + void on_light_remote_values_update() override { this->trigger(); } }; // This is slightly ugly, but we can't log in headers, and can't make this a static method on AddressableSet diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index f523b4451b..dca5861734 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -174,8 +174,10 @@ void LightCall::perform() { this->parent_->set_immediately_(v, publish); } - if (!this->has_transition_()) { - this->parent_->target_state_reached_callback_.call(); + if (!this->has_transition_() && this->parent_->target_state_reached_listeners_) { + for (auto *listener : *this->parent_->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } } if (publish) { this->parent_->publish_state(); diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9cde9077da..af619a426a 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -127,7 +127,11 @@ void LightState::loop() { this->transformer_->stop(); this->is_transformer_active_ = false; this->transformer_ = nullptr; - this->target_state_reached_callback_.call(); + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } // Disable loop if idle (no transformer and no effect) this->disable_loop_if_idle_(); @@ -146,7 +150,11 @@ void LightState::loop() { float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } void LightState::publish_state() { - this->remote_values_callback_.call(); + if (this->remote_values_listeners_) { + for (auto *listener : *this->remote_values_listeners_) { + listener->on_light_remote_values_update(); + } + } #if defined(USE_LIGHT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_light_update(this); #endif @@ -171,11 +179,17 @@ StringRef LightState::get_effect_name_ref() { return EFFECT_NONE_REF; } -void LightState::add_new_remote_values_callback(std::function &&send_callback) { - this->remote_values_callback_.add(std::move(send_callback)); +void LightState::add_remote_values_listener(LightRemoteValuesListener *listener) { + if (!this->remote_values_listeners_) { + this->remote_values_listeners_ = make_unique>(); + } + this->remote_values_listeners_->push_back(listener); } -void LightState::add_new_target_state_reached_callback(std::function &&send_callback) { - this->target_state_reached_callback_.add(std::move(send_callback)); +void LightState::add_target_state_reached_listener(LightTargetStateReachedListener *listener) { + if (!this->target_state_reached_listeners_) { + this->target_state_reached_listeners_ = make_unique>(); + } + this->target_state_reached_listeners_->push_back(listener); } void LightState::set_default_transition_length(uint32_t default_transition_length) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index ad8922b46f..7ea72306f9 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -18,6 +18,29 @@ namespace esphome::light { class LightOutput; +class LightState; + +/** Listener interface for light remote value changes. + * + * Components can implement this interface to receive notifications + * when the light's remote values change (state, brightness, color, etc.) + * without the overhead of std::function callbacks. + */ +class LightRemoteValuesListener { + public: + virtual void on_light_remote_values_update() = 0; +}; + +/** Listener interface for light target state reached. + * + * Components can implement this interface to receive notifications + * when the light finishes a transition and reaches its target state + * without the overhead of std::function callbacks. + */ +class LightTargetStateReachedListener { + public: + virtual void on_light_target_state_reached() = 0; +}; enum LightRestoreMode : uint8_t { LIGHT_RESTORE_DEFAULT_OFF, @@ -121,21 +144,17 @@ class LightState : public EntityBase, public Component { /// 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 - * when the remote color values are changed. - * - * @param send_callback The callback. + /** Add a listener for remote values changes. + * Listener is notified when the light's remote values change (state, brightness, color, etc.) + * Lazily allocates the listener vector on first registration. */ - void add_new_remote_values_callback(std::function &&send_callback); + void add_remote_values_listener(LightRemoteValuesListener *listener); - /** - * The callback is called once the state of current_values and remote_values are equal (when the - * transition is finished). - * - * @param send_callback + /** Add a listener for target state reached. + * Listener is notified when the light finishes a transition and reaches its target state. + * Lazily allocates the listener vector on first registration. */ - void add_new_target_state_reached_callback(std::function &&send_callback); + void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. void set_default_transition_length(uint32_t default_transition_length); @@ -279,19 +298,24 @@ class LightState : public EntityBase, public Component { // for effects, true if a transformer (transition) is active. bool is_transformer_active_ = false; - /** Callback to call when new values for the frontend are available. + /** Listeners for remote values changes. * * "Remote values" are light color values that are reported to the frontend and have a lower * publish frequency than the "real" color values. For example, during transitions the current * color value may change continuously, but the remote values will be reported as the target values * starting with the beginning of the transition. + * + * Lazily allocated - only created when a listener is actually registered. */ - CallbackManager remote_values_callback_{}; + std::unique_ptr> remote_values_listeners_; - /** Callback to call when the state of current_values and remote_values are equal - * This should be called once the state of current_values changed and equals the state of remote_values + /** Listeners for target state reached. + * Notified when the state of current_values and remote_values are equal + * (when the transition is finished). + * + * Lazily allocated - only created when a listener is actually registered. */ - CallbackManager target_state_reached_callback_{}; + std::unique_ptr> target_state_reached_listeners_; /// Initial state of the light. optional initial_state_{}; diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index 883b67ffc6..fe911bfba2 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -25,8 +25,11 @@ void MQTTJSONLightComponent::setup() { call.perform(); }); - auto f = std::bind(&MQTTJSONLightComponent::publish_state_, this); - this->state_->add_new_remote_values_callback([this, f]() { this->defer("send", f); }); + this->state_->add_remote_values_listener(this); +} + +void MQTTJSONLightComponent::on_light_remote_values_update() { + this->defer("send", [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 3d1e770d4d..a105f3d7b8 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -11,7 +11,7 @@ namespace esphome { namespace mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent { +class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); @@ -25,6 +25,9 @@ class MQTTJSONLightComponent : public mqtt::MQTTComponent { bool send_initial_state() override; + // LightRemoteValuesListener interface + void on_light_remote_values_update() override; + protected: std::string component_type() const override; const EntityBase *get_entity() const override; diff --git a/tests/integration/fixtures/light_automations.yaml b/tests/integration/fixtures/light_automations.yaml new file mode 100644 index 0000000000..b5b88d95e7 --- /dev/null +++ b/tests/integration/fixtures/light_automations.yaml @@ -0,0 +1,26 @@ +esphome: + name: light-automations-test + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: test_output + type: binary + write_action: + - lambda: "" + +light: + - platform: binary + id: test_light + name: "Test Light" + output: test_output + on_turn_on: + - logger.log: "TRIGGER: on_turn_on fired" + on_turn_off: + - logger.log: "TRIGGER: on_turn_off fired" + on_state: + - logger.log: "TRIGGER: on_state fired" diff --git a/tests/integration/test_light_automations.py b/tests/integration/test_light_automations.py new file mode 100644 index 0000000000..9ff334548a --- /dev/null +++ b/tests/integration/test_light_automations.py @@ -0,0 +1,101 @@ +"""Integration test for light automation triggers. + +Tests that on_turn_on, on_turn_off, and on_state triggers work correctly +with the listener interface pattern. +""" + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_automations( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light on_turn_on, on_turn_off, and on_state triggers.""" + loop = asyncio.get_running_loop() + + # Futures for log line detection + on_turn_on_future: asyncio.Future[bool] = loop.create_future() + on_turn_off_future: asyncio.Future[bool] = loop.create_future() + on_state_count = 0 + counting_enabled = False + on_state_futures: list[asyncio.Future[bool]] = [] + + def create_on_state_future() -> asyncio.Future[bool]: + """Create a new future for on_state trigger.""" + future: asyncio.Future[bool] = loop.create_future() + on_state_futures.append(future) + return future + + def check_output(line: str) -> None: + """Check log output for trigger messages.""" + nonlocal on_state_count + if "TRIGGER: on_turn_on fired" in line: + if not on_turn_on_future.done(): + on_turn_on_future.set_result(True) + elif "TRIGGER: on_turn_off fired" in line: + if not on_turn_off_future.done(): + on_turn_off_future.set_result(True) + elif "TRIGGER: on_state fired" in line: + # Only count on_state after we start testing + if counting_enabled: + on_state_count += 1 + # Complete any pending on_state futures + for future in on_state_futures: + if not future.done(): + future.set_result(True) + break + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entities + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + + # Start counting on_state events now + counting_enabled = True + + # Test 1: Turn light on - should trigger on_turn_on and on_state + on_state_future_1 = create_on_state_future() + client.light_command(key=light.key, state=True) + + # Wait for on_turn_on trigger + try: + await asyncio.wait_for(on_turn_on_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_turn_on trigger did not fire") + + # Wait for on_state trigger + try: + await asyncio.wait_for(on_state_future_1, timeout=5.0) + except TimeoutError: + pytest.fail("on_state trigger did not fire after turn on") + + # Test 2: Turn light off - should trigger on_turn_off and on_state + on_state_future_2 = create_on_state_future() + client.light_command(key=light.key, state=False) + + # Wait for on_turn_off trigger + try: + await asyncio.wait_for(on_turn_off_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_turn_off trigger did not fire") + + # Wait for on_state trigger + try: + await asyncio.wait_for(on_state_future_2, timeout=5.0) + except TimeoutError: + pytest.fail("on_state trigger did not fire after turn off") + + # Verify on_state fired exactly twice (once for on, once for off) + assert on_state_count == 2, ( + f"on_state should have triggered exactly twice, got {on_state_count}" + ) From 101103c66639bba53309fab3caec4992128b89e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:02:09 -0600 Subject: [PATCH 217/320] [core] Add RAM strings and symbols analysis to analyze-memory command (#12161) --- esphome/__main__.py | 22 +- esphome/analyze_memory/__init__.py | 173 +-------- esphome/analyze_memory/demangle.py | 182 ++++++++++ esphome/analyze_memory/ram_strings.py | 493 ++++++++++++++++++++++++++ esphome/analyze_memory/toolchain.py | 57 +++ tests/unit_tests/test_main.py | 25 +- 6 files changed, 779 insertions(+), 173 deletions(-) create mode 100644 esphome/analyze_memory/demangle.py create mode 100644 esphome/analyze_memory/ram_strings.py create mode 100644 esphome/analyze_memory/toolchain.py diff --git a/esphome/__main__.py b/esphome/__main__.py index f8fb678cb2..55fbbc6c8a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -944,6 +944,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: """ from esphome import platformio_api from esphome.analyze_memory.cli import MemoryAnalyzerCLI + from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) @@ -966,7 +967,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: external_components = detect_external_components(config) _LOGGER.debug("Detected external components: %s", external_components) - # Perform memory analysis + # Perform component memory analysis _LOGGER.info("Analyzing memory usage...") analyzer = MemoryAnalyzerCLI( str(firmware_elf), @@ -976,11 +977,28 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ) analyzer.analyze() - # Generate and display report + # Generate and display component report report = analyzer.generate_report() print() print(report) + # Perform RAM strings analysis + _LOGGER.info("Analyzing RAM strings...") + try: + ram_analyzer = RamStringsAnalyzer( + str(firmware_elf), + objdump_path=idedata.objdump_path, + platform=CORE.target_platform, + ) + ram_analyzer.analyze() + + # Generate and display RAM strings report + ram_report = ram_analyzer.generate_report() + print() + print(ram_report) + except Exception as e: # pylint: disable=broad-except + _LOGGER.warning("RAM strings analysis failed: %s", e) + return 0 diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 71e86e3788..9632a68913 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -15,6 +15,7 @@ from .const import ( SECTION_TO_ATTR, SYMBOL_PATTERNS, ) +from .demangle import batch_demangle from .helpers import ( get_component_class_patterns, get_esphome_components, @@ -27,15 +28,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# GCC global constructor/destructor prefix annotations -_GCC_PREFIX_ANNOTATIONS = { - "_GLOBAL__sub_I_": "global constructor for", - "_GLOBAL__sub_D_": "global destructor for", -} - -# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2) -_GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)") - # C++ runtime patterns for categorization _CPP_RUNTIME_PATTERNS = frozenset(["vtable", "typeinfo", "thunk"]) @@ -312,168 +304,9 @@ class MemoryAnalyzer: if not symbols: return - # Try to find the appropriate c++filt for the platform - cppfilt_cmd = "c++filt" - _LOGGER.info("Demangling %d symbols", len(symbols)) - _LOGGER.debug("objdump_path = %s", self.objdump_path) - - # Check if we have a toolchain-specific c++filt - if self.objdump_path and self.objdump_path != "objdump": - # Replace objdump with c++filt in the path - potential_cppfilt = self.objdump_path.replace("objdump", "c++filt") - _LOGGER.info("Checking for toolchain c++filt at: %s", potential_cppfilt) - if Path(potential_cppfilt).exists(): - cppfilt_cmd = potential_cppfilt - _LOGGER.info("✓ Using toolchain c++filt: %s", cppfilt_cmd) - else: - _LOGGER.info( - "✗ Toolchain c++filt not found at %s, using system c++filt", - potential_cppfilt, - ) - else: - _LOGGER.info("✗ Using system c++filt (objdump_path=%s)", self.objdump_path) - - # Strip GCC optimization suffixes and prefixes before demangling - # Suffixes like $isra$0, $part$0, $constprop$0 confuse c++filt - # Prefixes like _GLOBAL__sub_I_ need to be removed and tracked - symbols_stripped: list[str] = [] - symbols_prefixes: list[str] = [] # Track removed prefixes - for symbol in symbols: - # Remove GCC optimization markers - stripped = _GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol) - - # Handle GCC global constructor/initializer prefixes - # _GLOBAL__sub_I_ -> extract for demangling - prefix = "" - for gcc_prefix in _GCC_PREFIX_ANNOTATIONS: - if stripped.startswith(gcc_prefix): - prefix = gcc_prefix - stripped = stripped[len(prefix) :] - break - - symbols_stripped.append(stripped) - symbols_prefixes.append(prefix) - - try: - # Send all symbols to c++filt at once - result = subprocess.run( - [cppfilt_cmd], - input="\n".join(symbols_stripped), - capture_output=True, - text=True, - check=False, - ) - except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e: - # On error, cache originals - _LOGGER.warning("Failed to batch demangle symbols: %s", e) - for symbol in symbols: - self._demangle_cache[symbol] = symbol - return - - if result.returncode != 0: - _LOGGER.warning( - "c++filt exited with code %d: %s", - result.returncode, - result.stderr[:200] if result.stderr else "(no error output)", - ) - # Cache originals on failure - for symbol in symbols: - self._demangle_cache[symbol] = symbol - return - - # Process demangled output - self._process_demangled_output( - symbols, symbols_stripped, symbols_prefixes, result.stdout, cppfilt_cmd - ) - - def _process_demangled_output( - self, - symbols: list[str], - symbols_stripped: list[str], - symbols_prefixes: list[str], - demangled_output: str, - cppfilt_cmd: str, - ) -> None: - """Process demangled symbol output and populate cache. - - Args: - symbols: Original symbol names - symbols_stripped: Stripped symbol names sent to c++filt - symbols_prefixes: Removed prefixes to restore - demangled_output: Output from c++filt - cppfilt_cmd: Path to c++filt command (for logging) - """ - demangled_lines = demangled_output.strip().split("\n") - failed_count = 0 - - for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines - ): - # Add back any prefix that was removed - demangled = self._restore_symbol_prefix(prefix, stripped, demangled) - - # If we stripped a suffix, add it back to the demangled name for clarity - if original != stripped and not prefix: - demangled = self._restore_symbol_suffix(original, demangled) - - self._demangle_cache[original] = demangled - - # Log symbols that failed to demangle (stayed the same as stripped version) - if stripped == demangled and stripped.startswith("_Z"): - failed_count += 1 - if failed_count <= 5: # Only log first 5 failures - _LOGGER.warning("Failed to demangle: %s", original) - - if failed_count == 0: - _LOGGER.info("Successfully demangled all %d symbols", len(symbols)) - return - - _LOGGER.warning( - "Failed to demangle %d/%d symbols using %s", - failed_count, - len(symbols), - cppfilt_cmd, - ) - - @staticmethod - def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str: - """Restore prefix that was removed before demangling. - - Args: - prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_") - stripped: Stripped symbol name - demangled: Demangled symbol name - - Returns: - Demangled name with prefix restored/annotated - """ - if not prefix: - return demangled - - # Successfully demangled - add descriptive prefix - if demangled != stripped and ( - annotation := _GCC_PREFIX_ANNOTATIONS.get(prefix) - ): - return f"[{annotation}: {demangled}]" - - # Failed to demangle - restore original prefix - return prefix + demangled - - @staticmethod - def _restore_symbol_suffix(original: str, demangled: str) -> str: - """Restore GCC optimization suffix that was removed before demangling. - - Args: - original: Original symbol name with suffix - demangled: Demangled symbol name without suffix - - Returns: - Demangled name with suffix annotation - """ - if suffix_match := _GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original): - return f"{demangled} [{suffix_match.group(1)}]" - return demangled + self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path) + _LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache)) def _demangle_symbol(self, symbol: str) -> str: """Get demangled C++ symbol name from cache.""" diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py new file mode 100644 index 0000000000..8999108b51 --- /dev/null +++ b/esphome/analyze_memory/demangle.py @@ -0,0 +1,182 @@ +"""Symbol demangling utilities for memory analysis. + +This module provides functions for demangling C++ symbol names using c++filt. +""" + +from __future__ import annotations + +import logging +import re +import subprocess + +from .toolchain import find_tool + +_LOGGER = logging.getLogger(__name__) + +# GCC global constructor/destructor prefix annotations +GCC_PREFIX_ANNOTATIONS = { + "_GLOBAL__sub_I_": "global constructor for", + "_GLOBAL__sub_D_": "global destructor for", +} + +# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2) +GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)") + + +def _strip_gcc_annotations(symbol: str) -> tuple[str, str]: + """Strip GCC optimization suffixes and prefixes from a symbol. + + Args: + symbol: The mangled symbol name + + Returns: + Tuple of (stripped_symbol, removed_prefix) + """ + # Remove GCC optimization markers + stripped = GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol) + + # Handle GCC global constructor/initializer prefixes + prefix = "" + for gcc_prefix in GCC_PREFIX_ANNOTATIONS: + if stripped.startswith(gcc_prefix): + prefix = gcc_prefix + stripped = stripped[len(prefix) :] + break + + return stripped, prefix + + +def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str: + """Restore prefix that was removed before demangling. + + Args: + prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_") + stripped: Stripped symbol name + demangled: Demangled symbol name + + Returns: + Demangled name with prefix restored/annotated + """ + if not prefix: + return demangled + + # Successfully demangled - add descriptive prefix + if demangled != stripped and (annotation := GCC_PREFIX_ANNOTATIONS.get(prefix)): + return f"[{annotation}: {demangled}]" + + # Failed to demangle - restore original prefix + return prefix + demangled + + +def _restore_symbol_suffix(original: str, demangled: str) -> str: + """Restore GCC optimization suffix that was removed before demangling. + + Args: + original: Original symbol name with suffix + demangled: Demangled symbol name without suffix + + Returns: + Demangled name with suffix annotation + """ + if suffix_match := GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original): + return f"{demangled} [{suffix_match.group(1)}]" + return demangled + + +def batch_demangle( + symbols: list[str], + cppfilt_path: str | None = None, + objdump_path: str | None = None, +) -> dict[str, str]: + """Batch demangle C++ symbol names. + + Args: + symbols: List of symbol names to demangle + cppfilt_path: Path to c++filt binary (auto-detected if not provided) + objdump_path: Path to objdump binary to derive c++filt path from + + Returns: + Dictionary mapping original symbol names to demangled names + """ + cache: dict[str, str] = {} + + if not symbols: + return cache + + # Find c++filt tool + cppfilt_cmd = cppfilt_path or find_tool("c++filt", objdump_path) + if not cppfilt_cmd: + _LOGGER.warning("Could not find c++filt, symbols will not be demangled") + return {s: s for s in symbols} + + _LOGGER.debug("Demangling %d symbols using %s", len(symbols), cppfilt_cmd) + + # Strip GCC optimization suffixes and prefixes before demangling + symbols_stripped: list[str] = [] + symbols_prefixes: list[str] = [] + for symbol in symbols: + stripped, prefix = _strip_gcc_annotations(symbol) + symbols_stripped.append(stripped) + symbols_prefixes.append(prefix) + + try: + result = subprocess.run( + [cppfilt_cmd], + input="\n".join(symbols_stripped), + capture_output=True, + text=True, + check=False, + ) + except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e: + _LOGGER.warning("Failed to batch demangle symbols: %s", e) + return {s: s for s in symbols} + + if result.returncode != 0: + _LOGGER.warning( + "c++filt exited with code %d: %s", + result.returncode, + result.stderr[:200] if result.stderr else "(no error output)", + ) + return {s: s for s in symbols} + + # Process demangled output + demangled_lines = result.stdout.strip().split("\n") + + # Check for output length mismatch + if len(demangled_lines) != len(symbols): + _LOGGER.warning( + "c++filt output mismatch: expected %d lines, got %d", + len(symbols), + len(demangled_lines), + ) + return {s: s for s in symbols} + + failed_count = 0 + + for original, stripped, prefix, demangled in zip( + symbols, symbols_stripped, symbols_prefixes, demangled_lines + ): + # Add back any prefix that was removed + demangled = _restore_symbol_prefix(prefix, stripped, demangled) + + # If we stripped a suffix, add it back to the demangled name for clarity + if original != stripped and not prefix: + demangled = _restore_symbol_suffix(original, demangled) + + cache[original] = demangled + + # Count symbols that failed to demangle + if stripped == demangled and stripped.startswith("_Z"): + failed_count += 1 + if failed_count <= 5: + _LOGGER.debug("Failed to demangle: %s", original) + + if failed_count > 0: + _LOGGER.debug( + "Failed to demangle %d/%d symbols using %s", + failed_count, + len(symbols), + cppfilt_cmd, + ) + + return cache diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py new file mode 100644 index 0000000000..fbcbeeca61 --- /dev/null +++ b/esphome/analyze_memory/ram_strings.py @@ -0,0 +1,493 @@ +"""Analyzer for RAM-stored strings in ESP8266/ESP32 firmware ELF files. + +This module identifies strings that are stored in RAM sections (.data, .bss, .rodata) +rather than in flash sections (.irom0.text, .irom.text), which is important for +memory-constrained platforms like ESP8266. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +import logging +from pathlib import Path +import re +import subprocess + +from .demangle import batch_demangle +from .toolchain import find_tool + +_LOGGER = logging.getLogger(__name__) + +# ESP8266: .rodata is in RAM (DRAM), not flash +# ESP32: .rodata is in flash, mapped to data bus +ESP8266_RAM_SECTIONS = frozenset([".data", ".rodata", ".bss"]) +ESP8266_FLASH_SECTIONS = frozenset([".irom0.text", ".irom.text", ".text"]) + +# ESP32: .rodata is memory-mapped from flash +ESP32_RAM_SECTIONS = frozenset([".data", ".bss", ".dram0.data", ".dram0.bss"]) +ESP32_FLASH_SECTIONS = frozenset([".text", ".rodata", ".flash.text", ".flash.rodata"]) + +# nm symbol types for data symbols (D=global data, d=local data, R=rodata, B=bss) +DATA_SYMBOL_TYPES = frozenset(["D", "d", "R", "r", "B", "b"]) + + +@dataclass +class SectionInfo: + """Information about an ELF section.""" + + name: str + address: int + size: int + + +@dataclass +class RamString: + """A string found in RAM.""" + + section: str + address: int + content: str + + @property + def size(self) -> int: + """Size in bytes including null terminator.""" + return len(self.content) + 1 + + +@dataclass +class RamSymbol: + """A symbol found in RAM.""" + + name: str + sym_type: str + address: int + size: int + section: str + demangled: str = "" # Demangled name, set after batch demangling + + +class RamStringsAnalyzer: + """Analyzes ELF files to find strings stored in RAM.""" + + def __init__( + self, + elf_path: str, + objdump_path: str | None = None, + min_length: int = 8, + platform: str = "esp32", + ) -> None: + """Initialize the RAM strings analyzer. + + Args: + elf_path: Path to the ELF file to analyze + objdump_path: Path to objdump binary (used to find other tools) + min_length: Minimum string length to report (default: 8) + platform: Platform name ("esp8266", "esp32", etc.) for section mapping + """ + self.elf_path = Path(elf_path) + if not self.elf_path.exists(): + raise FileNotFoundError(f"ELF file not found: {elf_path}") + + self.objdump_path = objdump_path + self.min_length = min_length + self.platform = platform + + # Set RAM/flash sections based on platform + if self.platform == "esp8266": + self.ram_sections = ESP8266_RAM_SECTIONS + self.flash_sections = ESP8266_FLASH_SECTIONS + else: + # ESP32 and other platforms + self.ram_sections = ESP32_RAM_SECTIONS + self.flash_sections = ESP32_FLASH_SECTIONS + + self.sections: dict[str, SectionInfo] = {} + self.ram_strings: list[RamString] = [] + self.ram_symbols: list[RamSymbol] = [] + + def _run_command(self, cmd: list[str]) -> str: + """Run a command and return its output.""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return result.stdout + except subprocess.CalledProcessError as e: + _LOGGER.debug("Command failed: %s - %s", " ".join(cmd), e.stderr) + raise + except FileNotFoundError: + _LOGGER.warning("Command not found: %s", cmd[0]) + raise + + def analyze(self) -> None: + """Perform the full RAM analysis.""" + self._parse_sections() + self._extract_strings() + self._analyze_symbols() + self._demangle_symbols() + + def _parse_sections(self) -> None: + """Parse section headers from ELF file.""" + objdump = find_tool("objdump", self.objdump_path) + if not objdump: + _LOGGER.error("Could not find objdump command") + return + + try: + output = self._run_command([objdump, "-h", str(self.elf_path)]) + except (subprocess.CalledProcessError, FileNotFoundError): + return + + # Parse section headers + # Format: Idx Name Size VMA LMA File off Algn + section_pattern = re.compile( + r"^\s*\d+\s+(\S+)\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)" + ) + + for line in output.split("\n"): + if match := section_pattern.match(line): + name = match.group(1) + size = int(match.group(2), 16) + vma = int(match.group(3), 16) + self.sections[name] = SectionInfo(name, vma, size) + + def _extract_strings(self) -> None: + """Extract strings from RAM sections.""" + objdump = find_tool("objdump", self.objdump_path) + if not objdump: + return + + for section_name in self.ram_sections: + if section_name not in self.sections: + continue + + try: + output = self._run_command( + [objdump, "-s", "-j", section_name, str(self.elf_path)] + ) + except subprocess.CalledProcessError: + # Section may exist but have no content (e.g., .bss) + continue + except FileNotFoundError: + continue + + strings = self._parse_hex_dump(output, section_name) + self.ram_strings.extend(strings) + + def _parse_hex_dump(self, output: str, section_name: str) -> list[RamString]: + """Parse hex dump output to extract strings. + + Args: + output: Output from objdump -s + section_name: Name of the section being parsed + + Returns: + List of RamString objects + """ + strings: list[RamString] = [] + current_string = bytearray() + string_start_addr = 0 + + for line in output.split("\n"): + # Lines look like: " 3ffef8a0 00000000 00000000 00000000 00000000 ................" + match = re.match(r"^\s+([0-9a-fA-F]+)\s+((?:[0-9a-fA-F]{2,8}\s*)+)", line) + if not match: + continue + + addr = int(match.group(1), 16) + hex_data = match.group(2).strip() + + # Convert hex to bytes + hex_bytes = hex_data.split() + byte_offset = 0 + for hex_chunk in hex_bytes: + # Handle both byte-by-byte and word formats + for i in range(0, len(hex_chunk), 2): + byte_val = int(hex_chunk[i : i + 2], 16) + if 0x20 <= byte_val <= 0x7E: # Printable ASCII + if not current_string: + string_start_addr = addr + byte_offset + current_string.append(byte_val) + else: + if byte_val == 0 and len(current_string) >= self.min_length: + # Found null terminator + strings.append( + RamString( + section=section_name, + address=string_start_addr, + content=current_string.decode( + "ascii", errors="ignore" + ), + ) + ) + current_string = bytearray() + byte_offset += 1 + + return strings + + def _analyze_symbols(self) -> None: + """Analyze symbols in RAM sections.""" + nm = find_tool("nm", self.objdump_path) + if not nm: + return + + try: + output = self._run_command([nm, "-S", "--size-sort", str(self.elf_path)]) + except (subprocess.CalledProcessError, FileNotFoundError): + return + + for line in output.split("\n"): + parts = line.split() + if len(parts) < 4: + continue + + try: + addr = int(parts[0], 16) + size = int(parts[1], 16) if parts[1] != "?" else 0 + except ValueError: + continue + + sym_type = parts[2] + name = " ".join(parts[3:]) + + # Filter for data symbols + if sym_type not in DATA_SYMBOL_TYPES: + continue + + # Check if symbol is in a RAM section + for section_name in self.ram_sections: + if section_name not in self.sections: + continue + + section = self.sections[section_name] + if section.address <= addr < section.address + section.size: + self.ram_symbols.append( + RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, + ) + ) + break + + def _demangle_symbols(self) -> None: + """Batch demangle all RAM symbol names.""" + if not self.ram_symbols: + return + + # Collect all symbol names and demangle them + symbol_names = [s.name for s in self.ram_symbols] + demangle_cache = batch_demangle(symbol_names, objdump_path=self.objdump_path) + + # Assign demangled names to symbols + for symbol in self.ram_symbols: + symbol.demangled = demangle_cache.get(symbol.name, symbol.name) + + def _get_sections_size(self, section_names: frozenset[str]) -> int: + """Get total size of specified sections.""" + return sum( + section.size + for name, section in self.sections.items() + if name in section_names + ) + + def get_total_ram_usage(self) -> int: + """Get total RAM usage from RAM sections.""" + return self._get_sections_size(self.ram_sections) + + def get_total_flash_usage(self) -> int: + """Get total flash usage from flash sections.""" + return self._get_sections_size(self.flash_sections) + + def get_total_string_bytes(self) -> int: + """Get total bytes used by strings in RAM.""" + return sum(s.size for s in self.ram_strings) + + def get_repeated_strings(self) -> list[tuple[str, int]]: + """Find strings that appear multiple times. + + Returns: + List of (string, count) tuples sorted by potential savings + """ + string_counts: dict[str, int] = defaultdict(int) + for ram_string in self.ram_strings: + string_counts[ram_string.content] += 1 + + return sorted( + [(s, c) for s, c in string_counts.items() if c > 1], + key=lambda x: x[1] * (len(x[0]) + 1), + reverse=True, + ) + + def get_long_strings(self, min_len: int = 20) -> list[RamString]: + """Get strings longer than the specified length. + + Args: + min_len: Minimum string length + + Returns: + List of RamString objects sorted by length + """ + return sorted( + [s for s in self.ram_strings if len(s.content) >= min_len], + key=lambda x: len(x.content), + reverse=True, + ) + + def get_largest_symbols(self, min_size: int = 100) -> list[RamSymbol]: + """Get RAM symbols larger than the specified size. + + Args: + min_size: Minimum symbol size in bytes + + Returns: + List of RamSymbol objects sorted by size + """ + return sorted( + [s for s in self.ram_symbols if s.size >= min_size], + key=lambda x: x.size, + reverse=True, + ) + + def generate_report(self, show_all_sections: bool = False) -> str: + """Generate a formatted RAM strings analysis report. + + Args: + show_all_sections: If True, show all sections, not just RAM + + Returns: + Formatted report string + """ + lines: list[str] = [] + table_width = 80 + + lines.append("=" * table_width) + lines.append( + f"RAM Strings Analysis ({self.platform.upper()})".center(table_width) + ) + lines.append("=" * table_width) + lines.append("") + + # Section Analysis + lines.append("SECTION ANALYSIS") + lines.append("-" * table_width) + lines.append(f"{'Section':<20} {'Address':<12} {'Size':<12} {'Location'}") + lines.append("-" * table_width) + + total_ram_usage = 0 + total_flash_usage = 0 + + for name, section in sorted(self.sections.items(), key=lambda x: x[1].address): + if name in self.ram_sections: + location = "RAM" + total_ram_usage += section.size + elif name in self.flash_sections: + location = "FLASH" + total_flash_usage += section.size + else: + location = "OTHER" + + if show_all_sections or name in self.ram_sections: + lines.append( + f"{name:<20} 0x{section.address:08x} {section.size:>8} B {location}" + ) + + lines.append("-" * table_width) + lines.append(f"Total RAM sections size: {total_ram_usage:,} bytes") + lines.append(f"Total Flash sections size: {total_flash_usage:,} bytes") + + # Strings in RAM + lines.append("") + lines.append("=" * table_width) + lines.append("STRINGS IN RAM SECTIONS") + lines.append("=" * table_width) + lines.append( + "Note: .bss sections contain uninitialized data (no strings to extract)" + ) + + # Group strings by section + strings_by_section: dict[str, list[RamString]] = defaultdict(list) + for ram_string in self.ram_strings: + strings_by_section[ram_string.section].append(ram_string) + + for section_name in sorted(strings_by_section.keys()): + section_strings = strings_by_section[section_name] + lines.append(f"\nSection: {section_name}") + lines.append("-" * 40) + for ram_string in sorted(section_strings, key=lambda x: x.address): + clean_string = ram_string.content[:100] + ( + "..." if len(ram_string.content) > 100 else "" + ) + lines.append( + f' 0x{ram_string.address:08x}: "{clean_string}" (len={len(ram_string.content)})' + ) + + # Large RAM symbols + lines.append("") + lines.append("=" * table_width) + lines.append("LARGE DATA SYMBOLS IN RAM (>= 50 bytes)") + lines.append("=" * table_width) + + largest_symbols = self.get_largest_symbols(50) + lines.append(f"\n{'Symbol':<50} {'Type':<6} {'Size':<10} {'Section'}") + lines.append("-" * table_width) + + for symbol in largest_symbols: + # Use demangled name if available, otherwise raw name + display_name = symbol.demangled or symbol.name + name_display = display_name[:49] if len(display_name) > 49 else display_name + lines.append( + f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" + ) + + # Summary + lines.append("") + lines.append("=" * table_width) + lines.append("SUMMARY") + lines.append("=" * table_width) + lines.append(f"Total strings found in RAM: {len(self.ram_strings)}") + total_string_bytes = self.get_total_string_bytes() + lines.append(f"Total bytes used by strings: {total_string_bytes:,}") + + # Optimization targets + lines.append("") + lines.append("=" * table_width) + lines.append("POTENTIAL OPTIMIZATION TARGETS") + lines.append("=" * table_width) + + # Repeated strings + repeated = self.get_repeated_strings()[:10] + if repeated: + lines.append("\nRepeated strings (could be deduplicated):") + for string, count in repeated: + savings = (count - 1) * (len(string) + 1) + clean_string = string[:50] + ("..." if len(string) > 50 else "") + lines.append( + f' "{clean_string}" - appears {count} times (potential savings: {savings} bytes)' + ) + + # Long strings - platform-specific advice + long_strings = self.get_long_strings(20)[:10] + if long_strings: + if self.platform == "esp8266": + lines.append( + "\nLong strings that could be moved to PROGMEM (>= 20 chars):" + ) + else: + # ESP32: strings in DRAM are typically there for a reason + # (interrupt handlers, pre-flash-init code, etc.) + lines.append("\nLong strings in DRAM (>= 20 chars):") + lines.append( + "Note: ESP32 DRAM strings may be required for interrupt/early-boot contexts" + ) + for ram_string in long_strings: + clean_string = ram_string.content[:60] + ( + "..." if len(ram_string.content) > 60 else "" + ) + lines.append( + f' {ram_string.section} @ 0x{ram_string.address:08x}: "{clean_string}" ({len(ram_string.content)} bytes)' + ) + + lines.append("") + return "\n".join(lines) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py new file mode 100644 index 0000000000..e766252412 --- /dev/null +++ b/esphome/analyze_memory/toolchain.py @@ -0,0 +1,57 @@ +"""Toolchain utilities for memory analysis.""" + +from __future__ import annotations + +import logging +from pathlib import Path +import subprocess + +_LOGGER = logging.getLogger(__name__) + +# Platform-specific toolchain prefixes +TOOLCHAIN_PREFIXES = [ + "xtensa-lx106-elf-", # ESP8266 + "xtensa-esp32-elf-", # ESP32 + "xtensa-esp-elf-", # ESP32 (newer IDF) + "", # System default (no prefix) +] + + +def find_tool( + tool_name: str, + objdump_path: str | None = None, +) -> str | None: + """Find a toolchain tool by name. + + First tries to derive the tool path from objdump_path (if provided), + then falls back to searching for platform-specific tools. + + Args: + tool_name: Name of the tool (e.g., "objdump", "nm", "c++filt") + objdump_path: Path to objdump binary to derive other tool paths from + + Returns: + Path to the tool or None if not found + """ + # Try to derive from objdump path first (most reliable) + if objdump_path and objdump_path != "objdump": + objdump_file = Path(objdump_path) + # Replace just the filename portion, preserving any prefix (e.g., xtensa-esp32-elf-) + new_name = objdump_file.name.replace("objdump", tool_name) + potential_path = str(objdump_file.with_name(new_name)) + if Path(potential_path).exists(): + _LOGGER.debug("Found %s at: %s", tool_name, potential_path) + return potential_path + + # Try platform-specific tools + for prefix in TOOLCHAIN_PREFIXES: + cmd = f"{prefix}{tool_name}" + try: + subprocess.run([cmd, "--version"], capture_output=True, check=True) + _LOGGER.debug("Found %s: %s", tool_name, cmd) + return cmd + except (subprocess.CalledProcessError, FileNotFoundError): + continue + + _LOGGER.warning("Could not find %s tool", tool_name) + return None diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index ccbc5a1306..670d6c16fc 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -269,6 +269,16 @@ def mock_memory_analyzer_cli() -> Generator[Mock]: yield mock_class +@pytest.fixture +def mock_ram_strings_analyzer() -> Generator[Mock]: + """Mock RamStringsAnalyzer for testing.""" + with patch("esphome.analyze_memory.ram_strings.RamStringsAnalyzer") as mock_class: + mock_analyzer = MagicMock() + mock_analyzer.generate_report.return_value = "Mock RAM Strings Report" + mock_class.return_value = mock_analyzer + yield mock_class + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() @@ -2424,6 +2434,7 @@ def test_command_analyze_memory_success( mock_get_idedata: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, ) -> None: """Test command_analyze_memory with successful compilation and analysis.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") @@ -2471,9 +2482,20 @@ def test_command_analyze_memory_success( mock_analyzer.analyze.assert_called_once() mock_analyzer.generate_report.assert_called_once() - # Verify report was printed + # Verify RAM strings analyzer was created and run + mock_ram_strings_analyzer.assert_called_once_with( + str(firmware_elf), + objdump_path="/path/to/objdump", + platform="esp32", + ) + mock_ram_analyzer = mock_ram_strings_analyzer.return_value + mock_ram_analyzer.analyze.assert_called_once() + mock_ram_analyzer.generate_report.assert_called_once() + + # Verify reports were printed captured = capfd.readouterr() assert "Mock Memory Report" in captured.out + assert "Mock RAM Strings Report" in captured.out def test_command_analyze_memory_with_external_components( @@ -2483,6 +2505,7 @@ def test_command_analyze_memory_with_external_components( mock_get_idedata: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, ) -> None: """Test command_analyze_memory detects external components.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") From d1583456e97af1a00ec80350223d685addac2eb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:02:29 -0600 Subject: [PATCH 218/320] [web_server] Store update state strings in flash on ESP8266 (#12204) --- esphome/components/web_server/web_server.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 38fa54704a..b56d9ce698 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -41,8 +41,8 @@ namespace web_server { static const char *const TAG = "web_server"; -// Longest: HORIZONTAL (10 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 16; +// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) +static constexpr size_t PSTR_LOCAL_SIZE = 18; #define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS @@ -1717,16 +1717,16 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty #endif #ifdef USE_UPDATE -static const char *update_state_to_string(update::UpdateState state) { +static const LogString *update_state_to_string(update::UpdateState state) { switch (state) { case update::UPDATE_STATE_NO_UPDATE: - return "NO UPDATE"; + return LOG_STR("NO UPDATE"); case update::UPDATE_STATE_AVAILABLE: - return "UPDATE AVAILABLE"; + return LOG_STR("UPDATE AVAILABLE"); case update::UPDATE_STATE_INSTALLING: - return "INSTALLING"; + return LOG_STR("INSTALLING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -1769,8 +1769,9 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "update", update_state_to_string(obj->state), obj->update_info.latest_version, - start_config); + char buf[PSTR_LOCAL_SIZE]; + set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)), + obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root["current_version"] = obj->update_info.current_version; root["title"] = obj->update_info.title; From 3f08cacf71e32e582b9c6612c445488a9372423c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:02:51 -0600 Subject: [PATCH 219/320] [valve] Store valve state strings in flash on ESP8266 (#12202) --- .../prometheus/prometheus_handler.cpp | 6 ++++- esphome/components/valve/valve.cpp | 22 +++++++++---------- esphome/components/valve/valve.h | 3 ++- esphome/components/web_server/web_server.cpp | 3 ++- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 252b477400..4b5d834ebf 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -895,7 +895,11 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); - stream->print(valve::valve_operation_to_str(obj->current_operation)); +#ifdef USE_STORE_LOG_STR_IN_FLASH + stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); +#else + stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); +#endif stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 381d9061de..fed113afc2 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -12,25 +12,25 @@ static const char *const TAG = "valve"; const float VALVE_OPEN = 1.0f; const float VALVE_CLOSED = 0.0f; -const char *valve_command_to_str(float pos) { +const LogString *valve_command_to_str(float pos) { if (pos == VALVE_OPEN) { - return "OPEN"; + return LOG_STR("OPEN"); } else if (pos == VALVE_CLOSED) { - return "CLOSE"; + return LOG_STR("CLOSE"); } else { - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -const char *valve_operation_to_str(ValveOperation op) { +const LogString *valve_operation_to_str(ValveOperation op) { switch (op) { case VALVE_OPERATION_IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case VALVE_OPERATION_OPENING: - return "OPENING"; + return LOG_STR("OPENING"); case VALVE_OPERATION_CLOSING: - return "CLOSING"; + return LOG_STR("CLOSING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -82,7 +82,7 @@ void ValveCall::perform() { if (traits.get_supports_position()) { ESP_LOGD(TAG, " Position: %.0f%%", *this->position_ * 100.0f); } else { - ESP_LOGD(TAG, " Command: %s", valve_command_to_str(*this->position_)); + ESP_LOGD(TAG, " Command: %s", LOG_STR_ARG(valve_command_to_str(*this->position_))); } } if (this->toggle_.has_value()) { @@ -146,7 +146,7 @@ void Valve::publish_state(bool save) { ESP_LOGD(TAG, " State: UNKNOWN"); } } - ESP_LOGD(TAG, " Current Operation: %s", valve_operation_to_str(this->current_operation)); + ESP_LOGD(TAG, " Current Operation: %s", LOG_STR_ARG(valve_operation_to_str(this->current_operation))); this->state_callback_.call(); #if defined(USE_VALVE) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index ab7ff5abe1..2cb28e4b2f 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "valve_traits.h" @@ -81,7 +82,7 @@ enum ValveOperation : uint8_t { VALVE_OPERATION_CLOSING, }; -const char *valve_operation_to_str(ValveOperation op); +const LogString *valve_operation_to_str(ValveOperation op); /** Base class for all valve devices. * diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index b56d9ce698..35f20f8609 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1565,7 +1565,8 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + char buf[PSTR_LOCAL_SIZE]; + root["current_operation"] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root["position"] = obj->position; From 77477bd3300827055b3574c79fe55aa630cabd17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:03:29 -0600 Subject: [PATCH 220/320] [web_server_idf] Fix SSE multi-line message formatting (#12247) --- .../web_server_idf/web_server_idf.cpp | 87 +++++++++++++++++-- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index c910ed06c5..af99b85e53 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -664,17 +664,92 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char event_buffer_.append(CRLF_STR, CRLF_LEN); } - if (message && *message) { - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); - event_buffer_.append(CRLF_STR, CRLF_LEN); + // Match ESPAsyncWebServer: null message means no data lines and no terminating blank line + if (message) { + // SSE spec requires each line of a multi-line message to have its own "data:" prefix + // Handle \n, \r, and \r\n line endings (matching ESPAsyncWebServer behavior) + + // Fast path: check if message contains any newlines at all + // Most SSE messages (JSON state updates) have no newlines + const char *first_n = strchr(message, '\n'); + const char *first_r = strchr(message, '\r'); + + if (first_n == nullptr && first_r == nullptr) { + // No newlines - fast path (most common case) + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(message); + event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator + } else { + // Has newlines - handle multi-line message + const char *line_start = message; + size_t msg_len = strlen(message); + const char *msg_end = message + msg_len; + + // Reuse the first search results + const char *next_n = first_n; + const char *next_r = first_r; + + while (line_start <= msg_end) { + const char *line_end; + const char *next_line; + + if (next_n == nullptr && next_r == nullptr) { + // No more line breaks - output remaining text as final line + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(line_start); + event_buffer_.append(CRLF_STR, CRLF_LEN); + break; + } + + // Determine line ending type and next line start + if (next_n != nullptr && next_r != nullptr) { + if (next_r + 1 == next_n) { + // \r\n sequence + line_end = next_r; + next_line = next_n + 1; + } else { + // Mixed \n and \r - use whichever comes first + line_end = (next_r < next_n) ? next_r : next_n; + next_line = line_end + 1; + } + } else if (next_n != nullptr) { + // Unix LF + line_end = next_n; + next_line = next_n + 1; + } else { + // Old Mac CR + line_end = next_r; + next_line = next_r + 1; + } + + // Output this line + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(line_start, line_end - line_start); + event_buffer_.append(CRLF_STR, CRLF_LEN); + + line_start = next_line; + + // Check if we've consumed all content + if (line_start >= msg_end) { + break; + } + + // Search for next newlines only in remaining string + next_n = strchr(line_start, '\n'); + next_r = strchr(line_start, '\r'); + } + + // Terminate message with blank line + event_buffer_.append(CRLF_STR, CRLF_LEN); + } } - if (event_buffer_.empty()) { + if (event_buffer_.size() == static_cast(chunk_len_header_len)) { + // Nothing was added, reset buffer + event_buffer_.resize(0); return true; } - event_buffer_.append(CRLF_STR, CRLF_LEN); event_buffer_.append(CRLF_STR, CRLF_LEN); // chunk length header itself and the final chunk terminating CRLF are not counted as part of the chunk From 6ce2a456915e16968eb0275bc7d6531dd03b7ca4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:03:58 -0600 Subject: [PATCH 221/320] [text_sensor] Add deprecation warning for raw_state member access (#12246) --- esphome/components/text_sensor/text_sensor.cpp | 18 ++++++++++-------- esphome/components/text_sensor/text_sensor.h | 11 +++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index d984e78b2a..51923ebd96 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -25,11 +25,11 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text } void TextSensor::publish_state(const std::string &state) { - // Only store raw_state_ separately when filters exist - // When no filters, raw_state == state, so we avoid the duplicate storage - if (this->filter_list_ != nullptr) { - this->raw_state_ = state; - } +// Suppress deprecation warning - we need to populate raw_state for backwards compatibility +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->raw_state = state; +#pragma GCC diagnostic pop if (this->raw_callback_) { this->raw_callback_->call(state); } @@ -85,9 +85,11 @@ void TextSensor::add_on_raw_state_callback(std::function call std::string TextSensor::get_state() const { return this->state; } std::string TextSensor::get_raw_state() const { - // When no filters exist, raw_state == state, so return state to avoid - // requiring separate storage - return this->filter_list_ != nullptr ? this->raw_state_ : this->state; +// Suppress deprecation warning - get_raw_state() is the replacement API +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return this->raw_state; +#pragma GCC diagnostic pop } void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index fcfbed2fbc..7217806a55 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -51,6 +51,13 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { std::string state; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. + ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") + std::string raw_state; +#pragma GCC diagnostic pop + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -62,10 +69,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. - - /// Raw state (before filters). Only populated when filters are configured. - /// When no filters exist, get_raw_state() returns state directly. - std::string raw_state_; }; } // namespace text_sensor From 8f97f3b81f397c5204200bc4046c0ff7d634dec5 Mon Sep 17 00:00:00 2001 From: Flo Date: Tue, 2 Dec 2025 17:12:27 +0100 Subject: [PATCH 222/320] [wifi] Fix ap_active condition (#12227) --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi/wifi_component.h | 1 + esphome/components/wifi/wifi_component_esp8266.cpp | 3 +++ esphome/components/wifi/wifi_component_esp_idf.cpp | 5 ++--- esphome/components/wifi/wifi_component_libretiny.cpp | 3 +++ esphome/components/wifi/wifi_component_pico_w.cpp | 4 ++++ 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e67493aa4d..317507f242 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -580,7 +580,7 @@ void WiFiComponent::loop() { WiFiComponent::WiFiComponent() { global_wifi_component = this; } bool WiFiComponent::has_ap() const { return this->has_ap_; } -bool WiFiComponent::is_ap_active() const { return this->state_ == WIFI_COMPONENT_STATE_AP; } +bool WiFiComponent::is_ap_active() const { return this->ap_started_; } bool WiFiComponent::has_sta() const { return !this->sta_.empty(); } #ifdef USE_WIFI_11KV_SUPPORT void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 97cc3961fe..2148f2d4c7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -616,6 +616,7 @@ class WiFiComponent : public Component { bool error_from_callback_{false}; bool scan_done_{false}; bool ap_setup_{false}; + bool ap_started_{false}; bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; #ifdef USE_WIFI_11KV_SUPPORT diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 701cae5f7c..c1c0dd470f 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -82,8 +82,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (!ret) { ESP_LOGW(TAG, "Set mode failed"); + return false; } + this->ap_started_ = target_ap; + return ret; } bool WiFiComponent::wifi_apply_power_save_() { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 3d25d2890f..e1f8108892 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -53,7 +53,6 @@ static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid- #endif // USE_WIFI_AP static bool s_sta_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_ap_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -831,11 +830,11 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); - s_ap_started = true; + this->ap_started_ = true; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); - s_ap_started = false; + this->ap_started_ = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index f1405d3bef..0de7003899 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -50,8 +50,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (!ret) { ESP_LOGW(TAG, "Setting mode failed"); + return false; } + this->ap_started_ = enable_ap; + return ret; } bool WiFiComponent::wifi_apply_output_power_(float output_power) { diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1a8b75213c..c7dc4120dd 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -28,11 +28,15 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_STA, true, CYW43_COUNTRY_WORLDWIDE); } } + + bool ap_state = false; if (ap.has_value()) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); + ap_state = true; } } + this->ap_started_ = ap_state; return true; } From 638c59e162cd7ae46b6ddb24f9738cb332ea51ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:13:20 -0600 Subject: [PATCH 223/320] Bump pylint from 4.0.3 to 4.0.4 (#12239) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3aec877126..9d55d23272 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.3 +pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating ruff==0.14.7 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating From a6a6f482e6661c5747ba475d3a170268f9a88e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Dec 2025 10:51:05 -0600 Subject: [PATCH 224/320] [core] Add PROGMEM macros and move web_server JSON keys to flash (#12214) --- .../components/light/light_json_schema.cpp | 94 ++++++------ esphome/components/web_server/web_server.cpp | 142 +++++++++--------- .../web_server_base/web_server_base.h | 15 +- esphome/core/progmem.h | 16 ++ 4 files changed, 137 insertions(+), 130 deletions(-) create mode 100644 esphome/core/progmem.h diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 41cb855630..3365d1f417 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -1,5 +1,6 @@ #include "light_json_schema.h" #include "light_output.h" +#include "esphome/core/progmem.h" #ifdef USE_JSON @@ -35,9 +36,9 @@ static const char *get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root["effect"] = state.get_effect_name(); - root["effect_index"] = state.get_current_effect_index(); - root["effect_count"] = state.get_effect_count(); + root[ESPHOME_F("effect")] = state.get_effect_name(); + root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); + root[ESPHOME_F("effect_count")] = state.get_effect_count(); } auto values = state.remote_values; @@ -45,39 +46,39 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const char *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root["color_mode"] = mode_str; + root[ESPHOME_F("color_mode")] = mode_str; } if (color_mode & ColorCapability::ON_OFF) - root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF"; + root[ESPHOME_F("state")] = (values.get_state() != 0.0f) ? "ON" : "OFF"; if (color_mode & ColorCapability::BRIGHTNESS) - root["brightness"] = to_uint8_scale(values.get_brightness()); + root[ESPHOME_F("brightness")] = to_uint8_scale(values.get_brightness()); - JsonObject color = root["color"].to(); + JsonObject color = root[ESPHOME_F("color")].to(); if (color_mode & ColorCapability::RGB) { float color_brightness = values.get_color_brightness(); - color["r"] = to_uint8_scale(color_brightness * values.get_red()); - color["g"] = to_uint8_scale(color_brightness * values.get_green()); - color["b"] = to_uint8_scale(color_brightness * values.get_blue()); + color[ESPHOME_F("r")] = to_uint8_scale(color_brightness * values.get_red()); + color[ESPHOME_F("g")] = to_uint8_scale(color_brightness * values.get_green()); + color[ESPHOME_F("b")] = to_uint8_scale(color_brightness * values.get_blue()); } if (color_mode & ColorCapability::WHITE) { uint8_t white_val = to_uint8_scale(values.get_white()); - color["w"] = white_val; - root["white_value"] = white_val; // legacy API + color[ESPHOME_F("w")] = white_val; + root[ESPHOME_F("white_value")] = white_val; // legacy API } if (color_mode & ColorCapability::COLOR_TEMPERATURE) { // this one isn't under the color subkey for some reason - root["color_temp"] = uint32_t(values.get_color_temperature()); + root[ESPHOME_F("color_temp")] = uint32_t(values.get_color_temperature()); } if (color_mode & ColorCapability::COLD_WARM_WHITE) { - color["c"] = to_uint8_scale(values.get_cold_white()); - color["w"] = to_uint8_scale(values.get_warm_white()); + color[ESPHOME_F("c")] = to_uint8_scale(values.get_cold_white()); + color[ESPHOME_F("w")] = to_uint8_scale(values.get_warm_white()); } } void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonObject root) { - if (root["state"].is()) { - auto val = parse_on_off(root["state"]); + if (root[ESPHOME_F("state")].is()) { + auto val = parse_on_off(root[ESPHOME_F("state")]); switch (val) { case PARSE_ON: call.set_state(true); @@ -93,76 +94,77 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO } } - if (root["brightness"].is()) { - call.set_brightness(float(root["brightness"]) / 255.0f); + if (root[ESPHOME_F("brightness")].is()) { + call.set_brightness(float(root[ESPHOME_F("brightness")]) / 255.0f); } - if (root["color"].is()) { - JsonObject color = root["color"]; + if (root[ESPHOME_F("color")].is()) { + JsonObject color = root[ESPHOME_F("color")]; // HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness. float max_rgb = 0.0f; - if (color["r"].is()) { - float r = float(color["r"]) / 255.0f; + if (color[ESPHOME_F("r")].is()) { + float r = float(color[ESPHOME_F("r")]) / 255.0f; max_rgb = fmaxf(max_rgb, r); call.set_red(r); } - if (color["g"].is()) { - float g = float(color["g"]) / 255.0f; + if (color[ESPHOME_F("g")].is()) { + float g = float(color[ESPHOME_F("g")]) / 255.0f; max_rgb = fmaxf(max_rgb, g); call.set_green(g); } - if (color["b"].is()) { - float b = float(color["b"]) / 255.0f; + if (color[ESPHOME_F("b")].is()) { + float b = float(color[ESPHOME_F("b")]) / 255.0f; max_rgb = fmaxf(max_rgb, b); call.set_blue(b); } - if (color["r"].is() || color["g"].is() || color["b"].is()) { + if (color[ESPHOME_F("r")].is() || color[ESPHOME_F("g")].is() || + color[ESPHOME_F("b")].is()) { call.set_color_brightness(max_rgb); } - if (color["c"].is()) { - call.set_cold_white(float(color["c"]) / 255.0f); + if (color[ESPHOME_F("c")].is()) { + call.set_cold_white(float(color[ESPHOME_F("c")]) / 255.0f); } - if (color["w"].is()) { + if (color[ESPHOME_F("w")].is()) { // the HA scheme is ambiguous here, the same key is used for white channel in RGBW and warm // white channel in RGBWW. - if (color["c"].is()) { - call.set_warm_white(float(color["w"]) / 255.0f); + if (color[ESPHOME_F("c")].is()) { + call.set_warm_white(float(color[ESPHOME_F("w")]) / 255.0f); } else { - call.set_white(float(color["w"]) / 255.0f); + call.set_white(float(color[ESPHOME_F("w")]) / 255.0f); } } } - if (root["white_value"].is()) { // legacy API - call.set_white(float(root["white_value"]) / 255.0f); + if (root[ESPHOME_F("white_value")].is()) { // legacy API + call.set_white(float(root[ESPHOME_F("white_value")]) / 255.0f); } - if (root["color_temp"].is()) { - call.set_color_temperature(float(root["color_temp"])); + if (root[ESPHOME_F("color_temp")].is()) { + call.set_color_temperature(float(root[ESPHOME_F("color_temp")])); } } void LightJSONSchema::parse_json(LightState &state, LightCall &call, JsonObject root) { LightJSONSchema::parse_color_json(state, call, root); - if (root["flash"].is()) { - auto length = uint32_t(float(root["flash"]) * 1000); + if (root[ESPHOME_F("flash")].is()) { + auto length = uint32_t(float(root[ESPHOME_F("flash")]) * 1000); call.set_flash_length(length); } - if (root["transition"].is()) { - auto length = uint32_t(float(root["transition"]) * 1000); + if (root[ESPHOME_F("transition")].is()) { + auto length = uint32_t(float(root[ESPHOME_F("transition")]) * 1000); call.set_transition_length(length); } - if (root["effect"].is()) { - const char *effect = root["effect"]; + if (root[ESPHOME_F("effect")].is()) { + const char *effect = root[ESPHOME_F("effect")]; call.set_effect(effect); } - if (root["effect_index"].is()) { - uint32_t effect_index = root["effect_index"]; + if (root[ESPHOME_F("effect_index")].is()) { + uint32_t effect_index = root[ESPHOME_F("effect_index")]; call.set_effect(effect_index); } } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 35f20f8609..1f3605a082 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -244,8 +244,8 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; + root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("sorting_weight")] = group.second.weight; message = builder.serialize(); // up to 31 groups should be able to be queued initially without defer @@ -286,15 +286,15 @@ std::string WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root["comment"] = App.get_comment(); + root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root[ESPHOME_F("comment")] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root["ota"] = true; + root[ESPHOME_F("ota")] = true; #endif - root["log"] = this->expose_log_; - root["lang"] = "en"; + root[ESPHOME_F("log")] = this->expose_log_; + root[ESPHOME_F("lang")] = "en"; return builder.serialize(); } @@ -407,14 +407,14 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J char id_buf[160]; // object_id can be up to 128 chars + prefix + dash + null const auto &object_id = obj->get_object_id(); snprintf(id_buf, sizeof(id_buf), "%s-%s", prefix, object_id.c_str()); - root["id"] = id_buf; + root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { - root["name"] = obj->get_name(); - root["icon"] = obj->get_icon_ref(); - root["entity_category"] = obj->get_entity_category(); + root[ESPHOME_F("name")] = obj->get_name(); + root[ESPHOME_F("icon")] = obj->get_icon_ref(); + root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); if (is_disabled) - root["is_disabled_by_default"] = is_disabled; + root[ESPHOME_F("is_disabled_by_default")] = is_disabled; } } @@ -424,14 +424,14 @@ template static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); - root["value"] = value; + root[ESPHOME_F("value")] = value; } template static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const std::string &state, const T &value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); - root["state"] = state; + root[ESPHOME_F("state")] = state; } // Helper to get request detail parameter @@ -478,7 +478,7 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root["uom"] = uom_ref; + root[ESPHOME_F("uom")] = uom_ref; } return builder.serialize(); @@ -593,7 +593,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail set_json_icon_state_value(root, obj, "switch", value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { - root["assumed_state"] = obj->assumed_state(); + root[ESPHOME_F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } @@ -748,11 +748,11 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "fan", obj->state ? "ON" : "OFF", obj->state, start_config); const auto traits = obj->get_traits(); if (traits.supports_speed()) { - root["speed_level"] = obj->speed; - root["speed_count"] = traits.supported_speed_count(); + root[ESPHOME_F("speed_level")] = obj->speed; + root[ESPHOME_F("speed_count")] = traits.supported_speed_count(); } if (obj->get_traits().supports_oscillation()) - root["oscillation"] = obj->oscillating; + root[ESPHOME_F("oscillation")] = obj->oscillating; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -827,7 +827,7 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { - JsonArray opt = root["effects"].to(); + JsonArray opt = root[ESPHOME_F("effects")].to(); opt.add("None"); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); @@ -913,12 +913,12 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); char buf[PSTR_LOCAL_SIZE]; - root["current_operation"] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[ESPHOME_F("position")] = obj->position; if (obj->get_traits().get_supports_tilt()) - root["tilt"] = obj->tilt; + root[ESPHOME_F("tilt")] = obj->tilt; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -979,14 +979,15 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref); set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { - root["min_value"] = + root[ESPHOME_F("min_value")] = value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["max_value"] = + root[ESPHOME_F("max_value")] = value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["step"] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root["mode"] = (int) obj->traits.get_mode(); + root[ESPHOME_F("step")] = + value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); + root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root["uom"] = uom_ref; + root[ESPHOME_F("uom")] = uom_ref; this->add_sorting_info_(root, obj); } @@ -1208,11 +1209,11 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json std::string state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value; set_json_icon_state_value(root, obj, "text", state, value, start_config); - root["min_length"] = obj->traits.get_min_length(); - root["max_length"] = obj->traits.get_max_length(); - root["pattern"] = obj->traits.get_pattern(); + root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); + root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); + root[ESPHOME_F("pattern")] = obj->traits.get_pattern(); if (start_config == DETAIL_ALL) { - root["mode"] = (int) obj->traits.get_mode(); + root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } @@ -1266,7 +1267,7 @@ std::string WebServer::select_json(select::Select *obj, const char *value, JsonD set_json_icon_state_value(root, obj, "select", value, value, start_config); if (start_config == DETAIL_ALL) { - JsonArray opt = root["option"].to(); + JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { opt.add(option); } @@ -1337,32 +1338,32 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf char buf[PSTR_LOCAL_SIZE]; if (start_config == DETAIL_ALL) { - JsonArray opt = root["modes"].to(); + JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["fan_modes"].to(); + JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["custom_fan_modes"].to(); + JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - JsonArray opt = root["swing_modes"].to(); + JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root["presets"].to(); + JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - JsonArray opt = root["custom_presets"].to(); + JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); } @@ -1370,49 +1371,50 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf } bool has_state = false; - root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root["step"] = traits.get_visual_target_temperature_step(); + root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("max_temp")] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root[ESPHOME_F("min_temp")] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root[ESPHOME_F("step")] = traits.get_visual_target_temperature_step(); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root["state"] = root["action"]; + root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root["custom_fan_mode"] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root["custom_preset"] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { if (!std::isnan(obj->current_temperature)) { - root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); + root[ESPHOME_F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root["current_temperature"] = "NA"; + root[ESPHOME_F("current_temperature")] = "NA"; } } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { - root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + root[ESPHOME_F("target_temperature_low")] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root[ESPHOME_F("target_temperature_high")] = + value_accuracy_to_string(obj->target_temperature_high, target_accuracy); if (!has_state) { - root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); + root[ESPHOME_F("state")] = value_accuracy_to_string( + (obj->target_temperature_high + obj->target_temperature_low) / 2.0f, target_accuracy); } } else { - root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + root[ESPHOME_F("target_temperature")] = value_accuracy_to_string(obj->target_temperature, target_accuracy); if (!has_state) - root["state"] = root["target_temperature"]; + root[ESPHOME_F("state")] = root[ESPHOME_F("target_temperature")]; } return builder.serialize(); @@ -1566,10 +1568,10 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); char buf[PSTR_LOCAL_SIZE]; - root["current_operation"] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[ESPHOME_F("position")] = obj->position; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1701,14 +1703,14 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root["event_type"] = event_type; + root[ESPHOME_F("event_type")] = event_type; } if (start_config == DETAIL_ALL) { - JsonArray event_types = root["event_types"].to(); + JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { event_types.add(event_type); } - root["device_class"] = obj->get_device_class_ref(); + root[ESPHOME_F("device_class")] = obj->get_device_class_ref(); this->add_sorting_info_(root, obj); } @@ -1774,10 +1776,10 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { - root["current_version"] = obj->update_info.current_version; - root["title"] = obj->update_info.title; - root["summary"] = obj->update_info.summary; - root["release_url"] = obj->update_info.release_url; + root[ESPHOME_F("current_version")] = obj->update_info.current_version; + root[ESPHOME_F("title")] = obj->update_info.title; + root[ESPHOME_F("summary")] = obj->update_info.summary; + root[ESPHOME_F("release_url")] = obj->update_info.release_url; this->add_sorting_info_(root, obj); } @@ -2063,9 +2065,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { - root["sorting_weight"] = this->sorting_entitys_[entity].weight; + root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { - root["sorting_group"] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } #endif diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index fbf0d00c06..54ec997671 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -6,20 +6,7 @@ #include #include "esphome/core/component.h" - -// Platform-agnostic macros for web server components -// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) -// On ESP8266: Use Arduino's F() macro for PROGMEM strings -#ifdef USE_ESP32 -#define ESPHOME_F(string_literal) (string_literal) -#define ESPHOME_PGM_P const char * -#define ESPHOME_strncpy_P strncpy -#else -// ESP8266 uses Arduino macros -#define ESPHOME_F(string_literal) F(string_literal) -#define ESPHOME_PGM_P PGM_P -#define ESPHOME_strncpy_P strncpy_P -#endif +#include "esphome/core/progmem.h" #if USE_ESP32 #include "esphome/core/hal.h" diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h new file mode 100644 index 0000000000..67131fd113 --- /dev/null +++ b/esphome/core/progmem.h @@ -0,0 +1,16 @@ +#pragma once + +// Platform-agnostic macros for PROGMEM string handling +// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) +// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings + +#ifdef USE_ESP32 +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy +#else +// ESP8266 and other Arduino platforms use Arduino macros +#define ESPHOME_F(string_literal) F(string_literal) +#define ESPHOME_PGM_P PGM_P +#define ESPHOME_strncpy_P strncpy_P +#endif From 2f75962b19ed472604b1b4f938f8e70f5a879ea0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:40:46 -0500 Subject: [PATCH 225/320] [analog_threshold] Fix oscillation when using invert filter (#12251) Co-authored-by: Claude --- .../analog_threshold_binary_sensor.cpp | 11 +++++++---- .../analog_threshold/analog_threshold_binary_sensor.h | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp index f83f2aff08..0b3bd0e472 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp @@ -12,10 +12,11 @@ void AnalogThresholdBinarySensor::setup() { // TRUE state is defined to be when sensor is >= threshold // so when undefined sensor value initialize to FALSE if (std::isnan(sensor_value)) { + this->raw_state_ = false; this->publish_initial_state(false); } else { - this->publish_initial_state(sensor_value >= - (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f); + this->raw_state_ = sensor_value >= (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f; + this->publish_initial_state(this->raw_state_); } } @@ -25,8 +26,10 @@ void AnalogThresholdBinarySensor::set_sensor(sensor::Sensor *analog_sensor) { this->sensor_->add_on_state_callback([this](float sensor_value) { // if there is an invalid sensor reading, ignore the change and keep the current state if (!std::isnan(sensor_value)) { - this->publish_state(sensor_value >= - (this->state ? this->lower_threshold_.value() : this->upper_threshold_.value())); + // Use raw_state_ for hysteresis logic, not this->state which is post-filter + this->raw_state_ = + sensor_value >= (this->raw_state_ ? this->lower_threshold_.value() : this->upper_threshold_.value()); + this->publish_state(this->raw_state_); } }); } diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index 55d6b15c36..9ea95d8570 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -20,6 +20,7 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina sensor::Sensor *sensor_{nullptr}; TemplatableValue upper_threshold_{}; TemplatableValue lower_threshold_{}; + bool raw_state_{false}; // Pre-filter state for hysteresis logic }; } // namespace analog_threshold From 708496c10116dae0e6268983bcf368da7e6ad09e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:45:38 -0600 Subject: [PATCH 226/320] Bump actions/checkout from 6.0.0 to 6.0.1 (#12259) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-clang-tidy-hash.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 2 +- .github/workflows/ci.yml | 30 +++++++++---------- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 8 ++--- .github/workflows/sync-device-classes.yml | 4 +-- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index d09072d814..39164fc2ea 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,7 +22,7 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate a token id: generate-token diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2bee5ed211..a0c6568345 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 1826ed27cf..94068c19d6 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index c76d9cf2a5..bf7fa0c262 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 6ca58e252e..7e81e1184d 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cfc02d5cf..9ef6b4341c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -132,7 +132,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -183,7 +183,7 @@ jobs: component-test-batches: ${{ steps.determine.outputs.component-test-batches }} steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -237,7 +237,7 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python 3.13 id: python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 @@ -273,7 +273,7 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -321,7 +321,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -400,7 +400,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -489,7 +489,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -577,7 +577,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -662,7 +662,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') steps: - name: Check out code from GitHub - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -688,7 +688,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.base_ref }} @@ -840,7 +840,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -908,7 +908,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33f587a748..d9b6bcdcca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ff810d869..d52595bbb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download digests uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index baaa29df2c..ea81a1e013 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Checkout Home Assistant - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: repository: home-assistant/core path: lib/home-assistant From ab60ae092d095277e6f360f401e8e4b4057d2f96 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Tue, 2 Dec 2025 23:17:24 +0100 Subject: [PATCH 227/320] [tests] Allow substitution tests to run independently for debugging (#12224) Co-authored-by: J. Nick Koston --- tests/unit_tests/test_substitutions.py | 119 ++++++++++++------------- 1 file changed, 59 insertions(+), 60 deletions(-) diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index c5e6618ea6..eb9ef5443c 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -2,13 +2,16 @@ import glob import logging from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import pytest from esphome import config as config_module, yaml_util from esphome.components import substitutions +from esphome.components.packages import do_packages_pass from esphome.config import resolve_extend_remove from esphome.config_helpers import merge_config -from esphome.const import CONF_PACKAGES, CONF_SUBSTITUTIONS +from esphome.const import CONF_SUBSTITUTIONS from esphome.core import CORE from esphome.util import OrderedDict @@ -91,13 +94,22 @@ REMOTES = { ("https://github.com/esphome/repo2", "main"): "remotes/repo2/main", } +# Collect all input YAML files for test_substitutions_fixtures parametrized tests: +HERE = Path(__file__).parent +BASE_DIR = HERE / "fixtures" / "substitutions" +SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml"))) +assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}" + +@pytest.mark.parametrize( + "source_path", + [Path(p) for p in SOURCES], + ids=lambda p: p.name, +) @patch("esphome.git.clone_or_update") -def test_substitutions_fixtures(mock_clone_or_update, fixture_path): - base_dir = fixture_path / "substitutions" - sources = sorted(glob.glob(str(base_dir / "*.input.yaml"))) - assert sources, f"No input YAML files found in {base_dir}" - +def test_substitutions_fixtures( + mock_clone_or_update: MagicMock, source_path: Path +) -> None: def fake_clone_or_update( *, url: str, @@ -116,72 +128,59 @@ def test_substitutions_fixtures(mock_clone_or_update, fixture_path): raise RuntimeError( f"Cannot find test repository for {url} @ {ref}. Check the REMOTES mapping in test_substitutions.py" ) - return base_dir / path, None + return BASE_DIR / path, None mock_clone_or_update.side_effect = fake_clone_or_update - failures = [] - for source_path in sources: - source_path = Path(source_path) - try: - expected_path = source_path.with_suffix("").with_suffix(".approved.yaml") - test_case = source_path.with_suffix("").stem + expected_path = source_path.with_suffix("").with_suffix(".approved.yaml") + test_case = source_path.with_suffix("").stem - # Load using ESPHome's YAML loader - config = yaml_util.load_yaml(source_path) + # Load using ESPHome's YAML loader + config = yaml_util.load_yaml(source_path) - if CONF_PACKAGES in config: - from esphome.components.packages import do_packages_pass + config = do_packages_pass(config) - config = do_packages_pass(config) + substitutions.do_substitution_pass(config, None) - substitutions.do_substitution_pass(config, None) + resolve_extend_remove(config) + verify_database_result = verify_database(config) + if verify_database_result is not None: + raise AssertionError(verify_database_result) - resolve_extend_remove(config) - verify_database_result = verify_database(config) - if verify_database_result is not None: - raise AssertionError(verify_database_result) + # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE + if expected_path.is_file(): + expected = yaml_util.load_yaml(expected_path) + elif DEV_MODE: + expected = {} + else: + assert expected_path.is_file(), f"Expected file missing: {expected_path}" - # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE - if expected_path.is_file(): - expected = yaml_util.load_yaml(expected_path) - elif DEV_MODE: - expected = {} - else: - assert expected_path.is_file(), ( - f"Expected file missing: {expected_path}" - ) + # Sort dicts only (not lists) for comparison + got_sorted = sort_dicts(config) + expected_sorted = sort_dicts(expected) - # Sort dicts only (not lists) for comparison - got_sorted = sort_dicts(config) - expected_sorted = sort_dicts(expected) - - if got_sorted != expected_sorted: - diff = "\n".join(dict_diff(got_sorted, expected_sorted)) - msg = ( - f"Substitution result mismatch for {source_path.name}\n" - f"Diff:\n{diff}\n\n" - f"Got: {got_sorted}\n" - f"Expected: {expected_sorted}" - ) - # Write out the received file when test fails - if DEV_MODE: - received_path = source_path.with_name(f"{test_case}.received.yaml") - write_yaml(received_path, config) - print(msg) - failures.append(msg) - else: - raise AssertionError(msg) - except Exception as err: - _LOGGER.error("Error in test file %s", source_path) - raise err - - if DEV_MODE and failures: - print(f"\n{len(failures)} substitution test case(s) failed.") + if got_sorted != expected_sorted: + diff = "\n".join(dict_diff(got_sorted, expected_sorted)) + msg = ( + f"Substitution result mismatch for {source_path.name}\n" + f"Diff:\n{diff}\n\n" + f"Got: {got_sorted}\n" + f"Expected: {expected_sorted}" + ) + # Write out the received file when test fails + if DEV_MODE: + received_path = source_path.with_name(f"{test_case}.received.yaml") + write_yaml(received_path, config) + msg += f"\nWrote received file to {received_path}." + raise AssertionError(msg) if DEV_MODE: _LOGGER.error("Tests passed, but Dev mode is enabled.") - assert not DEV_MODE # make sure DEV_MODE is disabled after you are finished. + assert ( + not DEV_MODE # make sure DEV_MODE is disabled after you are finished. + ), ( + "Test passed but DEV_MODE must be disabled when running tests. Please set DEV_MODE=False." + ) def test_substitutions_with_command_line_maintains_ordered_dict() -> None: From 6f91c75f8605af0e0be3770755e788e94cdb2825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=2E=20=C3=81rkosi=20R=C3=B3bert?= Date: Wed, 3 Dec 2025 10:20:17 +0100 Subject: [PATCH 228/320] [gree] `turbo`, `light`, `health`, `xfan` switches (#12160) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/gree/__init__.py | 3 + esphome/components/gree/climate.py | 6 +- esphome/components/gree/gree.cpp | 26 ++++- esphome/components/gree/gree.h | 102 +++++++++--------- esphome/components/gree/switch/__init__.py | 74 +++++++++++++ .../components/gree/switch/gree_switch.cpp | 24 +++++ esphome/components/gree/switch/gree_switch.h | 24 +++++ tests/components/gree/common.yaml | 15 ++- 9 files changed, 218 insertions(+), 57 deletions(-) create mode 100644 esphome/components/gree/switch/__init__.py create mode 100644 esphome/components/gree/switch/gree_switch.cpp create mode 100644 esphome/components/gree/switch/gree_switch.h diff --git a/CODEOWNERS b/CODEOWNERS index 7861871323..dbeeb56f8f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -190,6 +190,7 @@ esphome/components/gps/* @coogle @ximex esphome/components/graph/* @synco esphome/components/graphical_display_menu/* @MrMDavidson esphome/components/gree/* @orestismers +esphome/components/gree/switch/* @nagyrobi esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_tb6612fng/* @max246 esphome/components/growatt_solar/* @leeuwte diff --git a/esphome/components/gree/__init__.py b/esphome/components/gree/__init__.py index e69de29bb2..2dd9ac0f1c 100644 --- a/esphome/components/gree/__init__.py +++ b/esphome/components/gree/__init__.py @@ -0,0 +1,3 @@ +import esphome.codegen as cg + +gree_ns = cg.esphome_ns.namespace("gree") diff --git a/esphome/components/gree/climate.py b/esphome/components/gree/climate.py index 057ba67b94..0892155fd2 100644 --- a/esphome/components/gree/climate.py +++ b/esphome/components/gree/climate.py @@ -3,11 +3,11 @@ from esphome.components import climate_ir import esphome.config_validation as cv from esphome.const import CONF_MODEL +from . import gree_ns + CODEOWNERS = ["@orestismers"] AUTO_LOAD = ["climate_ir"] - -gree_ns = cg.esphome_ns.namespace("gree") GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR) Model = gree_ns.enum("Model") @@ -23,7 +23,7 @@ MODELS = { CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend( { - cv.Required(CONF_MODEL): cv.enum(MODELS), + cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True), } ) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index e0cacb4f1e..b8cf8a39a8 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -16,13 +16,28 @@ void GreeClimate::set_model(Model model) { this->model_ = model; } +void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) { + if (enabled) { + this->mode_bits_ |= bit_mask; + } else { + this->mode_bits_ &= ~bit_mask; + } + this->transmit_state(); +} + void GreeClimate::transmit_state() { uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00}; remote_state[0] = this->fan_speed_() | this->operation_mode_(); remote_state[1] = this->temperature_(); - if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { + if (this->model_ == GREE_YAN) { + remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN + remote_state[3] = 0x50; // bits 4..7 always 0101 + remote_state[4] = this->vertical_swing_(); + } + + if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { remote_state[2] = 0x60; remote_state[3] = 0x50; remote_state[4] = this->vertical_swing_(); @@ -41,7 +56,7 @@ void GreeClimate::transmit_state() { } if (this->model_ == GREE_YAA || this->model_ == GREE_YAC || this->model_ == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO,LIGHT,HEALTH,X-FAN + remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 @@ -52,6 +67,13 @@ void GreeClimate::transmit_state() { } } + if (this->model_ == GREE_YAN || this->model_ == GREE_YAA || this->model_ == GREE_YAC || + this->model_ == GREE_YAC1FB9) { + // Merge the mode bits into remote_state[2] + // Clear the mode bits (bits 4-7) and OR in the current mode_bits_ + remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_; + } + if (this->model_ == GREE_YX1FF) { if (this->fan_speed_() == GREE_FAN_TURBO) { remote_state[2] |= GREE_FAN_TURBO_BIT; diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index f91d78cabd..24453750ae 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -2,80 +2,79 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace gree { +namespace esphome::gree { // Values for GREE IR Controllers // Temperature -const uint8_t GREE_TEMP_MIN = 16; // Celsius -const uint8_t GREE_TEMP_MAX = 30; // Celsius +static constexpr uint8_t GREE_TEMP_MIN = 16; // Celsius +static constexpr uint8_t GREE_TEMP_MAX = 30; // Celsius // Modes -const uint8_t GREE_MODE_AUTO = 0x00; -const uint8_t GREE_MODE_COOL = 0x01; -const uint8_t GREE_MODE_HEAT = 0x04; -const uint8_t GREE_MODE_DRY = 0x02; -const uint8_t GREE_MODE_FAN = 0x03; +static constexpr uint8_t GREE_MODE_AUTO = 0x00; +static constexpr uint8_t GREE_MODE_COOL = 0x01; +static constexpr uint8_t GREE_MODE_HEAT = 0x04; +static constexpr uint8_t GREE_MODE_DRY = 0x02; +static constexpr uint8_t GREE_MODE_FAN = 0x03; -const uint8_t GREE_MODE_OFF = 0x00; -const uint8_t GREE_MODE_ON = 0x08; +static constexpr uint8_t GREE_MODE_OFF = 0x00; +static constexpr uint8_t GREE_MODE_ON = 0x08; // Fan Speed -const uint8_t GREE_FAN_AUTO = 0x00; -const uint8_t GREE_FAN_1 = 0x10; -const uint8_t GREE_FAN_2 = 0x20; -const uint8_t GREE_FAN_3 = 0x30; +static constexpr uint8_t GREE_FAN_AUTO = 0x00; +static constexpr uint8_t GREE_FAN_1 = 0x10; +static constexpr uint8_t GREE_FAN_2 = 0x20; +static constexpr uint8_t GREE_FAN_3 = 0x30; // IR Transmission -const uint32_t GREE_IR_FREQUENCY = 38000; -const uint32_t GREE_HEADER_MARK = 9000; -const uint32_t GREE_HEADER_SPACE = 4000; -const uint32_t GREE_BIT_MARK = 620; -const uint32_t GREE_ONE_SPACE = 1600; -const uint32_t GREE_ZERO_SPACE = 540; -const uint32_t GREE_MESSAGE_SPACE = 19000; +static constexpr uint32_t GREE_IR_FREQUENCY = 38000; +static constexpr uint32_t GREE_HEADER_MARK = 9000; +static constexpr uint32_t GREE_HEADER_SPACE = 4000; +static constexpr uint32_t GREE_BIT_MARK = 620; +static constexpr uint32_t GREE_ONE_SPACE = 1600; +static constexpr uint32_t GREE_ZERO_SPACE = 540; +static constexpr uint32_t GREE_MESSAGE_SPACE = 19000; // Timing specific for YAC features (I-Feel mode) -const uint32_t GREE_YAC_HEADER_MARK = 6000; -const uint32_t GREE_YAC_HEADER_SPACE = 3000; -const uint32_t GREE_YAC_BIT_MARK = 650; +static constexpr uint32_t GREE_YAC_HEADER_MARK = 6000; +static constexpr uint32_t GREE_YAC_HEADER_SPACE = 3000; +static constexpr uint32_t GREE_YAC_BIT_MARK = 650; // Timing specific to YAC1FB9 -const uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500; -const uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980; +static constexpr uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500; +static constexpr uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980; // State Frame size -const uint8_t GREE_STATE_FRAME_SIZE = 8; +static constexpr uint8_t GREE_STATE_FRAME_SIZE = 8; // Only available on YAN // Vertical air directions. Note that these cannot be set on all heat pumps -const uint8_t GREE_VDIR_AUTO = 0x00; -const uint8_t GREE_VDIR_MANUAL = 0x00; -const uint8_t GREE_VDIR_SWING = 0x01; -const uint8_t GREE_VDIR_UP = 0x02; -const uint8_t GREE_VDIR_MUP = 0x03; -const uint8_t GREE_VDIR_MIDDLE = 0x04; -const uint8_t GREE_VDIR_MDOWN = 0x05; -const uint8_t GREE_VDIR_DOWN = 0x06; +static constexpr uint8_t GREE_VDIR_AUTO = 0x00; +static constexpr uint8_t GREE_VDIR_MANUAL = 0x00; +static constexpr uint8_t GREE_VDIR_SWING = 0x01; +static constexpr uint8_t GREE_VDIR_UP = 0x02; +static constexpr uint8_t GREE_VDIR_MUP = 0x03; +static constexpr uint8_t GREE_VDIR_MIDDLE = 0x04; +static constexpr uint8_t GREE_VDIR_MDOWN = 0x05; +static constexpr uint8_t GREE_VDIR_DOWN = 0x06; // Only available on YAC/YAG // Horizontal air directions. Note that these cannot be set on all heat pumps -const uint8_t GREE_HDIR_AUTO = 0x00; -const uint8_t GREE_HDIR_MANUAL = 0x00; -const uint8_t GREE_HDIR_SWING = 0x01; -const uint8_t GREE_HDIR_LEFT = 0x02; -const uint8_t GREE_HDIR_MLEFT = 0x03; -const uint8_t GREE_HDIR_MIDDLE = 0x04; -const uint8_t GREE_HDIR_MRIGHT = 0x05; -const uint8_t GREE_HDIR_RIGHT = 0x06; +static constexpr uint8_t GREE_HDIR_AUTO = 0x00; +static constexpr uint8_t GREE_HDIR_MANUAL = 0x00; +static constexpr uint8_t GREE_HDIR_SWING = 0x01; +static constexpr uint8_t GREE_HDIR_LEFT = 0x02; +static constexpr uint8_t GREE_HDIR_MLEFT = 0x03; +static constexpr uint8_t GREE_HDIR_MIDDLE = 0x04; +static constexpr uint8_t GREE_HDIR_MRIGHT = 0x05; +static constexpr uint8_t GREE_HDIR_RIGHT = 0x06; // Only available on YX1FF // Turbo (high) fan mode + sleep preset mode -const uint8_t GREE_FAN_TURBO = 0x80; -const uint8_t GREE_FAN_TURBO_BIT = 0x10; -const uint8_t GREE_PRESET_NONE = 0x00; -const uint8_t GREE_PRESET_SLEEP = 0x01; -const uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +static constexpr uint8_t GREE_FAN_TURBO = 0x80; +static constexpr uint8_t GREE_FAN_TURBO_BIT = 0x10; +static constexpr uint8_t GREE_PRESET_NONE = 0x00; +static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; +static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -90,6 +89,7 @@ class GreeClimate : public climate_ir::ClimateIR { climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} void set_model(Model model); + void set_mode_bit(uint8_t bit_mask, bool enabled); protected: // Transmit via IR the state of this climate controller. @@ -103,7 +103,7 @@ class GreeClimate : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; + uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] }; -} // namespace gree -} // namespace esphome +} // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py new file mode 100644 index 0000000000..111fea65d2 --- /dev/null +++ b/esphome/components/gree/switch/__init__.py @@ -0,0 +1,74 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_LIGHT, DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv + +from .. import gree_ns +from ..climate import CONF_MODEL, GreeClimate + +CODEOWNERS = ["@nagyrobi"] + +GreeModeBitSwitch = gree_ns.class_("GreeModeBitSwitch", switch.Switch, cg.Component) + +CONF_TURBO = "turbo" +CONF_HEALTH = "health" +CONF_XFAN = "xfan" +CONF_GREE_ID = "gree_id" + +# Switch configurations: (config_key, display_name, bit_mask, icon) +SWITCH_CONFIGS = ( + (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), + (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), + (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), +) + +SUPPORTED_MODELS = { + "yan", + "yaa", + "yac", + "yac1fb9", +} + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_GREE_ID): cv.use_id(GreeClimate), + **{ + cv.Optional(key): switch.switch_schema( + GreeModeBitSwitch, + icon=icon, + default_restore_mode="RESTORE_DEFAULT_OFF", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ) + for key, _, _, icon in SWITCH_CONFIGS + }, + } +) + + +def _validate_model(config): + full_config = fv.full_config.get() + climate_path = full_config.get_path_for_id(config[CONF_GREE_ID])[:-1] + climate_conf = full_config.get_config_for_path(climate_path) + if climate_conf[CONF_MODEL] not in SUPPORTED_MODELS: + raise cv.Invalid( + "Gree switches are only supported for the " + + ", ".join(SUPPORTED_MODELS) + + " models" + ) + + +FINAL_VALIDATE_SCHEMA = _validate_model + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_GREE_ID]) + + for conf_key, name, bit_mask, _ in SWITCH_CONFIGS: + if switch_conf := config.get(conf_key): + sw = cg.new_Pvariable(switch_conf[cv.CONF_ID], name, bit_mask) + await switch.register_switch(sw, switch_conf) + await cg.register_component(sw, switch_conf) + await cg.register_parented(sw, parent) diff --git a/esphome/components/gree/switch/gree_switch.cpp b/esphome/components/gree/switch/gree_switch.cpp new file mode 100644 index 0000000000..13f14e5453 --- /dev/null +++ b/esphome/components/gree/switch/gree_switch.cpp @@ -0,0 +1,24 @@ +#include "gree_switch.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace gree { + +static const char *const TAG = "gree.switch"; + +void GreeModeBitSwitch::setup() { + auto initial = this->get_initial_state_with_restore_mode(); + if (initial.has_value()) { + this->write_state(*initial); + } +} + +void GreeModeBitSwitch::dump_config() { log_switch(TAG, " ", this->name_, this); } + +void GreeModeBitSwitch::write_state(bool state) { + this->parent_->set_mode_bit(this->bit_mask_, state); + this->publish_state(state); +} + +} // namespace gree +} // namespace esphome diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h new file mode 100644 index 0000000000..239ac4bf17 --- /dev/null +++ b/esphome/components/gree/switch/gree_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/gree/gree.h" + +namespace esphome { +namespace gree { + +class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { + public: + GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {} + + void setup() override; + void dump_config() override; + void write_state(bool state) override; + + protected: + const char *name_; + uint8_t bit_mask_; +}; + +} // namespace gree +} // namespace esphome diff --git a/tests/components/gree/common.yaml b/tests/components/gree/common.yaml index e706076034..1ddce781bb 100644 --- a/tests/components/gree/common.yaml +++ b/tests/components/gree/common.yaml @@ -1,5 +1,18 @@ climate: - platform: gree name: GREE - model: generic + id: my_gree_ac + model: YAN transmitter_id: xmitr + +switch: + - platform: gree + gree_id: my_gree_ac + light: + name: "AC Lights" + turbo: + name: "AC Turbo" + health: + name: "AC Health" + xfan: + name: "AC X-Fan" From 669bcad4584f5f06b817ee53fbe91624232f26e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:31:12 -0500 Subject: [PATCH 229/320] [rtl87xx] Fix FreeRTOS version for RTL8720C boards (#12261) Co-authored-by: Claude --- esphome/components/rtl87xx/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index d24ffcea3d..8f27544108 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -10,7 +10,9 @@ import esphome.codegen as cg from esphome.components import libretiny from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, + FAMILY_RTL8710B, KEY_COMPONENT_DATA, + KEY_FAMILY, KEY_LIBRETINY, LibreTinyComponent, ) @@ -48,7 +50,9 @@ CONFIG_SCHEMA.prepend_extra(_set_core_data) async def to_code(config): # Use FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake required by AsyncTCP 3.4.3+ # https://github.com/esphome/esphome/issues/10220 - cg.add_platformio_option("custom_versions.freertos", "8.2.3") + # Only for RTL8710B (ambz) - RTL8720C (ambz2) requires FreeRTOS 10.x + if CORE.data[KEY_LIBRETINY][KEY_FAMILY] == FAMILY_RTL8710B: + cg.add_platformio_option("custom_versions.freertos", "8.2.3") return await libretiny.component_to_code(config) From 87ac4baf3ace99e1ccba9e773722974aff3eeb78 Mon Sep 17 00:00:00 2001 From: lygris Date: Wed, 3 Dec 2025 09:42:04 -0600 Subject: [PATCH 230/320] [cc1101] Add new cc1101 component (#11849) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/cc1101/__init__.py | 220 +++++++ esphome/components/cc1101/cc1101.cpp | 550 +++++++++++++++++ esphome/components/cc1101/cc1101.h | 110 ++++ esphome/components/cc1101/cc1101defs.h | 644 ++++++++++++++++++++ esphome/components/cc1101/cc1101pa.h | 174 ++++++ tests/components/cc1101/common.yaml | 20 + tests/components/cc1101/test.esp32-idf.yaml | 8 + tests/components/cc1101/test.esp8266.yaml | 8 + 9 files changed, 1735 insertions(+) create mode 100644 esphome/components/cc1101/__init__.py create mode 100644 esphome/components/cc1101/cc1101.cpp create mode 100644 esphome/components/cc1101/cc1101.h create mode 100644 esphome/components/cc1101/cc1101defs.h create mode 100644 esphome/components/cc1101/cc1101pa.h create mode 100644 tests/components/cc1101/common.yaml create mode 100644 tests/components/cc1101/test.esp32-idf.yaml create mode 100644 tests/components/cc1101/test.esp8266.yaml diff --git a/CODEOWNERS b/CODEOWNERS index dbeeb56f8f..65405f79d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -97,6 +97,7 @@ esphome/components/camera_encoder/* @DT-art1 esphome/components/canbus/* @danielschramm @mvturnho esphome/components/cap1188/* @mreditor97 esphome/components/captive_portal/* @esphome/core +esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke esphome/components/ch422g/* @clydebarrow @jesterret diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py new file mode 100644 index 0000000000..0f5743d0cd --- /dev/null +++ b/esphome/components/cc1101/__init__.py @@ -0,0 +1,220 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import spi +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_FREQUENCY, CONF_ID, CONF_WAIT_TIME + +CODEOWNERS = ["@lygris", "@gabest11"] +DEPENDENCIES = ["spi"] +MULTI_CONF = True + +ns = cg.esphome_ns.namespace("cc1101") +CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice) + +# Config keys +CONF_OUTPUT_POWER = "output_power" +CONF_RX_ATTENUATION = "rx_attenuation" +CONF_DC_BLOCKING_FILTER = "dc_blocking_filter" +CONF_IF_FREQUENCY = "if_frequency" +CONF_FILTER_BANDWIDTH = "filter_bandwidth" +CONF_CHANNEL_SPACING = "channel_spacing" +CONF_FSK_DEVIATION = "fsk_deviation" +CONF_MSK_DEVIATION = "msk_deviation" +CONF_SYMBOL_RATE = "symbol_rate" +CONF_SYNC_MODE = "sync_mode" +CONF_CARRIER_SENSE_ABOVE_THRESHOLD = "carrier_sense_above_threshold" +CONF_MODULATION_TYPE = "modulation_type" +CONF_MANCHESTER = "manchester" +CONF_NUM_PREAMBLE = "num_preamble" +CONF_SYNC1 = "sync1" +CONF_SYNC0 = "sync0" +CONF_PKTLEN = "pktlen" +CONF_MAGN_TARGET = "magn_target" +CONF_MAX_LNA_GAIN = "max_lna_gain" +CONF_MAX_DVGA_GAIN = "max_dvga_gain" +CONF_CARRIER_SENSE_ABS_THR = "carrier_sense_abs_thr" +CONF_CARRIER_SENSE_REL_THR = "carrier_sense_rel_thr" +CONF_LNA_PRIORITY = "lna_priority" +CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk" +CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" +CONF_FREEZE = "freeze" +CONF_HYST_LEVEL = "hyst_level" + +# Enums +SyncMode = ns.enum("SyncMode", True) +SYNC_MODE = { + "None": SyncMode.SYNC_MODE_NONE, + "15/16": SyncMode.SYNC_MODE_15_16, + "16/16": SyncMode.SYNC_MODE_16_16, + "30/32": SyncMode.SYNC_MODE_30_32, +} + +Modulation = ns.enum("Modulation", True) +MODULATION = { + "2-FSK": Modulation.MODULATION_2_FSK, + "GFSK": Modulation.MODULATION_GFSK, + "ASK/OOK": Modulation.MODULATION_ASK_OOK, + "4-FSK": Modulation.MODULATION_4_FSK, + "MSK": Modulation.MODULATION_MSK, +} + +RxAttenuation = ns.enum("RxAttenuation", True) +RX_ATTENUATION = { + "0dB": RxAttenuation.RX_ATTENUATION_0DB, + "6dB": RxAttenuation.RX_ATTENUATION_6DB, + "12dB": RxAttenuation.RX_ATTENUATION_12DB, + "18dB": RxAttenuation.RX_ATTENUATION_18DB, +} + +MagnTarget = ns.enum("MagnTarget", True) +MAGN_TARGET = { + "24dB": MagnTarget.MAGN_TARGET_24DB, + "27dB": MagnTarget.MAGN_TARGET_27DB, + "30dB": MagnTarget.MAGN_TARGET_30DB, + "33dB": MagnTarget.MAGN_TARGET_33DB, + "36dB": MagnTarget.MAGN_TARGET_36DB, + "38dB": MagnTarget.MAGN_TARGET_38DB, + "40dB": MagnTarget.MAGN_TARGET_40DB, + "42dB": MagnTarget.MAGN_TARGET_42DB, +} + +MaxLnaGain = ns.enum("MaxLnaGain", True) +MAX_LNA_GAIN = { + "Default": MaxLnaGain.MAX_LNA_GAIN_DEFAULT, + "2.6dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_2P6DB, + "6.1dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_6P1DB, + "7.4dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_7P4DB, + "9.2dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_9P2DB, + "11.5dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_11P5DB, + "14.6dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_14P6DB, + "17.1dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_17P1DB, +} + +MaxDvgaGain = ns.enum("MaxDvgaGain", True) +MAX_DVGA_GAIN = { + "Default": MaxDvgaGain.MAX_DVGA_GAIN_DEFAULT, + "-1": MaxDvgaGain.MAX_DVGA_GAIN_MINUS_1, + "-2": MaxDvgaGain.MAX_DVGA_GAIN_MINUS_2, + "-3": MaxDvgaGain.MAX_DVGA_GAIN_MINUS_3, +} + +CarrierSenseRelThr = ns.enum("CarrierSenseRelThr", True) +CARRIER_SENSE_REL_THR = { + "Default": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_DEFAULT, + "+6dB": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_PLUS_6DB, + "+10dB": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_PLUS_10DB, + "+14dB": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_PLUS_14DB, +} + +FilterLengthFskMsk = ns.enum("FilterLengthFskMsk", True) +FILTER_LENGTH_FSK_MSK = { + "8": FilterLengthFskMsk.FILTER_LENGTH_8DB, + "16": FilterLengthFskMsk.FILTER_LENGTH_16DB, + "32": FilterLengthFskMsk.FILTER_LENGTH_32DB, + "64": FilterLengthFskMsk.FILTER_LENGTH_64DB, +} + +FilterLengthAskOok = ns.enum("FilterLengthAskOok", True) +FILTER_LENGTH_ASK_OOK = { + "4dB": FilterLengthAskOok.FILTER_LENGTH_4DB, + "8dB": FilterLengthAskOok.FILTER_LENGTH_8DB, + "12dB": FilterLengthAskOok.FILTER_LENGTH_12DB, + "16dB": FilterLengthAskOok.FILTER_LENGTH_16DB, +} + +Freeze = ns.enum("Freeze", True) +FREEZE = { + "Default": Freeze.FREEZE_DEFAULT, + "On Sync": Freeze.FREEZE_ON_SYNC, + "Analog Only": Freeze.FREEZE_ANALOG_ONLY, + "Analog And Digital": Freeze.FREEZE_ANALOG_AND_DIGITAL, +} + +WaitTime = ns.enum("WaitTime", True) +WAIT_TIME = { + "8": WaitTime.WAIT_TIME_8_SAMPLES, + "16": WaitTime.WAIT_TIME_16_SAMPLES, + "24": WaitTime.WAIT_TIME_24_SAMPLES, + "32": WaitTime.WAIT_TIME_32_SAMPLES, +} + +HystLevel = ns.enum("HystLevel", True) +HYST_LEVEL = { + "None": HystLevel.HYST_LEVEL_NONE, + "Low": HystLevel.HYST_LEVEL_LOW, + "Medium": HystLevel.HYST_LEVEL_MEDIUM, + "High": HystLevel.HYST_LEVEL_HIGH, +} + +# Config key -> Validator mapping +CONFIG_MAP = { + CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0), + CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False), + CONF_DC_BLOCKING_FILTER: cv.boolean, + CONF_FREQUENCY: cv.float_range(min=300000.0, max=928000.0), + CONF_IF_FREQUENCY: cv.float_range(min=25, max=788), + CONF_FILTER_BANDWIDTH: cv.float_range(min=58.0, max=812.0), + CONF_CHANNEL: cv.uint8_t, + CONF_CHANNEL_SPACING: cv.float_range(min=25, max=405), + CONF_FSK_DEVIATION: cv.float_range(min=1.5, max=381), + CONF_MSK_DEVIATION: cv.int_range(min=1, max=8), + CONF_SYMBOL_RATE: cv.float_range(min=600, max=500000), + CONF_SYNC_MODE: cv.enum(SYNC_MODE, upper=False), + CONF_CARRIER_SENSE_ABOVE_THRESHOLD: cv.boolean, + CONF_MODULATION_TYPE: cv.enum(MODULATION, upper=False), + CONF_MANCHESTER: cv.boolean, + CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7), + CONF_SYNC1: cv.hex_uint8_t, + CONF_SYNC0: cv.hex_uint8_t, + CONF_PKTLEN: cv.uint8_t, + CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False), + CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False), + CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False), + CONF_CARRIER_SENSE_ABS_THR: cv.int_range(min=-8, max=7), + CONF_CARRIER_SENSE_REL_THR: cv.enum(CARRIER_SENSE_REL_THR, upper=False), + CONF_LNA_PRIORITY: cv.boolean, + CONF_FILTER_LENGTH_FSK_MSK: cv.enum(FILTER_LENGTH_FSK_MSK, upper=False), + CONF_FILTER_LENGTH_ASK_OOK: cv.enum(FILTER_LENGTH_ASK_OOK, upper=False), + CONF_FREEZE: cv.enum(FREEZE, upper=False), + CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False), + CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False), +} + +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(CC1101Component)}) + .extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()}) + .extend(cv.COMPONENT_SCHEMA) + .extend(spi.spi_device_schema(cs_pin_required=True)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await spi.register_spi_device(var, config) + + for key in CONFIG_MAP: + if key in config: + cg.add(getattr(var, f"set_{key}")(config[key])) + + +# Actions +BeginTxAction = ns.class_("BeginTxAction", automation.Action) +BeginRxAction = ns.class_("BeginRxAction", automation.Action) +ResetAction = ns.class_("ResetAction", automation.Action) +SetIdleAction = ns.class_("SetIdleAction", automation.Action) + +CC1101_ACTION_SCHEMA = cv.Schema( + maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)}) +) + + +@automation.register_action("cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA) +@automation.register_action("cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA) +@automation.register_action("cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA) +@automation.register_action("cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA) +async def cc1101_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp new file mode 100644 index 0000000000..1a758e415a --- /dev/null +++ b/esphome/components/cc1101/cc1101.cpp @@ -0,0 +1,550 @@ +#include "cc1101.h" +#include "cc1101pa.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::cc1101 { + +static const char *const TAG = "cc1101"; + +static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) { + int e_tmp; + float m_tmp = std::frexp(value, &e_tmp); + if (e_tmp <= mbits) { + e = 0; + m = 0; + return; + } + e = static_cast(e_tmp - mbits - 1); + m = static_cast(((m_tmp * 2 - 1) * (1 << (mbits + 1))) + 1) >> 1; + if (m == (1UL << mbits)) { + e = e + 1; + m = 0; + } +} + +CC1101Component::CC1101Component() { + // Datasheet defaults + memset(&this->state_, 0, sizeof(this->state_)); + this->state_.GDO2_CFG = 0x0D; // Serial Data (for RX on GDO2) + this->state_.GDO1_CFG = 0x2E; + this->state_.GDO0_CFG = 0x0D; // Serial Data (for RX on GDO0 / TX Input) + this->state_.FIFO_THR = 7; + this->state_.SYNC1 = 0xD3; + this->state_.SYNC0 = 0x91; + this->state_.PKTLEN = 0xFF; + this->state_.APPEND_STATUS = 1; + this->state_.LENGTH_CONFIG = 1; + this->state_.CRC_EN = 1; + this->state_.WHITE_DATA = 1; + this->state_.FREQ_IF = 0x0F; + this->state_.FREQ2 = 0x1E; + this->state_.FREQ1 = 0xC4; + this->state_.FREQ0 = 0xEC; + this->state_.DRATE_E = 0x0C; + this->state_.CHANBW_E = 0x02; + this->state_.DRATE_M = 0x22; + this->state_.SYNC_MODE = 2; + this->state_.CHANSPC_E = 2; + this->state_.NUM_PREAMBLE = 2; + this->state_.CHANSPC_M = 0xF8; + this->state_.DEVIATION_M = 7; + this->state_.DEVIATION_E = 4; + this->state_.RX_TIME = 7; + this->state_.CCA_MODE = 3; + this->state_.PO_TIMEOUT = 1; + this->state_.FOC_LIMIT = 2; + this->state_.FOC_POST_K = 1; + this->state_.FOC_PRE_K = 2; + this->state_.FOC_BS_CS_GATE = 1; + this->state_.BS_POST_KP = 1; + this->state_.BS_POST_KI = 1; + this->state_.BS_PRE_KP = 2; + this->state_.BS_PRE_KI = 1; + this->state_.MAGN_TARGET = 3; + this->state_.AGC_LNA_PRIORITY = 1; + this->state_.FILTER_LENGTH = 1; + this->state_.WAIT_TIME = 1; + this->state_.HYST_LEVEL = 2; + this->state_.WOREVT1 = 0x87; + this->state_.WOREVT0 = 0x6B; + this->state_.RC_CAL = 1; + this->state_.EVENT1 = 7; + this->state_.RC_PD = 1; + this->state_.MIX_CURRENT = 2; + this->state_.LODIV_BUF_CURRENT_RX = 1; + this->state_.LNA2MIX_CURRENT = 1; + this->state_.LNA_CURRENT = 1; + this->state_.LODIV_BUF_CURRENT_TX = 1; + this->state_.FSCAL3_LO = 9; + this->state_.CHP_CURR_CAL_EN = 2; + this->state_.FSCAL3_HI = 2; + this->state_.FSCAL2 = 0x0A; + this->state_.FSCAL1 = 0x20; + this->state_.FSCAL0 = 0x0D; + this->state_.RCCTRL1 = 0x41; + this->state_.FSTEST = 0x59; + this->state_.PTEST = 0x7F; + this->state_.AGCTEST = 0x3F; + this->state_.TEST2 = 0x88; + this->state_.TEST1 = 0x31; + this->state_.TEST0_LO = 1; + this->state_.VCO_SEL_CAL_EN = 1; + this->state_.TEST0_HI = 2; + + // PKTCTRL0 + this->state_.PKT_FORMAT = 3; + this->state_.LENGTH_CONFIG = 2; + this->state_.FS_AUTOCAL = 1; + + // Default Settings + this->set_frequency(433920); + this->set_if_frequency(153); + this->set_filter_bandwidth(203); + this->set_channel(0); + this->set_channel_spacing(200); + this->set_symbol_rate(5000); + this->set_sync_mode(SyncMode::SYNC_MODE_NONE); + this->set_carrier_sense_above_threshold(true); + this->set_modulation_type(Modulation::MODULATION_ASK_OOK); + this->set_magn_target(MagnTarget::MAGN_TARGET_42DB); + this->set_max_lna_gain(MaxLnaGain::MAX_LNA_GAIN_DEFAULT); + this->set_max_dvga_gain(MaxDvgaGain::MAX_DVGA_GAIN_MINUS_3); + this->set_lna_priority(false); + this->set_wait_time(WaitTime::WAIT_TIME_32_SAMPLES); + + // CRITICAL: Initialize PA Table to avoid transmitting 0 power (Silence) + memset(this->pa_table_, 0, sizeof(this->pa_table_)); + this->set_output_power(10.0f); +} + +void CC1101Component::setup() { + this->spi_setup(); + this->cs_->digital_write(true); + delayMicroseconds(1); + this->cs_->digital_write(false); + delayMicroseconds(1); + this->cs_->digital_write(true); + delayMicroseconds(41); + this->cs_->digital_write(false); + delay(5); + + this->strobe_(Command::RES); + delay(5); + + this->read_(Register::PARTNUM); + this->read_(Register::VERSION); + this->chip_id_ = encode_uint16(this->state_.PARTNUM, this->state_.VERSION); + ESP_LOGD(TAG, "CC1101 found! Chip ID: 0x%04X", this->chip_id_); + if (this->state_.VERSION == 0 || this->state_.PARTNUM == 0xFF) { + ESP_LOGE(TAG, "Failed to verify CC1101."); + this->mark_failed(); + return; + } + + this->initialized_ = true; + + for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { + if (i == static_cast(Register::FSTEST) || i == static_cast(Register::AGCTEST)) { + continue; + } + this->write_(static_cast(i)); + } + this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_)); + this->strobe_(Command::RX); +} + +void CC1101Component::dump_config() { + static const char *const MODULATION_NAMES[] = {"2-FSK", "GFSK", "UNUSED", "ASK/OOK", + "4-FSK", "UNUSED", "UNUSED", "MSK"}; + int32_t freq = static_cast(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) * + XTAL_FREQUENCY / (1 << 16); + float symbol_rate = + (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY * 1000.0f; + float bw = XTAL_FREQUENCY / (8.0f * (4 + this->state_.CHANBW_M) * (1 << this->state_.CHANBW_E)); + ESP_LOGCONFIG(TAG, "CC1101:"); + LOG_PIN(" CS Pin: ", this->cs_); + ESP_LOGCONFIG(TAG, + " Chip ID: 0x%04X\n" + " Frequency: %" PRId32 " kHz\n" + " Channel: %u\n" + " Modulation: %s\n" + " Symbol Rate: %.0f baud\n" + " Filter Bandwidth: %.1f kHz\n" + " Output Power: %.1f dBm", + this->chip_id_, freq, this->state_.CHANNR, MODULATION_NAMES[this->state_.MOD_FORMAT & 0x07], + symbol_rate, bw, this->output_power_effective_); +} + +void CC1101Component::begin_tx() { + // Ensure Packet Format is 3 (Async Serial), use GDO0 as input during TX + this->write_(Register::PKTCTRL0, 0x32); + ESP_LOGV(TAG, "Beginning TX sequence"); + this->strobe_(Command::TX); + if (!this->wait_for_state_(State::TX, 50)) { + ESP_LOGW(TAG, "Timed out waiting for TX state!"); + } +} + +void CC1101Component::begin_rx() { + ESP_LOGV(TAG, "Beginning RX sequence"); + this->strobe_(Command::RX); +} + +void CC1101Component::reset() { + this->strobe_(Command::RES); + this->setup(); +} + +void CC1101Component::set_idle() { + ESP_LOGV(TAG, "Setting IDLE state"); + this->enter_idle_(); +} + +void CC1101Component::set_gdo0_config(uint8_t value) { + this->state_.GDO0_CFG = value; + if (this->initialized_) { + this->write_(Register::IOCFG0); + } +} + +void CC1101Component::set_gdo2_config(uint8_t value) { + this->state_.GDO2_CFG = value; + if (this->initialized_) { + this->write_(Register::IOCFG2); + } +} + +bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) { + uint32_t start = millis(); + while (millis() - start < timeout_ms) { + this->read_(Register::MARCSTATE); + State s = static_cast(this->state_.MARC_STATE); + if (s == target_state) { + return true; + } + delayMicroseconds(100); + } + return false; +} + +void CC1101Component::enter_idle_() { + this->strobe_(Command::IDLE); + this->wait_for_state_(State::IDLE); +} + +uint8_t CC1101Component::strobe_(Command cmd) { + uint8_t index = static_cast(cmd); + if (cmd < Command::RES || cmd > Command::NOP) { + return 0xFF; + } + this->enable(); + uint8_t status_byte = this->transfer_byte(index); + this->disable(); + return status_byte; +} + +void CC1101Component::write_(Register reg) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index); + this->write_array(&this->state_.regs()[index], 1); + this->disable(); +} + +void CC1101Component::write_(Register reg, uint8_t value) { + uint8_t index = static_cast(reg); + this->state_.regs()[index] = value; + this->write_(reg); +} + +void CC1101Component::write_(Register reg, const uint8_t *buffer, size_t length) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index | BUS_WRITE | BUS_BURST); + this->write_array(buffer, length); + this->disable(); +} + +void CC1101Component::read_(Register reg) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index | BUS_READ | BUS_BURST); + this->state_.regs()[index] = this->transfer_byte(0); + this->disable(); +} + +void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index | BUS_READ | BUS_BURST); + this->read_array(buffer, length); + this->disable(); +} + +// Setters +void CC1101Component::set_output_power(float value) { + this->output_power_requested_ = value; + int32_t freq = static_cast(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) * + XTAL_FREQUENCY / (1 << 16); + uint8_t a = 0xC0; + if (freq >= 300000 && freq <= 348000) { + a = PowerTableItem::find(PA_TABLE_315, sizeof(PA_TABLE_315) / sizeof(PA_TABLE_315[0]), value); + } else if (freq >= 378000 && freq <= 464000) { + a = PowerTableItem::find(PA_TABLE_433, sizeof(PA_TABLE_433) / sizeof(PA_TABLE_433[0]), value); + } else if (freq >= 779000 && freq < 900000) { + a = PowerTableItem::find(PA_TABLE_868, sizeof(PA_TABLE_868) / sizeof(PA_TABLE_868[0]), value); + } else if (freq >= 900000 && freq <= 928000) { + a = PowerTableItem::find(PA_TABLE_915, sizeof(PA_TABLE_915) / sizeof(PA_TABLE_915[0]), value); + } + + if (static_cast(this->state_.MOD_FORMAT) == Modulation::MODULATION_ASK_OOK) { + this->pa_table_[0] = 0; + this->pa_table_[1] = a; + } else { + this->pa_table_[0] = a; + this->pa_table_[1] = 0; + } + this->output_power_effective_ = value; + if (this->initialized_) { + this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_)); + } +} + +void CC1101Component::set_rx_attenuation(RxAttenuation value) { + this->state_.CLOSE_IN_RX = static_cast(value); + if (this->initialized_) { + this->write_(Register::FIFOTHR); + } +} + +void CC1101Component::set_dc_blocking_filter(bool value) { + this->state_.DEM_DCFILT_OFF = value ? 0 : 1; + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_frequency(float value) { + int32_t freq = static_cast(value * (1 << 16) / XTAL_FREQUENCY); + this->state_.FREQ2 = static_cast(freq >> 16); + this->state_.FREQ1 = static_cast(freq >> 8); + this->state_.FREQ0 = static_cast(freq); + if (this->initialized_) { + this->enter_idle_(); + this->write_(Register::FREQ2); + this->write_(Register::FREQ1); + this->write_(Register::FREQ0); + this->strobe_(Command::RX); + } +} + +void CC1101Component::set_if_frequency(float value) { + this->state_.FREQ_IF = value * (1 << 10) / XTAL_FREQUENCY; + if (this->initialized_) { + this->write_(Register::FSCTRL1); + } +} + +void CC1101Component::set_filter_bandwidth(float value) { + uint8_t e; + uint32_t m; + split_float(XTAL_FREQUENCY / (value * 8), 2, e, m); + this->state_.CHANBW_E = e; + this->state_.CHANBW_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::MDMCFG4); + } +} + +void CC1101Component::set_channel(uint8_t value) { + this->state_.CHANNR = value; + if (this->initialized_) { + this->enter_idle_(); + this->write_(Register::CHANNR); + this->strobe_(Command::RX); + } +} + +void CC1101Component::set_channel_spacing(float value) { + uint8_t e; + uint32_t m; + split_float(value * (1 << 18) / XTAL_FREQUENCY, 8, e, m); + this->state_.CHANSPC_E = e; + this->state_.CHANSPC_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::MDMCFG1); + this->write_(Register::MDMCFG0); + } +} + +void CC1101Component::set_fsk_deviation(float value) { + uint8_t e; + uint32_t m; + split_float(value * (1 << 17) / XTAL_FREQUENCY, 3, e, m); + this->state_.DEVIATION_E = e; + this->state_.DEVIATION_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::DEVIATN); + } +} + +void CC1101Component::set_msk_deviation(uint8_t value) { + this->state_.DEVIATION_E = 0; + this->state_.DEVIATION_M = value - 1; + if (this->initialized_) { + this->write_(Register::DEVIATN); + } +} + +void CC1101Component::set_symbol_rate(float value) { + uint8_t e; + uint32_t m; + split_float(value * (1 << 28) / (XTAL_FREQUENCY * 1000), 8, e, m); + this->state_.DRATE_E = e; + this->state_.DRATE_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::MDMCFG4); + this->write_(Register::MDMCFG3); + } +} + +void CC1101Component::set_sync_mode(SyncMode value) { + this->state_.SYNC_MODE = static_cast(value); + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_carrier_sense_above_threshold(bool value) { + this->state_.CARRIER_SENSE_ABOVE_THRESHOLD = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_modulation_type(Modulation value) { + this->state_.MOD_FORMAT = static_cast(value); + this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0; + if (this->initialized_) { + this->enter_idle_(); + this->write_(Register::MDMCFG2); + this->write_(Register::FREND0); + this->strobe_(Command::RX); + } +} + +void CC1101Component::set_manchester(bool value) { + this->state_.MANCHESTER_EN = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_num_preamble(uint8_t value) { + this->state_.NUM_PREAMBLE = value; + if (this->initialized_) { + this->write_(Register::MDMCFG1); + } +} + +void CC1101Component::set_sync1(uint8_t value) { + this->state_.SYNC1 = value; + if (this->initialized_) { + this->write_(Register::SYNC1); + } +} + +void CC1101Component::set_sync0(uint8_t value) { + this->state_.SYNC0 = value; + if (this->initialized_) { + this->write_(Register::SYNC0); + } +} + +void CC1101Component::set_pktlen(uint8_t value) { + this->state_.PKTLEN = value; + if (this->initialized_) { + this->write_(Register::PKTLEN); + } +} + +void CC1101Component::set_magn_target(MagnTarget value) { + this->state_.MAGN_TARGET = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL2); + } +} + +void CC1101Component::set_max_lna_gain(MaxLnaGain value) { + this->state_.MAX_LNA_GAIN = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL2); + } +} + +void CC1101Component::set_max_dvga_gain(MaxDvgaGain value) { + this->state_.MAX_DVGA_GAIN = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL2); + } +} + +void CC1101Component::set_carrier_sense_abs_thr(int8_t value) { + this->state_.CARRIER_SENSE_ABS_THR = static_cast(value & 0b1111); + if (this->initialized_) { + this->write_(Register::AGCCTRL1); + } +} + +void CC1101Component::set_carrier_sense_rel_thr(CarrierSenseRelThr value) { + this->state_.CARRIER_SENSE_REL_THR = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL1); + } +} + +void CC1101Component::set_lna_priority(bool value) { + this->state_.AGC_LNA_PRIORITY = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::AGCCTRL1); + } +} + +void CC1101Component::set_filter_length_fsk_msk(FilterLengthFskMsk value) { + this->state_.FILTER_LENGTH = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_filter_length_ask_ook(FilterLengthAskOok value) { + this->state_.FILTER_LENGTH = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_freeze(Freeze value) { + this->state_.AGC_FREEZE = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_wait_time(WaitTime value) { + this->state_.WAIT_TIME = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_hyst_level(HystLevel value) { + this->state_.HYST_LEVEL = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +} // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h new file mode 100644 index 0000000000..65aeb2ea82 --- /dev/null +++ b/esphome/components/cc1101/cc1101.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/automation.h" +#include "cc1101defs.h" + +namespace esphome::cc1101 { + +class CC1101Component : public Component, + public spi::SPIDevice { + public: + CC1101Component(); + + void setup() override; + void dump_config() override; + + // Actions + void begin_tx(); + void begin_rx(); + void reset(); + void set_idle(); + + // GDO Pin Configuration + void set_gdo0_config(uint8_t value); + void set_gdo2_config(uint8_t value); + + // Configuration Setters + void set_output_power(float value); + void set_rx_attenuation(RxAttenuation value); + void set_dc_blocking_filter(bool value); + + // Tuner settings + void set_frequency(float value); + void set_if_frequency(float value); + void set_filter_bandwidth(float value); + void set_channel(uint8_t value); + void set_channel_spacing(float value); + void set_fsk_deviation(float value); + void set_msk_deviation(uint8_t value); + void set_symbol_rate(float value); + void set_sync_mode(SyncMode value); + void set_carrier_sense_above_threshold(bool value); + void set_modulation_type(Modulation value); + void set_manchester(bool value); + void set_num_preamble(uint8_t value); + void set_sync1(uint8_t value); + void set_sync0(uint8_t value); + void set_pktlen(uint8_t value); + + // AGC settings + void set_magn_target(MagnTarget value); + void set_max_lna_gain(MaxLnaGain value); + void set_max_dvga_gain(MaxDvgaGain value); + void set_carrier_sense_abs_thr(int8_t value); + void set_carrier_sense_rel_thr(CarrierSenseRelThr value); + void set_lna_priority(bool value); + void set_filter_length_fsk_msk(FilterLengthFskMsk value); + void set_filter_length_ask_ook(FilterLengthAskOok value); + void set_freeze(Freeze value); + void set_wait_time(WaitTime value); + void set_hyst_level(HystLevel value); + + protected: + uint16_t chip_id_{0}; + bool initialized_{false}; + + float output_power_requested_{10.0f}; + float output_power_effective_{10.0f}; + uint8_t pa_table_[PA_TABLE_SIZE]{}; + + CC1101State state_; + + // Low-level Helpers + uint8_t strobe_(Command cmd); + void write_(Register reg); + void write_(Register reg, uint8_t value); + void write_(Register reg, const uint8_t *buffer, size_t length); + void read_(Register reg); + void read_(Register reg, uint8_t *buffer, size_t length); + + // State Management + bool wait_for_state_(State target_state, uint32_t timeout_ms = 100); + void enter_idle_(); +}; + +// Action Wrappers +template class BeginTxAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->begin_tx(); } +}; + +template class BeginRxAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->begin_rx(); } +}; + +template class ResetAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->reset(); } +}; + +template class SetIdleAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_idle(); } +}; + +} // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h new file mode 100644 index 0000000000..52f15cb85a --- /dev/null +++ b/esphome/components/cc1101/cc1101defs.h @@ -0,0 +1,644 @@ +#pragma once + +#include + +namespace esphome::cc1101 { + +static constexpr float XTAL_FREQUENCY = 26000; + +static constexpr uint8_t BUS_BURST = 0x40; +static constexpr uint8_t BUS_READ = 0x80; +static constexpr uint8_t BUS_WRITE = 0x00; +static constexpr uint8_t BYTES_IN_RXFIFO = 0x7F; // byte number in RXfifo +static constexpr size_t PA_TABLE_SIZE = 8; + +enum class Register : uint8_t { + IOCFG2 = 0x00, // GDO2 output pin configuration + IOCFG1 = 0x01, // GDO1 output pin configuration + IOCFG0 = 0x02, // GDO0 output pin configuration + FIFOTHR = 0x03, // RX FIFO and TX FIFO thresholds + SYNC1 = 0x04, // Sync word, high INT8U + SYNC0 = 0x05, // Sync word, low INT8U + PKTLEN = 0x06, // Packet length + PKTCTRL1 = 0x07, // Packet automation control + PKTCTRL0 = 0x08, // Packet automation control + ADDR = 0x09, // Device address + CHANNR = 0x0A, // Channel number + FSCTRL1 = 0x0B, // Frequency synthesizer control + FSCTRL0 = 0x0C, // Frequency synthesizer control + FREQ2 = 0x0D, // Frequency control word, high INT8U + FREQ1 = 0x0E, // Frequency control word, middle INT8U + FREQ0 = 0x0F, // Frequency control word, low INT8U + MDMCFG4 = 0x10, // Modem configuration + MDMCFG3 = 0x11, // Modem configuration + MDMCFG2 = 0x12, // Modem configuration + MDMCFG1 = 0x13, // Modem configuration + MDMCFG0 = 0x14, // Modem configuration + DEVIATN = 0x15, // Modem deviation setting + MCSM2 = 0x16, // Main Radio Control State Machine configuration + MCSM1 = 0x17, // Main Radio Control State Machine configuration + MCSM0 = 0x18, // Main Radio Control State Machine configuration + FOCCFG = 0x19, // Frequency Offset Compensation configuration + BSCFG = 0x1A, // Bit Synchronization configuration + AGCCTRL2 = 0x1B, // AGC control + AGCCTRL1 = 0x1C, // AGC control + AGCCTRL0 = 0x1D, // AGC control + WOREVT1 = 0x1E, // High INT8U Event 0 timeout + WOREVT0 = 0x1F, // Low INT8U Event 0 timeout + WORCTRL = 0x20, // Wake On Radio control + FREND1 = 0x21, // Front end RX configuration + FREND0 = 0x22, // Front end TX configuration + FSCAL3 = 0x23, // Frequency synthesizer calibration + FSCAL2 = 0x24, // Frequency synthesizer calibration + FSCAL1 = 0x25, // Frequency synthesizer calibration + FSCAL0 = 0x26, // Frequency synthesizer calibration + RCCTRL1 = 0x27, // RC oscillator configuration + RCCTRL0 = 0x28, // RC oscillator configuration + FSTEST = 0x29, // Frequency synthesizer calibration control + PTEST = 0x2A, // Production test + AGCTEST = 0x2B, // AGC test + TEST2 = 0x2C, // Various test settings + TEST1 = 0x2D, // Various test settings + TEST0 = 0x2E, // Various test settings + UNUSED = 0x2F, + PARTNUM = 0x30, + VERSION = 0x31, + FREQEST = 0x32, + LQI = 0x33, + RSSI = 0x34, + MARCSTATE = 0x35, + WORTIME1 = 0x36, + WORTIME0 = 0x37, + PKTSTATUS = 0x38, + VCO_VC_DAC = 0x39, + TXBYTES = 0x3A, + RXBYTES = 0x3B, + RCCTRL1_STATUS = 0x3C, + RCCTRL0_STATUS = 0x3D, + PATABLE = 0x3E, + FIFO = 0x3F, +}; + +enum class Command : uint8_t { + RES = 0x30, // Reset chip. + FSTXON = 0x31, // Enable and calibrate frequency synthesizer + XOFF = 0x32, // Turn off crystal oscillator. + CAL = 0x33, // Calibrate frequency synthesizer and turn it off + RX = 0x34, // Enable RX. + TX = 0x35, // Enable TX. + IDLE = 0x36, // Exit RX / TX + // 0x37 is RESERVED / UNDEFINED in CC1101 Datasheet + WOR = 0x38, // Start automatic RX polling sequence (Wake-on-Radio) + PWD = 0x39, // Enter power down mode when CSn goes high. + FRX = 0x3A, // Flush the RX FIFO buffer. + FTX = 0x3B, // Flush the TX FIFO buffer. + WORRST = 0x3C, // Reset real time clock. + NOP = 0x3D, // No operation. +}; + +enum class State : uint8_t { + SLEEP, + IDLE, + XOFF, + VCOON_MC, + REGON_MC, + MANCAL, + VCOON, + REGON, + STARTCAL, + BWBOOST, + FS_LOCK, + IFADCON, + ENDCAL, + RX, + RX_END, + RX_RST, + TXRX_SWITCH, + RXFIFO_OVERFLOW, + FSTXON, + TX, + TX_END, + RXTX_SWITCH, + TXFIFO_UNDERFLOW, +}; + +enum class RxAttenuation : uint8_t { + RX_ATTENUATION_0DB, + RX_ATTENUATION_6DB, + RX_ATTENUATION_12DB, + RX_ATTENUATION_18DB, +}; + +enum class SyncMode : uint8_t { + SYNC_MODE_NONE, + SYNC_MODE_15_16, + SYNC_MODE_16_16, + SYNC_MODE_30_32, +}; + +enum class Modulation : uint8_t { + MODULATION_2_FSK, + MODULATION_GFSK, + MODULATION_UNUSED_2, + MODULATION_ASK_OOK, + MODULATION_4_FSK, + MODULATION_UNUSED_5, + MODULATION_UNUSED_6, + MODULATION_MSK, +}; + +enum class MagnTarget : uint8_t { + MAGN_TARGET_24DB, + MAGN_TARGET_27DB, + MAGN_TARGET_30DB, + MAGN_TARGET_33DB, + MAGN_TARGET_36DB, + MAGN_TARGET_38DB, + MAGN_TARGET_40DB, + MAGN_TARGET_42DB, +}; + +enum class MaxLnaGain : uint8_t { + MAX_LNA_GAIN_DEFAULT, + MAX_LNA_GAIN_MINUS_2P6DB, + MAX_LNA_GAIN_MINUS_6P1DB, + MAX_LNA_GAIN_MINUS_7P4DB, + MAX_LNA_GAIN_MINUS_9P2DB, + MAX_LNA_GAIN_MINUS_11P5DB, + MAX_LNA_GAIN_MINUS_14P6DB, + MAX_LNA_GAIN_MINUS_17P1DB, +}; + +enum class MaxDvgaGain : uint8_t { + MAX_DVGA_GAIN_DEFAULT, + MAX_DVGA_GAIN_MINUS_1, + MAX_DVGA_GAIN_MINUS_2, + MAX_DVGA_GAIN_MINUS_3, +}; + +enum class CarrierSenseRelThr : uint8_t { + CARRIER_SENSE_REL_THR_DEFAULT, + CARRIER_SENSE_REL_THR_PLUS_6DB, + CARRIER_SENSE_REL_THR_PLUS_10DB, + CARRIER_SENSE_REL_THR_PLUS_14DB, +}; + +enum class FilterLengthFskMsk : uint8_t { + FILTER_LENGTH_8DB, + FILTER_LENGTH_16DB, + FILTER_LENGTH_32DB, + FILTER_LENGTH_64DB, +}; + +enum class FilterLengthAskOok : uint8_t { + FILTER_LENGTH_4DB, + FILTER_LENGTH_8DB, + FILTER_LENGTH_12DB, + FILTER_LENGTH_16DB, +}; + +enum class Freeze : uint8_t { + FREEZE_DEFAULT, + FREEZE_ON_SYNC, + FREEZE_ANALOG_ONLY, + FREEZE_ANALOG_AND_DIGITAL, +}; + +enum class WaitTime : uint8_t { + WAIT_TIME_8_SAMPLES, + WAIT_TIME_16_SAMPLES, + WAIT_TIME_24_SAMPLES, + WAIT_TIME_32_SAMPLES, +}; + +enum class HystLevel : uint8_t { + HYST_LEVEL_NONE, + HYST_LEVEL_LOW, + HYST_LEVEL_MEDIUM, + HYST_LEVEL_HIGH, +}; + +struct __attribute__((packed)) CC1101State { + // Byte array accessors for bulk SPI transfers + uint8_t *regs() { return reinterpret_cast(this); } + const uint8_t *regs() const { return reinterpret_cast(this); } + + // 0x00 + union { + uint8_t IOCFG2; + struct { + uint8_t GDO2_CFG : 6; + uint8_t GDO2_INV : 1; + uint8_t : 1; + }; + }; + // 0x01 + union { + uint8_t IOCFG1; + struct { + uint8_t GDO1_CFG : 6; + uint8_t GDO1_INV : 1; + uint8_t GDO_DS : 1; // GDO, not GD0 + }; + }; + // 0x02 + union { + uint8_t IOCFG0; + struct { + uint8_t GDO0_CFG : 6; + uint8_t GDO0_INV : 1; + uint8_t TEMP_SENSOR_ENABLE : 1; + }; + }; + // 0x03 + union { + uint8_t FIFOTHR; + struct { + uint8_t FIFO_THR : 4; + uint8_t CLOSE_IN_RX : 2; // RxAttenuation + uint8_t ADC_RETENTION : 1; + uint8_t : 1; + }; + }; + // 0x04 + uint8_t SYNC1; + // 0x05 + uint8_t SYNC0; + // 0x06 + uint8_t PKTLEN; + // 0x07 + union { + uint8_t PKTCTRL1; + struct { + uint8_t ADR_CHK : 2; + uint8_t APPEND_STATUS : 1; + uint8_t CRC_AUTOFLUSH : 1; + uint8_t : 1; + uint8_t PQT : 3; + }; + }; + // 0x08 + union { + uint8_t PKTCTRL0; + struct { + uint8_t LENGTH_CONFIG : 2; + uint8_t CRC_EN : 1; + uint8_t : 1; + uint8_t PKT_FORMAT : 2; + uint8_t WHITE_DATA : 1; + uint8_t : 1; + }; + }; + // 0x09 + uint8_t ADDR; + // 0x0A + uint8_t CHANNR; + // 0x0B + union { + uint8_t FSCTRL1; + struct { + uint8_t FREQ_IF : 5; + uint8_t RESERVED : 1; // hm? + uint8_t : 2; + }; + }; + // 0x0C + uint8_t FSCTRL0; + // 0x0D + uint8_t FREQ2; // [7:6] always zero + // 0x0E + uint8_t FREQ1; + // 0x0F + uint8_t FREQ0; + // 0x10 + union { + uint8_t MDMCFG4; + struct { + uint8_t DRATE_E : 4; + uint8_t CHANBW_M : 2; + uint8_t CHANBW_E : 2; + }; + }; + // 0x11 + union { + uint8_t MDMCFG3; + struct { + uint8_t DRATE_M : 8; + }; + }; + // 0x12 + union { + uint8_t MDMCFG2; + struct { + uint8_t SYNC_MODE : 2; + uint8_t CARRIER_SENSE_ABOVE_THRESHOLD : 1; + uint8_t MANCHESTER_EN : 1; + uint8_t MOD_FORMAT : 3; // Modulation + uint8_t DEM_DCFILT_OFF : 1; + }; + }; + // 0x13 + union { + uint8_t MDMCFG1; + struct { + uint8_t CHANSPC_E : 2; + uint8_t : 2; + uint8_t NUM_PREAMBLE : 3; + uint8_t FEC_EN : 1; + }; + }; + // 0x14 + union { + uint8_t MDMCFG0; + struct { + uint8_t CHANSPC_M : 8; + }; + }; + // 0x15 + union { + uint8_t DEVIATN; + struct { + uint8_t DEVIATION_M : 3; + uint8_t : 1; + uint8_t DEVIATION_E : 3; + uint8_t : 1; + }; + }; + // 0x16 + union { + uint8_t MCSM2; + struct { + uint8_t RX_TIME : 3; + uint8_t RX_TIME_QUAL : 1; + uint8_t RX_TIME_RSSI : 1; + uint8_t : 3; + }; + }; + // 0x17 + union { + uint8_t MCSM1; + struct { + uint8_t TXOFF_MODE : 2; + uint8_t RXOFF_MODE : 2; + uint8_t CCA_MODE : 2; + uint8_t : 2; + }; + }; + // 0x18 + union { + uint8_t MCSM0; + struct { + uint8_t XOSC_FORCE_ON : 1; + uint8_t PIN_CTRL_EN : 1; + uint8_t PO_TIMEOUT : 2; + uint8_t FS_AUTOCAL : 2; + uint8_t : 2; + }; + }; + // 0x19 + union { + uint8_t FOCCFG; + struct { + uint8_t FOC_LIMIT : 2; + uint8_t FOC_POST_K : 1; + uint8_t FOC_PRE_K : 2; + uint8_t FOC_BS_CS_GATE : 1; + uint8_t : 2; + }; + }; + // 0x1A + union { + uint8_t BSCFG; + struct { + uint8_t BS_LIMIT : 2; + uint8_t BS_POST_KP : 1; + uint8_t BS_POST_KI : 1; + uint8_t BS_PRE_KP : 2; + uint8_t BS_PRE_KI : 2; + }; + }; + // 0x1B + union { + uint8_t AGCCTRL2; + struct { + uint8_t MAGN_TARGET : 3; // MagnTarget + uint8_t MAX_LNA_GAIN : 3; // MaxLnaGain + uint8_t MAX_DVGA_GAIN : 2; // MaxDvgaGain + }; + }; + // 0x1C + union { + uint8_t AGCCTRL1; + struct { + uint8_t CARRIER_SENSE_ABS_THR : 4; + uint8_t CARRIER_SENSE_REL_THR : 2; // CarrierSenseRelThr + uint8_t AGC_LNA_PRIORITY : 1; + uint8_t : 1; + }; + }; + // 0x1D + union { + uint8_t AGCCTRL0; + struct { + uint8_t FILTER_LENGTH : 2; // FilterLengthFskMsk or FilterLengthAskOok + uint8_t AGC_FREEZE : 2; // Freeze + uint8_t WAIT_TIME : 2; // WaitTime + uint8_t HYST_LEVEL : 2; // HystLevel + }; + }; + // 0x1E + uint8_t WOREVT1; + // 0x1F + uint8_t WOREVT0; + // 0x20 + union { + uint8_t WORCTRL; + struct { + uint8_t WOR_RES : 2; + uint8_t : 1; + uint8_t RC_CAL : 1; + uint8_t EVENT1 : 3; + uint8_t RC_PD : 1; + }; + }; + // 0x21 + union { + uint8_t FREND1; + struct { + uint8_t MIX_CURRENT : 2; + uint8_t LODIV_BUF_CURRENT_RX : 2; + uint8_t LNA2MIX_CURRENT : 2; + uint8_t LNA_CURRENT : 2; + }; + }; + // 0x22 + union { + uint8_t FREND0; + struct { + uint8_t PA_POWER : 3; + uint8_t : 1; + uint8_t LODIV_BUF_CURRENT_TX : 2; + uint8_t : 2; + }; + }; + // 0x23 + union { + uint8_t FSCAL3; + struct { + uint8_t FSCAL3_LO : 4; + uint8_t CHP_CURR_CAL_EN : 2; // Disable charge pump calibration stage when 0. + uint8_t FSCAL3_HI : 2; + }; + }; + // 0x24 + union { + // uint8_t FSCAL2; + struct { + uint8_t FSCAL2 : 5; + uint8_t VCO_CORE_H_EN : 1; + uint8_t : 2; + }; + }; + // 0x25 + union { + // uint8_t FSCAL1; + struct { + uint8_t FSCAL1 : 6; + uint8_t : 2; + }; + }; + // 0x26 + union { + // uint8_t FSCAL0; + struct { + uint8_t FSCAL0 : 7; + uint8_t : 1; + }; + }; + // 0x27 + union { + // uint8_t RCCTRL1; + struct { + uint8_t RCCTRL1 : 7; + uint8_t : 1; + }; + }; + // 0x28 + union { + // uint8_t RCCTRL0; + struct { + uint8_t RCCTRL0 : 7; + uint8_t : 1; + }; + }; + // 0x29 + uint8_t FSTEST; + // 0x2A + uint8_t PTEST; + // 0x2B + uint8_t AGCTEST; + // 0x2C + uint8_t TEST2; + // 0x2D + uint8_t TEST1; + // 0x2E + union { + uint8_t TEST0; + struct { + uint8_t TEST0_LO : 1; + uint8_t VCO_SEL_CAL_EN : 1; // Enable VCO selection calibration stage when 1 + uint8_t TEST0_HI : 6; + }; + }; + // 0x2F + uint8_t REG_2F; + // 0x30 + uint8_t PARTNUM; + // 0x31 + uint8_t VERSION; + // 0x32 + union { + uint8_t FREQEST; + struct { + int8_t FREQOFF_EST : 8; + }; + }; + // 0x33 + union { + uint8_t LQI; + struct { + uint8_t LQI_EST : 7; + uint8_t LQI_CRC_OK : 1; + }; + }; + // 0x34 + int8_t RSSI; + // 0x35 + union { + // uint8_t MARCSTATE; + struct { + uint8_t MARC_STATE : 5; // State + uint8_t : 3; + }; + }; + // 0x36 + uint8_t WORTIME1; + // 0x37 + uint8_t WORTIME0; + // 0x38 + union { + uint8_t PKTSTATUS; + struct { + uint8_t GDO0 : 1; + uint8_t : 1; + uint8_t GDO2 : 1; + uint8_t SFD : 1; + uint8_t CCA : 1; + uint8_t PQT_REACHED : 1; + uint8_t CS : 1; + uint8_t CRC_OK : 1; // same as LQI_CRC_OK? + }; + }; + // 0x39 + uint8_t VCO_VC_DAC; + // 0x3A + union { + uint8_t TXBYTES; + struct { + uint8_t NUM_TXBYTES : 7; + uint8_t TXFIFO_UNDERFLOW : 1; + }; + }; + // 0x3B + union { + uint8_t RXBYTES; + struct { + uint8_t NUM_RXBYTES : 7; + uint8_t RXFIFO_OVERFLOW : 1; + }; + }; + // 0x3C + union { + // uint8_t RCCTRL1_STATUS; + struct { + uint8_t RCCTRL1_STATUS : 7; + uint8_t : 1; + }; + }; + // 0x3D + union { + // uint8_t RCCTRL0_STATUS; + struct { + uint8_t RCCTRL0_STATUS : 7; + uint8_t : 1; + }; + }; + // 0x3E + uint8_t REG_3E; + // 0x3F + uint8_t REG_3F; +}; + +static_assert(sizeof(CC1101State) == 0x40, "CC1101State size mismatch"); + +} // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101pa.h b/esphome/components/cc1101/cc1101pa.h new file mode 100644 index 0000000000..e5e7a47c51 --- /dev/null +++ b/esphome/components/cc1101/cc1101pa.h @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include + +namespace esphome::cc1101 { + +// CC1101 Design Note DN013 + +struct PowerTableItem { + uint8_t value; + uint8_t dbm_diff; // starts from 12.0, diff to previous entry, scaled by 10 + + static uint8_t find(const PowerTableItem *items, size_t count, float &dbm_target) { + int32_t dbmi = 120; + int32_t dbmi_target = static_cast(std::lround(dbm_target * 10)); + for (size_t i = 0; i < count; i++) { + dbmi -= items[i].dbm_diff; + if (dbmi_target >= dbmi) { + // Skip invalid PA settings (magic numbers derived from TI DN013/SmartRC logic) + if (items[i].value >= 0x61 && items[i].value <= 0x6F) { + continue; + } + dbm_target = static_cast(dbmi) / 10.0f; + return items[i].value; + } + } + dbm_target = -30.0f; + return 0x03; + } +}; + +static const PowerTableItem PA_TABLE_315[] = { + {0xC0, 14}, // C0 10.6 -35.3 -44.4 -57.8 -53.8 -58.3 -57.2 -57.8 -56.7 28.5 + {0xC3, 10}, // C3 9.6 -39.2 -45.3 -59.0 -54.2 -59.0 -57.5 -58.3 -57.2 26.2 + {0xC6, 11}, // C6 8.5 -43.2 -46.3 -59.2 -54.7 -59.1 -57.7 -58.3 -57.4 24.4 + {0xC9, 10}, // C9 7.5 -47.0 -47.3 -58.9 -55.0 -59.0 -57.9 -58.4 -57.5 23.0 + {0x81, 12}, // 81 6.3 -49.2 -45.7 -57.3 -53.6 -59.0 -56.0 -56.5 -57.5 19.5 + {0x85, 13}, // 85 5.0 -51.0 -47.2 -59.8 -54.2 -59.0 -56.9 -57.9 -58.0 18.3 + {0x88, 11}, // 88 3.9 -46.6 -48.1 -60.0 -55.0 -58.9 -57.5 -58.2 -58.2 17.4 + {0xCF, 11}, // CF 2.8 -49.8 -51.3 -57.6 -56.8 -59.1 -58.4 -58.1 -58.3 18.0 + {0x8D, 11}, // 8D 1.7 -43.8 -49.5 -58.9 -56.3 -58.8 -58.2 -58.4 -58.5 15.8 + {0x50, 10}, // 50 0.7 -59.2 -51.2 -59.0 -56.5 -59.0 -58.3 -58.3 -58.2 15.3 + {0x40, 10}, // 40 -0.3 -58.2 -52.1 -59.4 -56.9 -59.0 -58.4 -58.4 -58.3 14.7 + {0x3D, 10}, // 3D -1.3 -54.4 -48.4 -59.8 -57.5 -58.9 -58.3 -58.5 -58.5 19.3 + {0x55, 10}, // 55 -2.3 -56.7 -53.6 -59.7 -57.5 -59.1 -58.7 -58.4 -58.4 13.7 + {0x39, 11}, // 39 -3.4 -50.9 -49.5 -59.8 -58.0 -59.0 -58.5 -58.4 -58.4 16.8 + {0x2B, 15}, // 2B -4.9 -51.2 -50.4 -59.9 -58.0 -58.9 -58.7 -58.3 -58.4 15.6 + {0x29, 16}, // 29 -6.5 -51.8 -51.6 -59.9 -58.4 -59.0 -58.8 -58.3 -58.3 14.7 + {0x28, 10}, // 28 -7.5 -52.2 -52.5 -60.0 -58.6 -59.0 -58.8 -58.2 -58.4 14.3 + {0x27, 11}, // 27 -8.6 -52.9 -53.1 -60.0 -58.8 -59.1 -58.8 -58.3 -58.5 13.9 + {0x26, 12}, // 26 -9.8 -53.6 -54.3 -60.1 -58.7 -59.0 -58.7 -58.4 -58.4 13.4 + {0x25, 13}, // 25 -11.1 -54.3 -55.5 -60.1 -58.8 -59.1 -58.8 -58.4 -58.4 13.0 + {0x33, 11}, // 33 -12.2 -55.0 -56.3 -60.0 -58.7 -59.0 -58.9 -58.4 -58.4 12.8 + {0x1F, 11}, // 1F -13.3 -55.6 -57.2 -60.0 -58.8 -58.9 -58.9 -58.3 -58.4 12.4 + {0x1D, 12}, // 1D -14.5 -56.0 -58.0 -60.0 -58.8 -59.1 -58.7 -58.4 -58.5 12.1 + {0x32, 11}, // 32 -15.6 -56.9 -58.8 -59.9 -58.8 -59.0 -58.8 -58.3 -58.5 12.2 + {0x1A, 10}, // 1A -16.6 -57.3 -59.5 -59.9 -58.8 -59.1 -58.8 -58.4 -58.4 11.8 + {0x18, 19}, // 18 -18.5 -57.8 -60.3 -60.0 -58.8 -59.0 -58.9 -58.2 -58.5 11.6 + {0x17, 11}, // 17 -19.6 -58.7 -60.9 -60.0 -58.7 -58.9 -58.9 -58.5 -58.4 11.4 + {0x0C, 11}, // C -20.7 -59.4 -61.1 -60.0 -58.8 -59.1 -58.9 -58.4 -58.3 11.3 + {0x0A, 15}, // A -22.2 -59.9 -61.9 -60.0 -58.9 -59.0 -58.9 -58.4 -58.5 11.2 + {0x08, 18}, // 8 -24.0 -60.5 -62.5 -60.0 -58.7 -59.1 -58.8 -58.3 -58.5 11.1 + {0x07, 11}, // 7 -25.1 -61.3 -62.9 -60.1 -58.8 -59.1 -58.8 -58.4 -58.4 11.0 + {0x06, 13}, // 6 -26.4 -61.6 -63.2 -60.1 -58.7 -59.0 -58.9 -58.5 -58.5 11.0 + {0x05, 13}, // 5 -27.7 -62.3 -63.4 -60.1 -58.7 -59.2 -58.8 -58.4 -58.5 10.9 + {0x04, 19}, // 4 -29.6 -62.7 -63.6 -59.9 -58.7 -59.0 -58.9 -58.4 -58.4 10.8 +}; + +static const PowerTableItem PA_TABLE_433[] = { + {0xC0, 21}, // C0 9.9 -43.4 -45.0 -53.9 -55.2 -55.8 -52.3 -55.6 29.1 + {0xC3, 11}, // C3 8.8 -49.3 -45.9 -55.9 -55.4 -57.2 -52.6 -57.5 26.9 + {0xC6, 10}, // C6 7.8 -56.2 -46.9 -56.9 -55.6 -58.2 -53.2 -57.9 25.2 + {0xC9, 10}, // C9 6.8 -56.1 -47.9 -57.3 -55.9 -58.5 -54.0 -56.9 23.8 + {0xCC, 10}, // CC 5.8 -52.8 -48.9 -57.0 -56.1 -58.4 -54.6 -56.2 22.6 + {0x85, 10}, // 85 4.8 -54.2 -53.0 -58.3 -55.0 -57.8 -56.8 -58.0 19.1 + {0x88, 12}, // 88 3.6 -56.2 -53.8 -58.3 -55.7 -58.1 -57.2 -58.2 18.2 + {0x8B, 13}, // 8B 2.3 -57.7 -54.5 -58.0 -56.3 -58.1 -57.5 -58.2 17.3 + {0x8E, 19}, // 8E 0.4 -58.0 -55.5 -57.8 -57.4 -58.2 -58.1 -58.4 16.2 + {0x40, 12}, // 40 -0.8 -59.7 -56.1 -58.2 -57.7 -58.4 -58.3 -58.2 15.4 + {0x3C, 13}, // 3C -2.1 -60.6 -57.3 -58.2 -58.0 -58.5 -58.4 -58.5 19.3 + {0x3A, 10}, // 3A -3.1 -59.5 -57.5 -58.3 -58.3 -58.6 -58.1 -58.6 18.1 + {0x8F, 15}, // 8F -4.6 -52.2 -57.7 -58.1 -58.8 -58.4 -58.7 -58.3 14.4 + {0x37, 10}, // 37 -5.6 -56.8 -58.3 -58.3 -58.8 -58.4 -58.5 -58.4 16.2 + {0x36, 12}, // 36 -6.8 -56.8 -58.9 -58.3 -58.8 -58.3 -58.5 -58.5 15.6 + {0x28, 10}, // 28 -7.8 -56.6 -59.0 -58.2 -59.0 -58.4 -58.5 -58.4 15.1 + {0x26, 21}, // 26 -9.9 -57.0 -59.4 -58.3 -59.0 -58.4 -58.7 -58.4 14.3 + {0x25, 15}, // 25 -11.4 -57.3 -59.7 -58.4 -59.0 -58.3 -58.7 -58.5 13.9 + {0x24, 19}, // 24 -13.3 -57.9 -59.9 -58.2 -59.0 -58.6 -58.7 -58.5 13.5 + {0x1E, 10}, // 1E -14.3 -58.4 -59.8 -58.2 -59.0 -58.4 -58.6 -58.6 13.2 + {0x1C, 12}, // 1C -15.5 -58.6 -59.9 -58.4 -58.8 -58.6 -58.8 -58.5 12.9 + {0x1A, 15}, // 1A -17.0 -59.4 -59.9 -58.3 -59.1 -58.5 -58.7 -58.4 12.7 + {0x18, 18}, // 18 -18.8 -60.2 -59.9 -58.2 -59.0 -58.5 -58.7 -58.6 12.5 + {0x17, 10}, // 17 -19.8 -60.6 -59.9 -58.2 -58.9 -58.4 -58.7 -58.4 12.4 + {0x0C, 12}, // C -21.0 -61.1 -59.9 -58.4 -59.0 -58.5 -58.7 -58.6 12.3 + {0x15, 15}, // 15 -22.5 -61.7 -60.0 -58.2 -59.1 -58.3 -58.6 -58.7 12.2 + {0x08, 18}, // 8 -24.3 -62.3 -59.9 -58.3 -59.0 -58.4 -58.8 -58.5 12.1 + {0x07, 10}, // 7 -25.3 -62.6 -59.9 -58.2 -59.0 -58.6 -58.7 -58.5 12.0 + {0x06, 12}, // 6 -26.5 -63.2 -59.9 -58.3 -58.9 -58.5 -58.6 -58.6 12.0 + {0x05, 14}, // 5 -27.9 -63.5 -59.8 -58.3 -59.1 -58.5 -58.7 -58.4 11.9 + {0x04, 16}, // 4 -29.5 -63.7 -59.9 -58.3 -58.9 -58.5 -58.5 -58.5 11.9 +}; + +static const PowerTableItem PA_TABLE_868[] = { + {0xC0, 13}, // C0 10.7 -35.1 -58.6 -58.6 -57.5 -50.0 34.2 + {0xC3, 11}, // C3 9.6 -41.5 -58.5 -58.3 -57.4 -54.4 31.6 + {0xC6, 11}, // C6 8.5 -47.7 -58.5 -58.3 -57.6 -55.0 29.5 + {0xC9, 10}, // C9 7.5 -44.4 -58.5 -58.5 -57.7 -53.6 27.8 + {0xCC, 10}, // CC 6.5 -40.6 -58.6 -58.4 -57.6 -52.5 26.3 + {0xCE, 10}, // CE 5.5 -38.5 -58.5 -58.4 -57.8 -52.2 25.0 + {0x84, 11}, // 84 4.4 -35.3 -58.7 -58.5 -57.8 -55.8 20.3 + {0x87, 10}, // 87 3.4 -39.4 -58.6 -58.6 -57.8 -55.7 19.5 + {0xCF, 10}, // CF 2.4 -36.6 -58.6 -58.4 -57.7 -53.6 22.0 + {0x8C, 13}, // 8C 1.1 -50.2 -58.6 -58.5 -57.7 -55.9 17.9 + {0x50, 14}, // 50 -0.3 -42.1 -58.5 -58.5 -57.6 -57.1 16.9 + {0x40, 12}, // 40 -1.5 -43.2 -58.5 -58.7 -57.7 -57.2 16.1 + {0x3F, 11}, // 3F -2.6 -53.7 -58.6 -58.5 -57.8 -57.5 21.4 + {0x55, 10}, // 55 -3.6 -44.9 -58.6 -58.4 -57.8 -57.5 15.0 + {0x57, 12}, // 57 -4.8 -46.0 -58.6 -58.5 -57.6 -57.4 14.5 + {0x8F, 12}, // 8F -6.0 -51.6 -58.5 -58.6 -57.7 -57.1 15.0 + {0x2A, 14}, // 2A -7.4 -49.3 -58.5 -58.6 -57.7 -57.4 16.2 + {0x28, 16}, // 28 -9.0 -49.0 -58.5 -58.6 -57.7 -57.4 15.4 + {0x26, 20}, // 26 -11.0 -49.2 -58.5 -58.5 -57.7 -57.4 14.6 + {0x25, 15}, // 25 -12.5 -49.5 -58.6 -58.6 -57.8 -57.3 14.1 + {0x24, 18}, // 24 -14.3 -50.2 -58.5 -58.4 -57.8 -57.4 13.7 + {0x1D, 14}, // 1D -15.7 -50.7 -58.6 -58.6 -57.8 -57.5 13.3 + {0x1B, 13}, // 1B -17.0 -51.3 -58.5 -58.4 -57.7 -57.5 13.1 + {0x19, 16}, // 19 -18.6 -52.0 -58.6 -58.5 -57.8 -57.5 12.9 + {0x22, 10}, // 22 -19.6 -52.5 -58.5 -58.6 -57.7 -57.4 12.9 + {0x0D, 15}, // D -21.1 -53.3 -58.6 -58.6 -57.8 -57.4 12.6 + {0x0B, 12}, // B -22.3 -53.9 -58.6 -58.5 -57.8 -57.4 12.5 + {0x09, 15}, // 9 -23.8 -54.7 -58.5 -58.5 -57.8 -57.5 12.4 + {0x21, 10}, // 21 -24.8 -55.1 -58.5 -58.5 -57.7 -57.5 12.5 + {0x13, 17}, // 13 -26.5 -55.9 -58.6 -58.5 -57.6 -57.6 12.3 + {0x05, 12}, // 5 -27.7 -56.4 -58.4 -58.4 -57.7 -57.5 12.2 + {0x12, 12}, // 12 -28.9 -57.1 -58.4 -58.5 -57.7 -57.3 12.2 +}; + +static const PowerTableItem PA_TABLE_915[] = { + {0xC0, 26}, // C0 9.4 -33.5 -58.5 -58.4 -55.8 -32.6 31.8 + {0xC3, 11}, // C3 8.3 -41.5 -58.6 -58.4 -56.3 -38.0 29.3 + {0xC6, 11}, // C6 7.2 -42.5 -58.5 -58.4 -56.7 -40.5 27.4 + {0xC9, 10}, // C9 6.2 -37.6 -58.6 -58.4 -57.2 -38.8 25.9 + {0xCD, 12}, // CD 5.0 -34.2 -58.6 -58.5 -57.5 -37.3 24.3 + {0x84, 11}, // 84 3.9 -32.0 -58.6 -58.4 -57.7 -40.1 19.7 + {0x87, 10}, // 87 2.9 -36.5 -58.4 -58.5 -57.7 -39.6 18.9 + {0x8A, 11}, // 8A 1.8 -42.2 -58.5 -58.4 -57.7 -39.6 18.1 + {0x8D, 13}, // 8D 0.5 -46.8 -58.5 -58.5 -57.7 -40.4 17.3 + {0x8E, 11}, // 8E -0.6 -46.6 -58.5 -58.5 -57.8 -41.1 16.7 + {0x51, 10}, // 51 -1.6 -38.7 -58.4 -58.5 -57.7 -46.9 16.0 + {0x3E, 11}, // 3E -2.7 -50.0 -58.5 -58.4 -57.6 -55.3 20.7 + {0x3B, 11}, // 3B -3.8 -50.7 -58.6 -58.4 -57.6 -55.2 18.9 + {0x39, 13}, // 39 -5.1 -50.0 -58.5 -58.5 -57.6 -54.0 17.7 + {0x2B, 13}, // 2B -6.4 -47.6 -58.4 -58.4 -57.8 -52.1 16.5 + {0x36, 15}, // 36 -7.9 -46.9 -58.5 -58.4 -57.7 -51.2 15.8 + {0x35, 14}, // 35 -9.3 -46.7 -58.6 -58.4 -57.7 -50.7 15.2 + {0x26, 16}, // 26 -10.9 -47.0 -58.6 -58.4 -57.8 -50.9 14.5 + {0x25, 14}, // 25 -12.3 -47.2 -58.6 -58.3 -57.7 -51.0 14.1 + {0x24, 18}, // 24 -14.1 -48.1 -58.4 -58.4 -57.8 -51.4 13.7 + {0x1D, 14}, // 1D -15.5 -48.7 -58.4 -58.5 -57.7 -51.9 13.2 + {0x1B, 13}, // 1B -16.8 -49.3 -58.6 -58.4 -57.8 -52.3 13.0 + {0x19, 15}, // 19 -18.3 -50.2 -58.5 -58.5 -57.6 -52.8 12.8 + {0x18, 10}, // 18 -19.3 -50.6 -58.5 -58.5 -57.7 -53.1 12.7 + {0x17, 10}, // 17 -20.3 -51.2 -58.6 -58.5 -57.8 -53.1 12.6 + {0x0C, 11}, // C -21.4 -51.8 -58.4 -58.5 -57.7 -53.4 12.5 + {0x0A, 13}, // A -22.7 -52.6 -58.5 -58.4 -57.7 -53.6 12.4 + {0x08, 16}, // 8 -24.3 -53.6 -58.4 -58.4 -57.6 -54.1 12.3 + {0x13, 19}, // 13 -26.2 -54.6 -58.4 -58.5 -57.7 -54.3 12.2 + {0x05, 11}, // 5 -27.3 -55.3 -58.4 -58.4 -57.8 -54.5 12.1 + {0x12, 13}, // 12 -28.6 -55.9 -58.6 -58.5 -57.7 -54.7 12.1 + {0x03, 12}, // 3 -29.8 -56.9 -58.5 -58.4 -57.7 -54.7 12.0 +}; +} // namespace esphome::cc1101 diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml new file mode 100644 index 0000000000..7fd265ca4a --- /dev/null +++ b/tests/components/cc1101/common.yaml @@ -0,0 +1,20 @@ +cc1101: + id: transceiver + cs_pin: ${cs_pin} + frequency: 433920 + if_frequency: 153 + filter_bandwidth: 203 + channel: 0 + channel_spacing: 200 + symbol_rate: 5000 + modulation_type: ASK/OOK + +button: + - platform: template + name: "CC1101 Button" + on_press: + then: + - cc1101.begin_tx: transceiver + - cc1101.begin_rx: transceiver + - cc1101.set_idle: transceiver + - cc1101.reset: transceiver diff --git a/tests/components/cc1101/test.esp32-idf.yaml b/tests/components/cc1101/test.esp32-idf.yaml new file mode 100644 index 0000000000..e075629679 --- /dev/null +++ b/tests/components/cc1101/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266.yaml new file mode 100644 index 0000000000..7900658bc1 --- /dev/null +++ b/tests/components/cc1101/test.esp8266.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + +<<: !include common.yaml From b3812b58112332ca9050588cf27b00cd59eb7afe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Dec 2025 10:22:06 -0600 Subject: [PATCH 231/320] [text_sensor] Fix spurious raw_state deprecation warnings (#12262) --- esphome/components/text_sensor/text_sensor.h | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 7217806a55..e411f57d67 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -24,7 +24,17 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text class TextSensor : public EntityBase, public EntityBase_DeviceClass { public: + std::string state; + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. + ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") + std::string raw_state; + TextSensor() = default; + ~TextSensor() = default; +#pragma GCC diagnostic pop /// Getter-syntax for .state. std::string get_state() const; @@ -49,15 +59,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { /// Add a callback that will be called every time the sensor sends a raw value. void add_on_raw_state_callback(std::function callback); - std::string state; - -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") - std::string raw_state; -#pragma GCC diagnostic pop - // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) From 623cdac689517fa771898ceb09f902fe1ec5caf5 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 3 Dec 2025 18:36:35 +0100 Subject: [PATCH 232/320] [tests] Add testing of command line substitutions (#12210) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../fixtures/substitutions/00-simple_var.approved.yaml | 4 ++++ .../fixtures/substitutions/00-simple_var.input.yaml | 9 +++++++++ tests/unit_tests/test_substitutions.py | 4 +++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml index 6f3bae1ac4..9ed9b99c49 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml @@ -8,6 +8,8 @@ substitutions: position: x: 79 y: 82 + a: 15 + b: 20 esphome: name: test @@ -34,3 +36,5 @@ test_list: - '{{{"AA"}}}' - '"HELLO"' - '{ 79, 82 }' + - a: 15 should be 15, overridden from command line + b: 20 should stay as 20, not overridden diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml index 306119b753..64701c03dd 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml @@ -11,6 +11,13 @@ substitutions: position: x: 79 y: 82 + a: 10 + b: 20 + +# The following key is only used by the test framework +# to simulate command line substitutions +command_line_substitutions: + a: 15 test_list: - "$var1" @@ -35,3 +42,5 @@ test_list: - ${ '{{{"AA"}}}' } - ${ '"HELLO"' } - '{ ${position.x}, ${position.y} }' + - a: ${a} should be 15, overridden from command line + b: ${b} should stay as 20, not overridden diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index eb9ef5443c..cba1e398c3 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -138,9 +138,11 @@ def test_substitutions_fixtures( # Load using ESPHome's YAML loader config = yaml_util.load_yaml(source_path) + command_line_substitutions = config.pop("command_line_substitutions", None) + config = do_packages_pass(config) - substitutions.do_substitution_pass(config, None) + substitutions.do_substitution_pass(config, command_line_substitutions) resolve_extend_remove(config) verify_database_result = verify_database(config) From a24ba260689310e542a2da5f34cab223d22159fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Dec 2025 12:33:57 -0600 Subject: [PATCH 233/320] [core] Improve CORE.data documentation with dataclass pattern (#12170) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .ai/instructions.md | 44 ++++++++++++++++++++++++---------------- esphome/core/__init__.py | 18 ++++++++++++++-- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 681829bae6..9d48f467cb 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -402,35 +402,45 @@ This document provides essential context for AI models interacting with this pro _use_feature = True ``` - **Good Pattern (CORE.data with Helpers):** + **Bad Pattern (Flat Keys):** ```python + # Don't do this - keys should be namespaced under component domain + MY_FEATURE_KEY = "my_component_feature" + CORE.data[MY_FEATURE_KEY] = True + ``` + + **Good Pattern (dataclass):** + ```python + from dataclasses import dataclass, field from esphome.core import CORE - # Keys for CORE.data storage - COMPONENT_STATE_KEY = "my_component_state" - USE_FEATURE_KEY = "my_component_use_feature" + DOMAIN = "my_component" - def _get_component_state() -> list: - """Get component state from CORE.data.""" - return CORE.data.setdefault(COMPONENT_STATE_KEY, []) + @dataclass + class MyComponentData: + feature_enabled: bool = False + item_count: int = 0 + items: list[str] = field(default_factory=list) - def _get_use_feature() -> bool | None: - """Get feature flag from CORE.data.""" - return CORE.data.get(USE_FEATURE_KEY) + def _get_data() -> MyComponentData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = MyComponentData() + return CORE.data[DOMAIN] - def _set_use_feature(value: bool) -> None: - """Set feature flag in CORE.data.""" - CORE.data[USE_FEATURE_KEY] = value + def request_feature() -> None: + _get_data().feature_enabled = True - def enable_feature(): - _set_use_feature(True) + def add_item(item: str) -> None: + _get_data().items.append(item) ``` + If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs. + **Why this matters:** - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec - `CORE.data` automatically clears between runs - - Typed helper functions provide better IDE support and maintainability - - Encapsulation makes state management explicit and testable + - Namespacing under `DOMAIN` prevents key collisions between components + - `@dataclass` provides type safety and cleaner attribute access * **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys. diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 08753b0f2d..721cd5787d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -541,8 +541,22 @@ class EsphomeCore: self.friendly_name: str | None = None # The area / zone of the node self.area: str | None = None - # Additional data components can store temporary data in - # The first key to this dict should always be the integration name + # Additional data components can store temporary data in. + # This dict is cleared between compilation runs. + # + # Usage pattern (use @dataclass for type safety): + # DOMAIN = "my_component" + # + # @dataclass + # class MyComponentData: + # feature_enabled: bool = False + # + # def _get_data() -> MyComponentData: + # if DOMAIN not in CORE.data: + # CORE.data[DOMAIN] = MyComponentData() + # return CORE.data[DOMAIN] + # + # The first key should always be the component domain name (DOMAIN constant). self.data = {} # The relative path to the configuration YAML self.config_path: Path | None = None From 03aaa66f8e9c9cec01588e20a4797d7c8926af80 Mon Sep 17 00:00:00 2001 From: jsmarion Date: Wed, 3 Dec 2025 14:35:14 -0500 Subject: [PATCH 234/320] [cst816] Fix CST826 & CST836 (#12260) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../cst816/touchscreen/cst816_touchscreen.cpp | 64 ++++++++++++------- .../cst816/touchscreen/cst816_touchscreen.h | 2 + 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index f6280a75a1..5be93692c0 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -9,27 +9,40 @@ void CST816Touchscreen::continue_setup_() { this->interrupt_pin_->setup(); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } - if (this->read_byte(REG_CHIP_ID, &this->chip_id_)) { - switch (this->chip_id_) { - case CST820_CHIP_ID: - case CST826_CHIP_ID: - case CST716_CHIP_ID: - case CST816S_CHIP_ID: - case CST816D_CHIP_ID: - case CST816T_CHIP_ID: - break; - default: + + if (!this->read_byte(REG_CHIP_ID, &this->chip_id_) && !this->skip_probe_) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + + // CST826/CST836 return 0 for chip ID, need to read from factory ID register + if (this->chip_id_ == 0) { + if (!this->read_byte(REG_FACTORY_ID, &this->chip_id_) && !this->skip_probe_) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + } + + switch (this->chip_id_) { + case CST716_CHIP_ID: + case CST816S_CHIP_ID: + case CST816D_CHIP_ID: + case CST816T_CHIP_ID: + case CST820_CHIP_ID: + case CST826_CHIP_ID: + case CST836_CHIP_ID: + break; + default: + if (!this->skip_probe_) { ESP_LOGE(TAG, "Unknown chip ID: 0x%02X", this->chip_id_); this->status_set_error(LOG_STR("Unknown chip ID")); this->mark_failed(); return; - } - this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); - } else if (!this->skip_probe_) { - this->status_set_error(LOG_STR("Failed to read chip id")); - this->mark_failed(); - return; + } } + this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); if (this->x_raw_max_ == this->x_raw_min_) { this->x_raw_max_ = this->display_->get_native_width(); } @@ -80,11 +93,8 @@ void CST816Touchscreen::dump_config() { this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); const char *name; switch (this->chip_id_) { - case CST820_CHIP_ID: - name = "CST820"; - break; - case CST826_CHIP_ID: - name = "CST826"; + case CST716_CHIP_ID: + name = "CST716"; break; case CST816S_CHIP_ID: name = "CST816S"; @@ -92,12 +102,18 @@ void CST816Touchscreen::dump_config() { case CST816D_CHIP_ID: name = "CST816D"; break; - case CST716_CHIP_ID: - name = "CST716"; - break; case CST816T_CHIP_ID: name = "CST816T"; break; + case CST820_CHIP_ID: + name = "CST820"; + break; + case CST826_CHIP_ID: + name = "CST826"; + break; + case CST836_CHIP_ID: + name = "CST836"; + break; default: name = "Unknown"; break; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 99ea085e37..99b93d8342 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -19,12 +19,14 @@ static const uint8_t REG_YPOS_HIGH = 0x05; static const uint8_t REG_YPOS_LOW = 0x06; static const uint8_t REG_DIS_AUTOSLEEP = 0xFE; static const uint8_t REG_CHIP_ID = 0xA7; +static const uint8_t REG_FACTORY_ID = 0xAA; static const uint8_t REG_FW_VERSION = 0xA9; static const uint8_t REG_SLEEP = 0xE5; static const uint8_t REG_IRQ_CTL = 0xFA; static const uint8_t IRQ_EN_MOTION = 0x70; static const uint8_t CST826_CHIP_ID = 0x11; +static const uint8_t CST836_CHIP_ID = 0x13; static const uint8_t CST820_CHIP_ID = 0xB7; static const uint8_t CST816S_CHIP_ID = 0xB4; static const uint8_t CST816D_CHIP_ID = 0xB6; From a8518d3cea6d22d4413621240c7a6f6b5ee4fa3a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 3 Dec 2025 15:18:59 -0500 Subject: [PATCH 235/320] [wifi, wifi_info] Add a WiFi power mode text sensor (#11480) Co-authored-by: J. Nick Koston --- esphome/components/wifi/wifi_component.h | 15 +++++ .../wifi/wifi_component_esp8266.cpp | 10 +++- .../wifi/wifi_component_esp_idf.cpp | 10 +++- .../wifi/wifi_component_libretiny.cpp | 12 +++- .../components/wifi/wifi_component_pico_w.cpp | 10 +++- esphome/components/wifi_info/text_sensor.py | 10 ++++ .../wifi_info/wifi_info_text_sensor.cpp | 60 +++++++++++++++++++ .../wifi_info/wifi_info_text_sensor.h | 11 ++++ tests/components/wifi_info/common.yaml | 4 +- 9 files changed, 137 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 2148f2d4c7..be94e9462b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -273,6 +273,16 @@ class WiFiConnectStateListener { virtual void on_wifi_connect_state(const std::string &ssid, const bssid_t &bssid) = 0; }; +/** Listener interface for WiFi power save mode changes. + * + * Components can implement this interface to receive power save mode updates + * without the overhead of std::function callbacks. + */ +class WiFiPowerSaveListener { + public: + virtual void on_wifi_power_save(WiFiPowerSaveMode mode) = 0; +}; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent : public Component { public: @@ -419,6 +429,10 @@ class WiFiComponent : public Component { void add_connect_state_listener(WiFiConnectStateListener *listener) { this->connect_state_listeners_.push_back(listener); } + /** Add a listener for WiFi power save mode changes. + * Listener receives: WiFiPowerSaveMode + */ + void add_power_save_listener(WiFiPowerSaveListener *listener) { this->power_save_listeners_.push_back(listener); } #endif // USE_WIFI_LISTENERS #ifdef USE_WIFI_RUNTIME_POWER_SAVE @@ -581,6 +595,7 @@ class WiFiComponent : public Component { std::vector ip_state_listeners_; std::vector scan_results_listeners_; std::vector connect_state_listeners_; + std::vector power_save_listeners_; #endif // USE_WIFI_LISTENERS ESPPreferenceObject pref_; #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index c1c0dd470f..3b1a442bdb 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -104,7 +104,15 @@ bool WiFiComponent::wifi_apply_power_save_() { break; } wifi_fpm_auto_sleep_set_in_null_mode(1); - return wifi_set_sleep_type(power_save); + bool success = wifi_set_sleep_type(power_save); +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; } #if LWIP_VERSION_MAJOR != 1 diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index e1f8108892..1f4eb1e42c 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -280,7 +280,15 @@ bool WiFiComponent::wifi_apply_power_save_() { power_save = WIFI_PS_NONE; break; } - return esp_wifi_set_ps(power_save) == ESP_OK; + bool success = esp_wifi_set_ps(power_save) == ESP_OK; +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; } bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 0de7003899..1a6f037a87 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -69,7 +69,17 @@ bool WiFiComponent::wifi_sta_pre_setup_() { delay(10); return true; } -bool WiFiComponent::wifi_apply_power_save_() { return WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); } +bool WiFiComponent::wifi_apply_power_save_() { + bool success = WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; +} bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index c7dc4120dd..0228755432 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -54,7 +54,15 @@ bool WiFiComponent::wifi_apply_power_save_() { break; } int ret = cyw43_wifi_pm(&cyw43_state, pm); - return ret == 0; + bool success = ret == 0; +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; } // TODO: The driver doesn't seem to have an API for this diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index bc0c038f80..8a7f192367 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_DNS_ADDRESS, CONF_IP_ADDRESS, CONF_MAC_ADDRESS, + CONF_POWER_SAVE_MODE, CONF_SCAN_RESULTS, CONF_SSID, ENTITY_CATEGORY_DIAGNOSTIC, @@ -30,6 +31,9 @@ MacAddressWifiInfo = wifi_info_ns.class_( DNSAddressWifiInfo = wifi_info_ns.class_( "DNSAddressWifiInfo", text_sensor.TextSensor, cg.Component ) +PowerSaveModeWiFiInfo = wifi_info_ns.class_( + "PowerSaveModeWiFiInfo", text_sensor.TextSensor, cg.Component +) CONFIG_SCHEMA = cv.Schema( { @@ -58,6 +62,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_DNS_ADDRESS): text_sensor.text_sensor_schema( DNSAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), + cv.Optional(CONF_POWER_SAVE_MODE): text_sensor.text_sensor_schema( + PowerSaveModeWiFiInfo, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ) @@ -68,6 +76,7 @@ _NETWORK_INFO_KEYS = { CONF_IP_ADDRESS, CONF_DNS_ADDRESS, CONF_SCAN_RESULTS, + CONF_POWER_SAVE_MODE, } @@ -90,6 +99,7 @@ async def to_code(config): await setup_conf(config, CONF_SCAN_RESULTS) wifi.request_wifi_scan_results() await setup_conf(config, CONF_DNS_ADDRESS) + await setup_conf(config, CONF_POWER_SAVE_MODE) if conf := config.get(CONF_IP_ADDRESS): wifi_info = await text_sensor.new_text_sensor(config[CONF_IP_ADDRESS]) await cg.register_component(wifi_info, config[CONF_IP_ADDRESS]) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 6c9d0c00e5..56cf49028c 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -2,6 +2,10 @@ #ifdef USE_WIFI #include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include +#endif + namespace esphome::wifi_info { static const char *const TAG = "wifi_info"; @@ -100,6 +104,62 @@ void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::b this->publish_state(buf); } +/************************ + * PowerSaveModeWiFiInfo + ***********************/ + +void PowerSaveModeWiFiInfo::setup() { wifi::global_wifi_component->add_power_save_listener(this); } + +void PowerSaveModeWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "WiFi Power Save Mode", this); } + +void PowerSaveModeWiFiInfo::on_wifi_power_save(wifi::WiFiPowerSaveMode mode) { +#ifdef USE_ESP8266 +#define MODE_STR(s) static const char MODE_##s[] PROGMEM = #s + MODE_STR(NONE); + MODE_STR(LIGHT); + MODE_STR(HIGH); + MODE_STR(UNKNOWN); + + const char *mode_str_p; + switch (mode) { + case wifi::WIFI_POWER_SAVE_NONE: + mode_str_p = MODE_NONE; + break; + case wifi::WIFI_POWER_SAVE_LIGHT: + mode_str_p = MODE_LIGHT; + break; + case wifi::WIFI_POWER_SAVE_HIGH: + mode_str_p = MODE_HIGH; + break; + default: + mode_str_p = MODE_UNKNOWN; + break; + } + + char mode_str[8]; + strncpy_P(mode_str, mode_str_p, sizeof(mode_str)); + mode_str[sizeof(mode_str) - 1] = '\0'; +#undef MODE_STR +#else + const char *mode_str; + switch (mode) { + case wifi::WIFI_POWER_SAVE_NONE: + mode_str = "NONE"; + break; + case wifi::WIFI_POWER_SAVE_LIGHT: + mode_str = "LIGHT"; + break; + case wifi::WIFI_POWER_SAVE_HIGH: + mode_str = "HIGH"; + break; + default: + mode_str = "UNKNOWN"; + break; + } +#endif + this->publish_state(mode_str); +} + #endif /********************* diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index f1f85c114f..b2242372da 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -63,6 +63,17 @@ class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, pu // WiFiConnectStateListener interface void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; + +class PowerSaveModeWiFiInfo final : public Component, + public text_sensor::TextSensor, + public wifi::WiFiPowerSaveListener { + public: + void setup() override; + void dump_config() override; + + // WiFiPowerSaveListener interface + void on_wifi_power_save(wifi::WiFiPowerSaveMode mode) override; +}; #endif class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { diff --git a/tests/components/wifi_info/common.yaml b/tests/components/wifi_info/common.yaml index f87d381d0c..91dea6c66e 100644 --- a/tests/components/wifi_info/common.yaml +++ b/tests/components/wifi_info/common.yaml @@ -15,4 +15,6 @@ text_sensor: mac_address: name: MAC Address dns_address: - name: DNS ADdress + name: DNS Address + power_save_mode: + name: "WiFi Power Save Mode" From fb331e1c5a5a13768ededf855d3b60fb4c9eccba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:04:09 +0000 Subject: [PATCH 236/320] Bump actions/stale from 10.1.0 to 10.1.1 (#12270) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5843b3a5e0..7e03e2a5f9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true From 20f82a3820881053f8045e639e1fd1a549e7f98a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:49:57 +1100 Subject: [PATCH 237/320] [esp32] Add build flag to suppress noexecstack message (#12272) --- esphome/components/esp32/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 14db25fd46..ceb28fd939 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -907,6 +907,7 @@ async def to_code(config): ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") + cg.add_build_flag("-Wl,-z,noexecstack") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") From 22803ef54b5b8ff26dc1c8c8f9cbe7db950fa9af Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Thu, 4 Dec 2025 02:48:11 +0100 Subject: [PATCH 238/320] [esp32] Sort variants in situ (#10410) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- esphome/components/adc/__init__.py | 38 ++++++++++--------- esphome/components/adc/adc_sensor_esp32.cpp | 16 ++++---- esphome/components/deep_sleep/__init__.py | 10 ++--- esphome/components/esp32/__init__.py | 4 +- esphome/components/esp32/const.py | 12 +++--- esphome/components/esp32_can/canbus.py | 4 +- esphome/components/esp32_can/esp32_can.cpp | 4 +- .../components/esp32_rmt_led_strip/light.py | 8 ++-- .../ethernet/ethernet_component.cpp | 4 +- esphome/components/i2s_audio/__init__.py | 4 +- .../improv_serial/improv_serial_component.h | 4 +- .../internal_temperature.cpp | 24 ++++++------ esphome/components/logger/__init__.py | 10 ++--- esphome/components/psram/__init__.py | 4 +- .../components/remote_receiver/__init__.py | 8 ++-- .../components/remote_transmitter/__init__.py | 8 ++-- esphome/components/spi/__init__.py | 2 +- .../components/tinyusb/tinyusb_component.cpp | 2 +- .../components/tinyusb/tinyusb_component.h | 2 +- esphome/components/usb_host/usb_host.h | 4 +- .../components/usb_host/usb_host_client.cpp | 4 +- .../usb_host/usb_host_component.cpp | 4 +- esphome/components/usb_uart/ch34x.cpp | 4 +- esphome/components/usb_uart/cp210x.cpp | 4 +- esphome/components/usb_uart/usb_uart.cpp | 4 +- esphome/components/usb_uart/usb_uart.h | 4 +- esphome/core/defines.h | 6 +-- platformio.ini | 24 ++++++------ tests/component_tests/mipi_spi/test_init.py | 16 ++++---- tests/unit_tests/test_config_validation.py | 20 +++++----- 31 files changed, 133 insertions(+), 131 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3ade00f0cd..ab3217b5e5 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c +29270eecb86ffa07b2b1d2a4ca56dd7f84762ddc89c6248dbf3f012eca8780b6 diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 15dc447b6c..8f751c496e 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -107,6 +107,17 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 4: adc_channel_t.ADC_CHANNEL_3, 5: adc_channel_t.ADC_CHANNEL_4, }, + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32p4/include/soc/adc_channel.h + VARIANT_ESP32P4: { + 16: adc_channel_t.ADC_CHANNEL_0, + 17: adc_channel_t.ADC_CHANNEL_1, + 18: adc_channel_t.ADC_CHANNEL_2, + 19: adc_channel_t.ADC_CHANNEL_3, + 20: adc_channel_t.ADC_CHANNEL_4, + 21: adc_channel_t.ADC_CHANNEL_5, + 22: adc_channel_t.ADC_CHANNEL_6, + 23: adc_channel_t.ADC_CHANNEL_7, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h VARIANT_ESP32S2: { 1: adc_channel_t.ADC_CHANNEL_0, @@ -133,16 +144,6 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 9: adc_channel_t.ADC_CHANNEL_8, 10: adc_channel_t.ADC_CHANNEL_9, }, - VARIANT_ESP32P4: { - 16: adc_channel_t.ADC_CHANNEL_0, - 17: adc_channel_t.ADC_CHANNEL_1, - 18: adc_channel_t.ADC_CHANNEL_2, - 19: adc_channel_t.ADC_CHANNEL_3, - 20: adc_channel_t.ADC_CHANNEL_4, - 21: adc_channel_t.ADC_CHANNEL_5, - 22: adc_channel_t.ADC_CHANNEL_6, - 23: adc_channel_t.ADC_CHANNEL_7, - }, } # pin to adc2 channel mapping @@ -175,6 +176,15 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { VARIANT_ESP32C6: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h VARIANT_ESP32H2: {}, # no ADC2 + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32p4/include/soc/adc_channel.h + VARIANT_ESP32P4: { + 49: adc_channel_t.ADC_CHANNEL_0, + 50: adc_channel_t.ADC_CHANNEL_1, + 51: adc_channel_t.ADC_CHANNEL_2, + 52: adc_channel_t.ADC_CHANNEL_3, + 53: adc_channel_t.ADC_CHANNEL_4, + 54: adc_channel_t.ADC_CHANNEL_5, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h VARIANT_ESP32S2: { 11: adc_channel_t.ADC_CHANNEL_0, @@ -201,14 +211,6 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { 19: adc_channel_t.ADC_CHANNEL_8, 20: adc_channel_t.ADC_CHANNEL_9, }, - VARIANT_ESP32P4: { - 49: adc_channel_t.ADC_CHANNEL_0, - 50: adc_channel_t.ADC_CHANNEL_1, - 51: adc_channel_t.ADC_CHANNEL_2, - 52: adc_channel_t.ADC_CHANNEL_3, - 53: adc_channel_t.ADC_CHANNEL_4, - 54: adc_channel_t.ADC_CHANNEL_5, - }, } diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index ab6a89fce0..e25b275cd6 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -74,7 +74,7 @@ void ADCSensor::setup() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 // RISC-V variants and S3 use curve fitting calibration adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) @@ -111,7 +111,7 @@ void ADCSensor::setup() { ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err); this->setup_flags_.calibration_complete = false; } -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32H2 || ESP32P4 || ESP32S3 } this->setup_flags_.init_complete = true; @@ -186,11 +186,11 @@ float ADCSensor::sample_fixed_attenuation_() { ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); if (this->calibration_handle_ != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else // Other ESP32 variants use line fitting calibration adc_cali_delete_scheme_line_fitting(this->calibration_handle_); -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32H2 || ESP32P4 || ESP32S3 this->calibration_handle_ = nullptr; } } @@ -219,7 +219,7 @@ float ADCSensor::sample_autorange_() { if (this->calibration_handle_ != nullptr) { // Delete old calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else adc_cali_delete_scheme_line_fitting(this->calibration_handle_); @@ -231,7 +231,7 @@ float ADCSensor::sample_autorange_() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_curve_fitting_config_t cali_config = {}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -266,7 +266,7 @@ float ADCSensor::sample_autorange_() { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); if (handle != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); @@ -288,7 +288,7 @@ float ADCSensor::sample_autorange_() { } // Clean up calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 19fb726016..18ba167952 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -52,7 +52,10 @@ WAKEUP_PINS = { 38, 39, ], + VARIANT_ESP32C2: [0, 1, 2, 3, 4, 5], VARIANT_ESP32C3: [0, 1, 2, 3, 4, 5], + VARIANT_ESP32C6: [0, 1, 2, 3, 4, 5, 6, 7], + VARIANT_ESP32H2: [7, 8, 9, 10, 11, 12, 13, 14], VARIANT_ESP32S2: [ 0, 1, @@ -101,9 +104,6 @@ WAKEUP_PINS = { 20, 21, ], - VARIANT_ESP32C2: [0, 1, 2, 3, 4, 5], - VARIANT_ESP32C6: [0, 1, 2, 3, 4, 5, 6, 7], - VARIANT_ESP32H2: [7, 8, 9, 10, 11, 12, 13, 14], } @@ -122,10 +122,10 @@ def _validate_ex1_wakeup_mode(value): if value == "ANY_LOW": esp32.only_on_variant( supported=[ - VARIANT_ESP32S2, - VARIANT_ESP32S3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32S2, + VARIANT_ESP32S3, ], msg_prefix="ANY_LOW", )(value) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ceb28fd939..1d05e16ebd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -122,14 +122,14 @@ def get_cpu_frequencies(*frequencies): CPU_FREQUENCIES = { VARIANT_ESP32: get_cpu_frequencies(80, 160, 240), - VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), - VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C2: get_cpu_frequencies(80, 120), VARIANT_ESP32C3: get_cpu_frequencies(80, 160), VARIANT_ESP32C5: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), + VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), + VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), } # Make sure not missed here if a new variant added. diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 9bef18847f..4358a4b712 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -13,36 +13,36 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" VARIANT_ESP32 = "ESP32" -VARIANT_ESP32S2 = "ESP32S2" -VARIANT_ESP32S3 = "ESP32S3" VARIANT_ESP32C2 = "ESP32C2" VARIANT_ESP32C3 = "ESP32C3" VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32H2 = "ESP32H2" VARIANT_ESP32P4 = "ESP32P4" +VARIANT_ESP32S2 = "ESP32S2" +VARIANT_ESP32S3 = "ESP32S3" VARIANTS = [ VARIANT_ESP32, - VARIANT_ESP32S2, - VARIANT_ESP32S3, VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, ] VARIANT_FRIENDLY = { VARIANT_ESP32: "ESP32", - VARIANT_ESP32S2: "ESP32-S2", - VARIANT_ESP32S3: "ESP32-S3", VARIANT_ESP32C2: "ESP32-C2", VARIANT_ESP32C3: "ESP32-C3", VARIANT_ESP32C5: "ESP32-C5", VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32H2: "ESP32-H2", VARIANT_ESP32P4: "ESP32-P4", + VARIANT_ESP32S2: "ESP32-S2", + VARIANT_ESP32S3: "ESP32-S3", } esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index acc3785f22..5cee27506a 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -64,12 +64,12 @@ CAN_SPEEDS_ESP32_P4 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS = { VARIANT_ESP32: CAN_SPEEDS_ESP32, - VARIANT_ESP32S2: CAN_SPEEDS_ESP32_S2, - VARIANT_ESP32S3: CAN_SPEEDS_ESP32_S3, VARIANT_ESP32C3: CAN_SPEEDS_ESP32_C3, VARIANT_ESP32C6: CAN_SPEEDS_ESP32_C6, VARIANT_ESP32H2: CAN_SPEEDS_ESP32_H2, VARIANT_ESP32P4: CAN_SPEEDS_ESP32_P4, + VARIANT_ESP32S2: CAN_SPEEDS_ESP32_S2, + VARIANT_ESP32S3: CAN_SPEEDS_ESP32_S3, } diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index f9b63b8ebc..c10ad01450 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -16,8 +16,8 @@ static const char *const TAG = "esp32_can"; static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config) { switch (bitrate) { -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) case canbus::CAN_1KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_1KBITS(); return true; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index ac4f0b2e92..2ec0750ae6 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -77,13 +77,13 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, - esp32_s2=192, - esp32_s3=192, - esp32_p4=192, esp32_c3=96, esp32_c5=96, esp32_c6=96, esp32_h2=96, + esp32_p4=192, + esp32_s2=192, + esp32_s3=192, ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), @@ -91,7 +91,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32S3, esp32.const.VARIANT_ESP32P4] + supported=[esp32.const.VARIANT_ESP32P4, esp32.const.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 9a46aa2687..757e358db3 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -87,8 +87,8 @@ void EthernetComponent::setup() { .intr_flags = 0, }; -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) auto host = SPI2_HOST; #else auto host = SPI3_HOST; diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 907429ee0e..0c7c8f6642 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -68,13 +68,13 @@ I2S_ROLE_OPTIONS = { # https://github.com/espressif/esp-idf/blob/master/components/soc/{variant}/include/soc/soc_caps.h (SOC_I2S_NUM) I2S_PORTS = { VARIANT_ESP32: 2, - VARIANT_ESP32S2: 1, - VARIANT_ESP32S3: 2, VARIANT_ESP32C3: 1, VARIANT_ESP32C5: 1, VARIANT_ESP32C6: 1, VARIANT_ESP32H2: 1, VARIANT_ESP32P4: 3, + VARIANT_ESP32S2: 1, + VARIANT_ESP32S3: 2, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 057247f376..abe50b87f2 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -11,8 +11,8 @@ #ifdef USE_ESP32 #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32H2) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) #include #include #endif diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature.cpp index 28ac55d6de..6365392ce9 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature.cpp @@ -7,9 +7,9 @@ extern "C" { uint8_t temprature_sens_read(); } -#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32P4) +#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "driver/temperature_sensor.h" #endif // USE_ESP32_VARIANT #endif // USE_ESP32 @@ -27,9 +27,9 @@ namespace internal_temperature { static const char *const TAG = "internal_temperature"; #ifdef USE_ESP32 -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32C2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32_VARIANT #endif // USE_ESP32 @@ -43,9 +43,9 @@ void InternalTemperatureSensor::update() { ESP_LOGV(TAG, "Raw temperature value: %d", raw); temperature = (raw - 32) / 1.8f; success = (raw != 128); -#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32P4) +#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); success = (result == ESP_OK); if (!success) { @@ -81,9 +81,9 @@ void InternalTemperatureSensor::update() { void InternalTemperatureSensor::setup() { #ifdef USE_ESP32 -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32C2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &tsensNew); diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index d9ca44d3c9..c81ade8fc3 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -100,14 +100,14 @@ CONF_TASK_LOG_BUFFER_SIZE = "task_log_buffer_size" UART_SELECTION_ESP32 = { VARIANT_ESP32: [UART0, UART1, UART2], - VARIANT_ESP32S2: [UART0, UART1, USB_CDC], - VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], - VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C2: [UART0, UART1], + VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C5: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32S2: [UART0, UART1, USB_CDC], + VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -238,12 +238,12 @@ CONFIG_SCHEMA = cv.All( CONF_HARDWARE_UART, esp8266=UART0, esp32=UART0, - esp32_s2=USB_CDC, - esp32_s3=USB_SERIAL_JTAG, esp32_c3=USB_SERIAL_JTAG, esp32_c5=USB_SERIAL_JTAG, esp32_c6=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, + esp32_s2=USB_CDC, + esp32_s3=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 4ee4e97696..529097889d 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -54,18 +54,18 @@ CONF_ENABLE_ECC = "enable_ecc" SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), + VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), - VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32P4: (TYPE_HEX,), } SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), + VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), - VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32P4: (20, 100, 200), } diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index e79b3f91ed..7f70e2c2a2 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -131,13 +131,13 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, - esp32_s2=192, - esp32_s3=192, - esp32_p4=192, esp32_c3=96, esp32_c5=96, esp32_c6=96, esp32_h2=96, + esp32_p4=192, + esp32_s2=192, + esp32_s3=192, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_FILTER_SYMBOLS): cv.All( cv.only_on_esp32, cv.int_range(min=0) @@ -148,7 +148,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32S3, esp32.const.VARIANT_ESP32P4] + supported=[esp32.const.VARIANT_ESP32P4, esp32.const.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index ff055b959b..ec4f62666d 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -55,20 +55,20 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_EOT_LEVEL): cv.All(cv.only_on_esp32, cv.boolean), cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32S3, esp32.const.VARIANT_ESP32P4] + supported=[esp32.const.VARIANT_ESP32P4, esp32.const.VARIANT_ESP32S3] ), cv.boolean, ), cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=64, - esp32_s2=64, - esp32_s3=48, - esp32_p4=48, esp32_c3=48, esp32_c5=48, esp32_c6=48, esp32_h2=48, + esp32_p4=48, + esp32_s2=64, + esp32_s3=48, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean), cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True), diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d803ee66dc..8f23735fff 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -310,7 +310,7 @@ def spi_mode_schema(mode): if pin_count == 8: onlys.append( only_on_variant( - supported=[VARIANT_ESP32S3, VARIANT_ESP32S2, VARIANT_ESP32P4] + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3] ) ) return cv.All( diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index a2057c90ce..19bb545c4b 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -41,4 +41,4 @@ void TinyUSB::dump_config() { } } // namespace esphome::tinyusb -#endif +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 56c286f455..7d8caade74 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -69,4 +69,4 @@ class TinyUSB : public Component { }; } // namespace esphome::tinyusb -#endif +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 31bdde2df8..d11a148a0f 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,7 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "esphome/core/defines.h" #include "esphome/core/component.h" #include @@ -188,4 +188,4 @@ class USBHost : public Component { } // namespace usb_host } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index fe61353b5d..664f49d137 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -531,4 +531,4 @@ void USBClient::release_trq(TransferRequest *trq) { } // namespace usb_host } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 1e70c289df..790fe6713b 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_host.h" #include #include "esphome/core/log.h" @@ -31,4 +31,4 @@ void USBHost::loop() { } // namespace usb_host } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 889366b579..caa4b65657 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -78,4 +78,4 @@ void USBUartTypeCH34X::enable_channels() { } } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5fec0bed02..be024d1ba2 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -123,4 +123,4 @@ void USBUartTypeCP210X::enable_channels() { } } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index fefccd3645..edd01c26c6 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -392,4 +392,4 @@ void USBUartTypeCdcAcm::enable_channels() { } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a5e7905ac5..96c17bd155 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart_component.h" @@ -173,4 +173,4 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 12dfdba5ce..5d3bca55a2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -233,9 +233,9 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC -#elif defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) +#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S3) #define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif diff --git a/platformio.ini b/platformio.ini index 94f58f84ab..81f8b3295b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -378,6 +378,18 @@ build_flags = build_unflags = ${common.build_unflags} +;;;;;;;; ESP32-P4 ;;;;;;;; + +[env:esp32p4-idf] +extends = common:esp32-idf +board = esp32-p4-evboard + +board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf +build_flags = + ${common:esp32-idf.build_flags} + ${flags:runtime.build_flags} + -DUSE_ESP32_VARIANT_ESP32P4 + ;;;;;;;; ESP32-S2 ;;;;;;;; [env:esp32s2-arduino] @@ -466,18 +478,6 @@ build_flags = build_unflags = ${common.build_unflags} -;;;;;;;; ESP32-P4 ;;;;;;;; - -[env:esp32p4-idf] -extends = common:esp32-idf -board = esp32-p4-evboard - -board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf -build_flags = - ${common:esp32-idf.build_flags} - ${flags:runtime.build_flags} - -DUSE_ESP32_VARIANT_ESP32P4 - ;;;;;;;; RP2040 ;;;;;;;; [env:rp2040-pico-arduino] diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 56a52df2ab..0c7dea2286 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -304,14 +304,14 @@ def test_all_predefined_models( config = {"model": name} # Get the pins required by this model and find a compatible variant - pins = [ - pin - for pin in [ - model.get_default(pin, None) - for pin in ("dc_pin", "reset_pin", "cs_pin") - ] - if pin is not None - ] + pins = [] + for pin_name in ("dc_pin", "reset_pin", "cs_pin", "enable_pin"): + pin_value = model.get_default(pin_name, None) + if pin_value is not None: + if isinstance(pin_value, list): + pins.extend(pin_value) + else: + pins.append(pin_value) choose_variant_with_pins(pins) # Add required fields that don't have defaults diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 104cdc2b7a..73b15aaadf 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -251,15 +251,6 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "host": "24", } - idf_mappings = { - "esp32_idf": "4", - "esp32_s2_idf": "7", - "esp32_s3_idf": "10", - "esp32_c3_idf": "13", - "esp32_c6_idf": "16", - "esp32_h2_idf": "19", - } - arduino_mappings = { "esp32_arduino": "3", "esp32_s2_arduino": "6", @@ -269,6 +260,15 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_h2_arduino": "18", } + idf_mappings = { + "esp32_idf": "4", + "esp32_s2_idf": "7", + "esp32_s3_idf": "10", + "esp32_c3_idf": "13", + "esp32_c6_idf": "16", + "esp32_h2_idf": "19", + } + schema = config_validation.Schema( { config_validation.SplitDefault( @@ -293,8 +293,8 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) @pytest.mark.parametrize( "framework, platform, message", [ - ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), + ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), From 951c5377c5127b89f06ff2965ea194b87c879925 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 02:25:13 -0500 Subject: [PATCH 239/320] [ld2420] Add missing USE_SELECT ifdefs (#12275) Co-authored-by: Claude --- esphome/components/ld2420/ld2420.cpp | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index 4fca9494aa..10c623bce0 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -204,8 +204,10 @@ void LD2420Component::dump_config() { LOG_BUTTON(" ", "Factory Reset:", this->factory_reset_button_); LOG_BUTTON(" ", "Restart Module:", this->restart_module_button_); #endif +#ifdef USE_SELECT ESP_LOGCONFIG(TAG, "Select:"); LOG_SELECT(" ", "Operating Mode", this->operating_selector_); +#endif if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) { ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_); } @@ -237,12 +239,20 @@ void LD2420Component::setup() { memcpy(&this->new_config, &this->current_config, sizeof(this->current_config)); if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) { this->set_operating_mode(OP_SIMPLE_MODE_STRING); - this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); + } +#endif this->set_mode_(CMD_SYSTEM_MODE_SIMPLE); ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_); } else { this->set_mode_(CMD_SYSTEM_MODE_ENERGY); - this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING); + } +#endif } #ifdef USE_NUMBER this->init_gate_config_numbers(); @@ -382,8 +392,12 @@ void LD2420Component::set_operating_mode(const char *state) { // If unsupported firmware ignore mode select if (ld2420::get_firmware_int(firmware_ver_) >= CALIBRATE_VERSION_MIN) { this->current_operating_mode = find_uint8(OP_MODE_BY_STR, state); - // Entering Auto Calibrate we need to clear the privoiuos data collection - this->operating_selector_->publish_state(state); + // Entering Auto Calibrate we need to clear the previous data collection +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(state); + } +#endif if (current_operating_mode == OP_CALIBRATE_MODE) { this->set_calibration_(true); for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) { @@ -403,7 +417,11 @@ void LD2420Component::set_operating_mode(const char *state) { } } else { this->current_operating_mode = OP_SIMPLE_MODE; - this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); + } +#endif } } From 2af66bd6fceb27ba73f64b8bab6f0e22c3364ac6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:20:55 +1100 Subject: [PATCH 240/320] [config] Provide path for `has_at_most_one_of` messages (#12277) --- esphome/config_validation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a3fd271a86..ee926b1b6d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -740,9 +740,10 @@ def has_at_most_one_key(*keys): if not isinstance(obj, dict): raise Invalid("expected dictionary") - number = sum(k in keys for k in obj) - if number > 1: - raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.") + used = set(obj) & set(keys) + if len(used) > 1: + msg = "Cannot specify more than one of '" + "', '".join(used) + "'." + raise MultipleInvalid([Invalid(msg, path=[k]) for k in used]) return obj return validate From 37019231de98f48cb26208481c80d2ada945c56a Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Thu, 4 Dec 2025 10:18:27 +0100 Subject: [PATCH 241/320] [lvgl] refactor hello world to yaml file (#12274) --- esphome/components/lvgl/__init__.py | 7 +- esphome/components/lvgl/hello_world.py | 127 ----------------------- esphome/components/lvgl/hello_world.yaml | 118 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 129 deletions(-) delete mode 100644 esphome/components/lvgl/hello_world.py create mode 100644 esphome/components/lvgl/hello_world.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 040661495c..19c258fcd5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,5 +1,6 @@ import importlib import logging +from pathlib import Path import pkgutil from esphome.automation import build_automation, validate_automation @@ -26,6 +27,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets from .automation import disp_update, focused_widgets, refreshed_widgets @@ -37,7 +39,6 @@ from .encoders import ( initial_focus_to_code, ) from .gradient import GRADIENT_SCHEMA, gradients_to_code -from .hello_world import get_hello_world from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool, lv_images_used from .lvcode import LvContext, LvglComponent, lvgl_static @@ -84,6 +85,7 @@ DEPENDENCIES = ["display"] AUTO_LOAD = ["key_provider"] CODEOWNERS = ["@clydebarrow"] LOGGER = logging.getLogger(__name__) +HELLO_WORLD_FILE = "hello_world.yaml" SIMPLE_TRIGGERS = ( @@ -354,7 +356,8 @@ def display_schema(config): def add_hello_world(config): if df.CONF_WIDGETS not in config and CONF_PAGES not in config: LOGGER.info("No pages or widgets configured, creating default hello_world page") - config[df.CONF_WIDGETS] = any_widget_schema()(get_hello_world()) + hello_world_path = Path(__file__).parent / HELLO_WORLD_FILE + config[df.CONF_WIDGETS] = any_widget_schema()(load_yaml(hello_world_path)) return config diff --git a/esphome/components/lvgl/hello_world.py b/esphome/components/lvgl/hello_world.py deleted file mode 100644 index f85da9d8e4..0000000000 --- a/esphome/components/lvgl/hello_world.py +++ /dev/null @@ -1,127 +0,0 @@ -from io import StringIO - -from esphome.yaml_util import parse_yaml - -CONFIG = """ -- obj: - id: hello_world_card_ - pad_all: 12 - bg_color: white - height: 100% - width: 100% - scrollable: false - widgets: - - obj: - align: top_mid - outline_width: 0 - border_width: 0 - pad_all: 4 - scrollable: false - height: size_content - width: 100% - layout: - type: flex - flex_flow: row - flex_align_cross: center - flex_align_track: start - flex_align_main: space_between - widgets: - - button: - checkable: true - radius: 4 - text_font: montserrat_20 - on_click: - lvgl.label.update: - id: hello_world_label_ - text: "Clicked!" - widgets: - - label: - text: "Button" - - label: - id: hello_world_title_ - text: ESPHome - text_font: montserrat_20 - width: 100% - text_align: center - on_boot: - lvgl.widget.refresh: hello_world_title_ - hidden: !lambda |- - return lv_obj_get_width(lv_scr_act()) < 400; - - checkbox: - text: Checkbox - id: hello_world_checkbox_ - on_boot: - lvgl.widget.refresh: hello_world_checkbox_ - hidden: !lambda |- - return lv_obj_get_width(lv_scr_act()) < 240; - on_click: - lvgl.label.update: - id: hello_world_label_ - text: "Checked!" - - obj: - id: hello_world_container_ - align: center - y: 14 - pad_all: 0 - outline_width: 0 - border_width: 0 - width: 100% - height: size_content - scrollable: false - on_click: - lvgl.spinner.update: - id: hello_world_spinner_ - arc_color: springgreen - layout: - type: flex - flex_flow: row_wrap - flex_align_cross: center - flex_align_track: center - flex_align_main: space_evenly - widgets: - - spinner: - id: hello_world_spinner_ - indicator: - arc_color: tomato - height: 100 - width: 100 - spin_time: 2s - arc_length: 60deg - widgets: - - label: - id: hello_world_label_ - text: "Hello World!" - align: center - - obj: - id: hello_world_qrcode_ - outline_width: 0 - border_width: 0 - hidden: !lambda |- - return lv_obj_get_width(lv_scr_act()) < 300 && lv_obj_get_height(lv_scr_act()) < 400; - widgets: - - label: - text_font: montserrat_14 - text: esphome.io - align: top_mid - - qrcode: - text: "https://esphome.io" - size: 80 - align: bottom_mid - on_boot: - lvgl.widget.refresh: hello_world_qrcode_ - - - slider: - width: 80% - align: bottom_mid - on_value: - lvgl.label.update: - id: hello_world_label_ - text: - format: "%.0f%%" - args: [x] -""" - - -def get_hello_world(): - with StringIO(CONFIG) as fp: - return parse_yaml("hello_world", fp) diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml new file mode 100644 index 0000000000..359e73cd52 --- /dev/null +++ b/esphome/components/lvgl/hello_world.yaml @@ -0,0 +1,118 @@ +# This file defines a placeholder LVGL "Hello World" that is shown when no +# widgets are configured. +- obj: + id: hello_world_card_ + pad_all: 12 + bg_color: white + height: 100% + width: 100% + scrollable: false + widgets: + - obj: + align: top_mid + outline_width: 0 + border_width: 0 + pad_all: 4 + scrollable: false + height: size_content + width: 100% + layout: + type: flex + flex_flow: row + flex_align_cross: center + flex_align_track: start + flex_align_main: space_between + widgets: + - button: + checkable: true + radius: 4 + text_font: montserrat_20 + on_click: + lvgl.label.update: + id: hello_world_label_ + text: "Clicked!" + widgets: + - label: + text: "Button" + - label: + id: hello_world_title_ + text: ESPHome + text_font: montserrat_20 + width: 100% + text_align: center + on_boot: + lvgl.widget.refresh: hello_world_title_ + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 400; + - checkbox: + text: Checkbox + id: hello_world_checkbox_ + on_boot: + lvgl.widget.refresh: hello_world_checkbox_ + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 240; + on_click: + lvgl.label.update: + id: hello_world_label_ + text: "Checked!" + - obj: + id: hello_world_container_ + align: center + y: 14 + pad_all: 0 + outline_width: 0 + border_width: 0 + width: 100% + height: size_content + scrollable: false + on_click: + lvgl.spinner.update: + id: hello_world_spinner_ + arc_color: springgreen + layout: + type: flex + flex_flow: row_wrap + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - spinner: + id: hello_world_spinner_ + indicator: + arc_color: tomato + height: 100 + width: 100 + spin_time: 2s + arc_length: 60deg + widgets: + - label: + id: hello_world_label_ + text: "Hello World!" + align: center + - obj: + id: hello_world_qrcode_ + outline_width: 0 + border_width: 0 + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 300 && lv_obj_get_height(lv_scr_act()) < 400; + widgets: + - label: + text_font: montserrat_14 + text: esphome.io + align: top_mid + - qrcode: + text: "https://esphome.io" + size: 80 + align: bottom_mid + on_boot: + lvgl.widget.refresh: hello_world_qrcode_ + + - slider: + width: 80% + align: bottom_mid + on_value: + lvgl.label.update: + id: hello_world_label_ + text: + format: "%.0f%%" + args: [x] From a31fb223f3c5fecc4a78d5ba828e14e741fc0840 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:00:45 -0500 Subject: [PATCH 242/320] [es8311] Remove MIN and MAX from mic_gain enum options (#12281) Co-authored-by: Claude --- esphome/components/es8311/audio_dac.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 7d80cfd5fb..5941a81935 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -22,7 +22,6 @@ ES8311_BITS_PER_SAMPLE_ENUM = { es8311_mic_gain = es8311_ns.enum("ES8311MicGain") ES8311_MIC_GAIN_ENUM = { - "MIN": es8311_mic_gain.ES8311_MIC_GAIN_MIN, "0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB, "6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB, "12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB, @@ -31,7 +30,6 @@ ES8311_MIC_GAIN_ENUM = { "30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB, "36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB, "42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB, - "MAX": es8311_mic_gain.ES8311_MIC_GAIN_MAX, } From cafa275579f99ae78a2b89a22206ecfe83e109d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:47:21 -0500 Subject: [PATCH 243/320] [esp32_hosted] Fix build and bump IDF component version to 2.7.0 (#12282) Co-authored-by: Claude --- esphome/components/esp32_hosted/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index fde75517eb..9c9d1d4bb4 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -93,9 +93,9 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" if framework_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.2.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.6.1") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.7.0") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") From 0da157ab98a7472475d8f5d05009c6ae18bd175f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:14:30 -0500 Subject: [PATCH 244/320] [tests] Bump esp32_hosted in the test code (#12289) Co-authored-by: Claude --- esphome/idf_component.yml | 4 ++-- tests/components/esp32/test.esp32-p4-idf.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b27b6b8ed1..9bb5967248 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -6,7 +6,7 @@ dependencies: espressif/mdns: version: 1.9.1 espressif/esp_wifi_remote: - version: 1.1.5 + version: 1.2.2 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: @@ -14,7 +14,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.6.1 + version: 2.7.0 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index 1c243ef459..00a4ceec27 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -7,7 +7,7 @@ esp32: components: - espressif/mdns^1.8.2 - name: espressif/esp_hosted - ref: 2.6.6 + ref: 2.7.0 advanced: enable_idf_experimental_features: yes From 4db77488157b5b64f280cadad4cd114853a0f40f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:53:36 +0000 Subject: [PATCH 245/320] Bump ruff from 0.14.7 to 0.14.8 (#12286) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 412a678d02..49b87866f1 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.14.7 + rev: v0.14.8 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index 9d55d23272..16ac131517 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.7 # also change in .pre-commit-config.yaml when updating +ruff==0.14.8 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 8caaf53ef0ef4eeacf824d91f2d7031b7fba903f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:53:13 +1300 Subject: [PATCH 246/320] [CI] Update renamed action repo (#12290) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ef6b4341c..01689d3697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -668,7 +668,7 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: SKIP: pylint,clang-tidy-hash - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 From 78b2ae8a352f4e604277ffd7bb4deeb8737fc925 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:00:08 +1300 Subject: [PATCH 247/320] [CI] Trigger generic version notifier job on release (#12292) --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d52595bbb3..51aa1f885e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -219,10 +219,19 @@ jobs: - init - deploy-manifest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: home-assistant-addon + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | let description = "ESPHome"; if (context.eventName == "release") { @@ -245,10 +254,19 @@ jobs: needs: [init] environment: ${{ needs.init.outputs.deploy_env }} steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: esphome-schema + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | github.rest.actions.createWorkflowDispatch({ owner: "esphome", @@ -259,3 +277,34 @@ jobs: version: "${{ needs.init.outputs.tag }}", } }) + + version-notifier: + if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' + runs-on: ubuntu-latest + needs: + - init + - deploy-manifest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: version-notifier + + - name: Trigger Workflow + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + github.rest.actions.createWorkflowDispatch({ + owner: "esphome", + repo: "version-notifier", + workflow_id: "notify.yml", + ref: "main", + inputs: { + version: "${{ needs.init.outputs.tag }}", + } + }) From 80e881655fbffc6140e9bf13bf1ba93a8fb2439e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 01:14:22 +0000 Subject: [PATCH 248/320] [scheduler] Fix use-after-free when cancelling timeouts from non-main-loop threads (#12288) --- esphome/core/scheduler.cpp | 33 ++++++++++++++------------------- esphome/core/scheduler.h | 8 +++++--- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 352587bf10..5e313f770f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -315,7 +315,7 @@ void Scheduler::full_cleanup_removed_items_() { valid_items.push_back(std::move(item)); } else { // Recycle removed items - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } } @@ -400,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) { // Don't run on failed components if (item->component != nullptr && item->component->is_failed()) { LockGuard guard{this->lock_}; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; } @@ -413,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -422,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) { // Single-threaded or multi-threaded with atomics: can check without lock if (is_item_removed_(item.get())) { LockGuard guard{this->lock_}; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -449,7 +449,7 @@ void HOT Scheduler::call(uint32_t now) { if (executed_item->remove) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); continue; } @@ -460,7 +460,7 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(executed_item)); } else { // Timeout completed - recycle it - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); } has_added_items |= !this->to_add_.empty(); @@ -475,7 +475,7 @@ void HOT Scheduler::process_to_add() { for (auto &it : this->to_add_) { if (is_item_removed_(it.get())) { // Recycle cancelled items - this->recycle_item_(std::move(it)); + this->recycle_item_main_loop_(std::move(it)); continue; } @@ -509,7 +509,7 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); } return this->items_.size(); } @@ -562,20 +562,15 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #endif /* not ESPHOME_THREAD_SINGLE */ // Cancel items in the main heap - // Special case: if the last item in the heap matches, we can remove it immediately - // (removing the last element doesn't break heap structure) + // We only mark items for removal here - never recycle directly. + // The main loop may be executing an item's callback right now, and recycling + // would destroy the callback while it's running (use-after-free). + // Only the main loop in call() should recycle items after execution completes. if (!this->items_.empty()) { - auto &last_item = this->items_.back(); - if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) { - this->recycle_item_(std::move(this->items_.back())); - this->items_.pop_back(); - total_cancelled++; - } - // For other items in heap, we can only mark for removal (can't remove from middle of heap) size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry); total_cancelled += heap_cancelled; - this->to_remove_ += heap_cancelled; // Track removals for heap items + this->to_remove_ += heap_cancelled; } // Cancel items in to_add_ @@ -749,7 +744,7 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, : (a->next_execution_high_ > b->next_execution_high_); } -void Scheduler::recycle_item_(std::unique_ptr item) { +void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { if (!item) return; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 08e003c9fb..dcf418c14f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -272,8 +272,10 @@ class Scheduler { return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); } - // Helper to recycle a SchedulerItem - void recycle_item_(std::unique_ptr item); + // Helper to recycle a SchedulerItem back to the pool. + // IMPORTANT: Only call from main loop context! Recycling clears the callback, + // so calling from another thread while the callback is executing causes use-after-free. + void recycle_item_main_loop_(std::unique_ptr item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -329,7 +331,7 @@ class Scheduler { now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } // If we've consumed all items up to the snapshot point, clean up the dead space From 637cb3f04a9fc0d1efe3bbd1c229c23e36f79ed2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 01:14:35 +0000 Subject: [PATCH 249/320] [api] Use loop-based reboot timeout check to avoid scheduler heap churn (#12291) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 38 ++++++++++++++------------- esphome/components/api/api_server.h | 2 +- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 4168761c74..565714a4e5 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -52,11 +52,6 @@ void APIServer::setup() { #endif #endif - // Schedule reboot if no clients connect within timeout - if (this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); - } - this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->socket_ == nullptr) { ESP_LOGW(TAG, "Could not create socket"); @@ -110,16 +105,13 @@ void APIServer::setup() { camera::Camera::instance()->add_listener(this); } #endif -} -void APIServer::schedule_reboot_timeout_() { - this->status_set_warning(); - this->set_timeout("api_reboot", this->reboot_timeout_, []() { - if (!global_api_server->is_connected()) { - ESP_LOGE(TAG, "No clients; rebooting"); - App.reboot(); - } - }); + // Initialize last_connected_ for reboot timeout tracking + this->last_connected_ = App.get_loop_component_start_time(); + // Set warning status if reboot timeout is enabled + if (this->reboot_timeout_ != 0) { + this->status_set_warning(); + } } void APIServer::loop() { @@ -147,15 +139,24 @@ void APIServer::loop() { this->clients_.emplace_back(conn); conn->start(); - // Clear warning status and cancel reboot when first client connects + // First client connected - clear warning and update timestamp if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); - this->cancel_timeout("api_reboot"); + this->last_connected_ = App.get_loop_component_start_time(); } } } if (this->clients_.empty()) { + // Check reboot timeout - done in loop to avoid scheduler heap churn + // (cancelled scheduler items sit in heap memory until their scheduled time) + if (this->reboot_timeout_ != 0) { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_connected_ > this->reboot_timeout_) { + ESP_LOGE(TAG, "No clients; rebooting"); + App.reboot(); + } + } return; } @@ -194,9 +195,10 @@ void APIServer::loop() { } this->clients_.pop_back(); - // Schedule reboot when last client disconnects + // Last client disconnected - set warning and start tracking for reboot timeout if (this->clients_.empty() && this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); + this->status_set_warning(); + this->last_connected_ = App.get_loop_component_start_time(); } // Don't increment client_index since we need to process the swapped element } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 3089bb1d35..eb495afde7 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -202,7 +202,6 @@ class APIServer : public Component, #endif protected: - void schedule_reboot_timeout_(); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, const psk_t &active_psk, bool make_active); @@ -218,6 +217,7 @@ class APIServer : public Component, // 4-byte aligned types uint32_t reboot_timeout_{300000}; + uint32_t last_connected_{0}; // Vectors and strings (12 bytes each on 32-bit) std::vector> clients_; From 22481d9c0e07c8b58b29e4a53a845ee063add179 Mon Sep 17 00:00:00 2001 From: Citizen07 <34106434+Citizen07@users.noreply.github.com> Date: Fri, 5 Dec 2025 05:50:23 +0200 Subject: [PATCH 250/320] [remote_receiver] buffer usage fix and idle optimizations (#9999) Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../remote_receiver/remote_receiver.cpp | 181 ++++++++++-------- .../remote_receiver/remote_receiver.h | 32 ++-- .../remote_receiver/remote_receiver_esp32.cpp | 6 +- 3 files changed, 120 insertions(+), 99 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index 53bfb0890f..a7ac74199d 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -5,63 +5,79 @@ #if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) -namespace esphome { -namespace remote_receiver { +namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver"; +static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_t delta, bool level) { + // convert level to -1 or +1 and write the delta to the buffer + int32_t multiplier = ((int32_t) level << 1) - 1; + uint32_t buffer_write = arg->buffer_write; + arg->buffer[buffer_write++] = (int32_t) delta * multiplier; + if (buffer_write >= arg->buffer_size) { + buffer_write = 0; + } + + // detect overflow and reset the write pointer + if (buffer_write == arg->buffer_read) { + buffer_write = arg->buffer_start; + arg->overflow = true; + } + + // detect idle and start a new sequence unless there is only idle in + // which case reset the write pointer instead + if (delta >= arg->idle_us) { + if (arg->buffer_write == arg->buffer_start) { + buffer_write = arg->buffer_start; + } else { + arg->buffer_start = buffer_write; + } + } + arg->buffer_write = buffer_write; +} + +static void IRAM_ATTR HOT commit_value(RemoteReceiverComponentStore *arg, uint32_t micros, bool level) { + // commit value if the level is different from the last commit level + if (level != arg->commit_level) { + write_value(arg, micros - arg->commit_micros, level); + arg->commit_micros = micros; + arg->commit_level = level; + } +} + void IRAM_ATTR HOT RemoteReceiverComponentStore::gpio_intr(RemoteReceiverComponentStore *arg) { - const uint32_t now = micros(); - // If the lhs is 1 (rising edge) we should write to an uneven index and vice versa - const uint32_t next = (arg->buffer_write_at + 1) % arg->buffer_size; - const bool level = arg->pin.digital_read(); - if (level != next % 2) - return; + // invert the level so it matches the level of the signal before the edge + const bool curr_level = !arg->pin.digital_read(); + const uint32_t curr_micros = micros(); + const bool prev_level = arg->prev_level; + const uint32_t prev_micros = arg->prev_micros; - // If next is buffer_read, we have hit an overflow - if (next == arg->buffer_read_at) - return; - - const uint32_t last_change = arg->buffer[arg->buffer_write_at]; - const uint32_t time_since_change = now - last_change; - if (time_since_change <= arg->filter_us) - return; - - arg->buffer[arg->buffer_write_at = next] = now; // NOLINT(clang-diagnostic-deprecated-volatile) + // commit the previous value if the pulse is not filtered and the level is different + if (curr_micros - prev_micros >= arg->filter_us && prev_level != curr_level) { + commit_value(arg, prev_micros, prev_level); + } + arg->prev_micros = curr_micros; + arg->prev_level = curr_level; } void RemoteReceiverComponent::setup() { this->pin_->setup(); - auto &s = this->store_; - s.filter_us = this->filter_us_; - s.pin = this->pin_->to_isr(); - s.buffer_size = this->buffer_size_; - - this->high_freq_.start(); - if (s.buffer_size % 2 != 0) { - // Make sure divisible by two. This way, we know that every 0bxxx0 index is a space and every 0bxxx1 index is a mark - s.buffer_size++; - } - - s.buffer = new uint32_t[s.buffer_size]; - void *buf = (void *) s.buffer; - memset(buf, 0, s.buffer_size * sizeof(uint32_t)); - - // First index is a space. - if (this->pin_->digital_read()) { - s.buffer_write_at = s.buffer_read_at = 1; - } else { - s.buffer_write_at = s.buffer_read_at = 0; - } + this->store_.idle_us = this->idle_us_; + this->store_.filter_us = this->filter_us_; + this->store_.pin = this->pin_->to_isr(); + this->store_.buffer = new int32_t[this->buffer_size_]; + this->store_.buffer_size = this->buffer_size_; + this->store_.prev_micros = micros(); + this->store_.commit_micros = this->store_.prev_micros; + this->store_.prev_level = this->pin_->digital_read(); + this->store_.commit_level = this->store_.prev_level; this->pin_->attach_interrupt(RemoteReceiverComponentStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE); + this->high_freq_.start(); } + void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:"); LOG_PIN(" Pin: ", this->pin_); - if (this->pin_->digital_read()) { - ESP_LOGW(TAG, "Remote Receiver Signal starts with a HIGH value. Usually this means you have to " - "invert the signal using 'inverted: True' in the pin schema!"); - } ESP_LOGCONFIG(TAG, " Buffer Size: %u\n" " Tolerance: %u%s\n" @@ -73,53 +89,54 @@ void RemoteReceiverComponent::dump_config() { } void RemoteReceiverComponent::loop() { + // check for overflow auto &s = this->store_; - - // copy write at to local variables, as it's volatile - const uint32_t write_at = s.buffer_write_at; - const uint32_t dist = (s.buffer_size + write_at - s.buffer_read_at) % s.buffer_size; - // signals must at least one rising and one leading edge - if (dist <= 1) - return; - const uint32_t now = micros(); - if (now - s.buffer[write_at] < this->idle_us_) { - // The last change was fewer than the configured idle time ago. - return; + if (s.overflow) { + ESP_LOGW(TAG, "Buffer overflow"); + s.overflow = false; } - ESP_LOGVV(TAG, "read_at=%u write_at=%u dist=%u now=%u end=%u", s.buffer_read_at, write_at, dist, now, - s.buffer[write_at]); - - // Skip first value, it's from the previous idle level - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - uint32_t prev = s.buffer_read_at; - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - const uint32_t reserve_size = 1 + (s.buffer_size + write_at - s.buffer_read_at) % s.buffer_size; - this->temp_.clear(); - this->temp_.reserve(reserve_size); - int32_t multiplier = s.buffer_read_at % 2 == 0 ? 1 : -1; - - for (uint32_t i = 0; prev != write_at; i++) { - int32_t delta = s.buffer[s.buffer_read_at] - s.buffer[prev]; - if (uint32_t(delta) >= this->idle_us_) { - // already found a space longer than idle. There must have been two pulses - break; + // if no data is available check for uncommitted data stuck in the buffer and commit + // the previous value if needed + uint32_t last_index = s.buffer_start; + if (last_index == s.buffer_read) { + InterruptLock lock; + if (s.buffer_read == s.buffer_start && s.buffer_write != s.buffer_start && + micros() - s.prev_micros >= this->idle_us_) { + commit_value(&s, s.prev_micros, s.prev_level); + write_value(&s, s.idle_us, !s.commit_level); + last_index = s.buffer_start; } - - ESP_LOGVV(TAG, " i=%u buffer[%u]=%u - buffer[%u]=%u -> %d", i, s.buffer_read_at, s.buffer[s.buffer_read_at], prev, - s.buffer[prev], multiplier * delta); - this->temp_.push_back(multiplier * delta); - prev = s.buffer_read_at; - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - multiplier *= -1; } - s.buffer_read_at = (s.buffer_size + s.buffer_read_at - 1) % s.buffer_size; - this->temp_.push_back(this->idle_us_ * multiplier); + if (last_index == s.buffer_read) { + return; + } + // find the size of the packet and reserve the memory + uint32_t temp_read = s.buffer_read; + uint32_t reserve_size = 0; + while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { + reserve_size++; + temp_read++; + if (temp_read >= s.buffer_size) { + temp_read = 0; + } + } + this->temp_.clear(); + this->temp_.reserve(reserve_size + 1); + + // read the buffer + for (uint32_t i = 0; i < reserve_size + 1; i++) { + this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); + if (s.buffer_read >= s.buffer_size) { + s.buffer_read = 0; + } + } + + // call the listeners and dumpers this->call_listeners_dumpers_(); } -} // namespace remote_receiver -} // namespace esphome +} // namespace esphome::remote_receiver #endif diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 3d2f7f0ef9..fabf0a481a 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -9,25 +9,31 @@ #include #endif -namespace esphome { -namespace remote_receiver { +namespace esphome::remote_receiver { #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); - /// Stores the time (in micros) that the leading/falling edge happened at - /// * An even index means a falling edge appeared at the time stored at the index - /// * An uneven index means a rising edge appeared at the time stored at the index - volatile uint32_t *buffer{nullptr}; + /// Stores pulse durations in microseconds as signed integers + /// * Positive values indicate high pulses (marks) + /// * Negative values indicate low pulses (spaces) + volatile int32_t *buffer{nullptr}; /// The position last written to - volatile uint32_t buffer_write_at; + volatile uint32_t buffer_write{0}; + /// The start position of the last sequence + volatile uint32_t buffer_start{0}; /// The position last read from - uint32_t buffer_read_at{0}; - bool overflow{false}; + uint32_t buffer_read{0}; + volatile uint32_t commit_micros{0}; + volatile uint32_t prev_micros{0}; uint32_t buffer_size{1000}; uint32_t filter_us{10}; + uint32_t idle_us{10000}; ISRInternalGPIOPin pin; + volatile bool commit_level{false}; + volatile bool prev_level{false}; + volatile bool overflow{false}; }; #elif defined(USE_ESP32) struct RemoteReceiverComponentStore { @@ -84,15 +90,15 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, std::string error_string_{""}; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_ESP32) || defined(USE_RP2040) - RemoteReceiverComponentStore store_; +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) HighFrequencyLoopRequester high_freq_; #endif + RemoteReceiverComponentStore store_; + uint32_t buffer_size_{}; uint32_t filter_us_{10}; uint32_t idle_us_{10000}; }; -} // namespace remote_receiver -} // namespace esphome +} // namespace esphome::remote_receiver diff --git a/esphome/components/remote_receiver/remote_receiver_esp32.cpp b/esphome/components/remote_receiver/remote_receiver_esp32.cpp index 49358eef3f..bd0bc8e57b 100644 --- a/esphome/components/remote_receiver/remote_receiver_esp32.cpp +++ b/esphome/components/remote_receiver/remote_receiver_esp32.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace remote_receiver { +namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver.esp32"; #ifdef USE_ESP32_VARIANT_ESP32H2 @@ -248,7 +247,6 @@ void RemoteReceiverComponent::decode_rmt_(rmt_symbol_word_t *item, size_t item_c } } -} // namespace remote_receiver -} // namespace esphome +} // namespace esphome::remote_receiver #endif From 19fa76873070356fda4b277bfee7b3db152e9b49 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 6 Dec 2025 02:48:04 +1300 Subject: [PATCH 251/320] Update readme logo (#12294) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0439b1bc06..b8ce8d091d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ - - ESPHome Logo + + ESPHome Logo From 7fd79fdded14b48e9a6cc2b3bba2191a749972f1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sat, 6 Dec 2025 03:53:08 +1300 Subject: [PATCH 252/320] [esp32] Change imports to use esp32 only, not .const (#12243) --- esphome/components/adc/__init__.py | 5 +++-- esphome/components/bme680_bsec/__init__.py | 2 +- esphome/components/deep_sleep/__init__.py | 4 ++-- esphome/components/esp32_can/canbus.py | 4 ++-- esphome/components/esp32_dac/output.py | 3 +-- esphome/components/esp32_hosted/update/__init__.py | 4 ++-- esphome/components/esp32_rmt/__init__.py | 2 +- esphome/components/esp32_rmt_led_strip/light.py | 2 +- esphome/components/esp32_touch/__init__.py | 11 ++++++----- esphome/components/ethernet/__init__.py | 8 +++----- esphome/components/i2s_audio/__init__.py | 5 +++-- esphome/components/i2s_audio/media_player/__init__.py | 2 +- esphome/components/i2s_audio/microphone/__init__.py | 4 ++-- esphome/components/i2s_audio/speaker/__init__.py | 2 +- esphome/components/improv_serial/__init__.py | 3 +-- esphome/components/logger/__init__.py | 5 +++-- esphome/components/mipi_dsi/display.py | 4 ++-- esphome/components/mipi_rgb/display.py | 4 ++-- esphome/components/neopixelbus/_methods.py | 4 ++-- esphome/components/neopixelbus/light.py | 3 +-- esphome/components/psram/__init__.py | 6 ++---- esphome/components/remote_receiver/__init__.py | 4 ++-- esphome/components/remote_transmitter/__init__.py | 2 +- esphome/components/rpi_dpi_rgb/display.py | 4 ++-- esphome/components/spi/__init__.py | 4 ++-- esphome/components/st7701s/display.py | 4 ++-- esphome/components/tinyusb/__init__.py | 5 +++-- esphome/config_validation.py | 3 +-- tests/component_tests/esp32/test_esp32.py | 3 +-- tests/component_tests/psram/test_psram.py | 2 +- tests/unit_tests/test_config_validation.py | 4 ++-- tests/unit_tests/test_main.py | 2 +- 32 files changed, 60 insertions(+), 64 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 8f751c496e..62c1a5fffa 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,15 +1,16 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 8a8d74b5f3..06e641d34d 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -69,7 +69,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp8266, cv.All( cv.only_on_esp32, - esp32.only_on_variant(supported=[esp32.const.VARIANT_ESP32]), + esp32.only_on_variant(supported=[esp32.VARIANT_ESP32]), ), ), ) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 18ba167952..75affd7843 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -1,8 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, time -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, @@ -10,6 +9,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32H2, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 5cee27506a..8708c6fb36 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -4,8 +4,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C6, @@ -13,6 +12,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import ( diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index cf4f12c46d..daace596d3 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -1,8 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 +from esphome.components.esp32 import VARIANT_ESP32, VARIANT_ESP32S2, get_esp32_variant import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_NUMBER, CONF_PIN diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 040f989a64..fff0d3623a 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -40,8 +40,8 @@ CONFIG_SCHEMA = cv.All( ), esp32.only_on_variant( supported=[ - esp32.const.VARIANT_ESP32H2, - esp32.const.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H2, + esp32.VARIANT_ESP32P4, ] ), ) diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1e72185e3e..272c7c81ba 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -9,7 +9,7 @@ def validate_clock_resolution(): cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() - if variant == esp32.const.VARIANT_ESP32H2 and value > 32000000: + if variant == esp32.VARIANT_ESP32H2 and value > 32000000: raise cv.Invalid( f"ESP32 variant {variant} has a max clock_resolution of 32000000." ) diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 2ec0750ae6..f020d02e86 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -91,7 +91,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32P4, esp32.const.VARIANT_ESP32S3] + supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index b6cb19ebb1..c54ed8b9ea 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -1,10 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.esp32 import get_esp32_variant, gpio -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, + gpio, ) import esphome.config_validation as cv from esphome.const import ( @@ -255,9 +256,9 @@ CONFIG_SCHEMA = cv.All( cv.has_none_or_all_keys(CONF_WATERPROOF_GUARD_RING, CONF_WATERPROOF_SHIELD_DRIVER), esp32.only_on_variant( supported=[ - esp32.const.VARIANT_ESP32, - esp32.const.VARIANT_ESP32S2, - esp32.const.VARIANT_ESP32S3, + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S2, + esp32.VARIANT_ESP32S3, ] ), validate_variant_vars, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index b4d67635c1..39af1ff4b9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -3,16 +3,14 @@ import logging from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( - add_idf_component, - add_idf_sdkconfig_option, - get_esp32_variant, -) -from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_component, + add_idf_sdkconfig_option, + get_esp32_variant, ) from esphome.components.network import ip_address_literal from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 0c7c8f6642..802db06f48 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -1,7 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -10,6 +9,8 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_sdkconfig_option, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index 316ce7c48b..35c42e1b06 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -40,7 +40,7 @@ INTERNAL_DAC_OPTIONS = { EXTERNAL_DAC_OPTIONS = [CONF_MONO, CONF_STEREO] -NO_INTERNAL_DAC_VARIANTS = [esp32.const.VARIANT_ESP32S2] +NO_INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32S2] I2C_COMM_FMT_OPTIONS = ["lsb", "msb"] diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index f919199c60..dd23673db5 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -37,8 +37,8 @@ I2SAudioMicrophone = i2s_audio_ns.class_( "I2SAudioMicrophone", I2SAudioIn, microphone.Microphone, cg.Component ) -INTERNAL_ADC_VARIANTS = [esp32.const.VARIANT_ESP32] -PDM_VARIANTS = [esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S3] +INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] +PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3] def _validate_esp32_variant(config): diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 98322d3a18..2e009a1de1 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -62,7 +62,7 @@ I2C_COMM_FMT_OPTIONS = { "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, } -INTERNAL_DAC_VARIANTS = [esp32.const.VARIANT_ESP32] +INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] def _set_num_channels_from_config(config): diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index fb2b541707..7f88b17e11 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import improv_base -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32S3 +from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant from esphome.components.logger import USB_CDC import esphome.config_validation as cv from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_LOGGER diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index c81ade8fc3..7369e99c85 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -3,8 +3,7 @@ import re from esphome import automation from esphome.automation import LambdaAction, StatelessLambdaAction import esphome.codegen as cg -from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, @@ -14,6 +13,8 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_sdkconfig_option, + get_esp32_variant, ) from esphome.components.libretiny import get_libretiny_component, get_libretiny_family from esphome.components.libretiny.const import ( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 4fc837be67..90c4cc082e 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -12,7 +12,7 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant from esphome.components.mipi import ( COLOR_ORDERS, CONF_COLOR_DEPTH, @@ -165,7 +165,7 @@ def model_schema(config): ) return cv.All( schema, - only_on_variant(supported=[const.VARIANT_ESP32P4]), + only_on_variant(supported=[VARIANT_ESP32P4]), cv.only_with_esp_idf, ) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 9d6b1fa729..2d2e022045 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -11,7 +11,7 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( COLOR_ORDERS, CONF_DE_PIN, @@ -224,7 +224,7 @@ def _config_schema(config): schema = model_schema(config) return cv.All( schema, - only_on_variant(supported=[const.VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3]), cv.only_with_esp_idf, )(config) diff --git a/esphome/components/neopixelbus/_methods.py b/esphome/components/neopixelbus/_methods.py index 5a00fa2804..9072f78035 100644 --- a/esphome/components/neopixelbus/_methods.py +++ b/esphome/components/neopixelbus/_methods.py @@ -2,12 +2,12 @@ from dataclasses import dataclass from typing import Any import esphome.codegen as cg -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import ( diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 0c9604e932..d071059185 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,8 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import light -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32C3, VARIANT_ESP32S3 +from esphome.components.esp32 import VARIANT_ESP32C3, VARIANT_ESP32S3, get_esp32_variant import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 529097889d..fcbe9ed043 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -7,14 +7,12 @@ from esphome.components.esp32 import ( CONF_CPU_FREQUENCY, CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, VARIANT_ESP32, - add_idf_sdkconfig_option, - get_esp32_variant, -) -from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_sdkconfig_option, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import ( diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 7f70e2c2a2..f5d89f2f0f 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -65,7 +65,7 @@ RemoteReceiverComponent = remote_receiver_ns.class_( def validate_config(config): if CORE.is_esp32: variant = esp32.get_esp32_variant() - if variant in (esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S2): + if variant in (esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S2): max_idle = 65535 else: max_idle = 32767 @@ -148,7 +148,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32P4, esp32.const.VARIANT_ESP32S3] + supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index ec4f62666d..f182a1ec0d 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -55,7 +55,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_EOT_LEVEL): cv.All(cv.only_on_esp32, cv.boolean), cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32P4, esp32.const.VARIANT_ESP32S3] + supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 513ed8eb58..8e9da43a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,7 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( CONF_DE_PIN, CONF_HSYNC_BACK_PORCH, @@ -121,7 +121,7 @@ CONFIG_SCHEMA = cv.All( } ) ), - only_on_variant(supported=[const.VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3]), cv.only_with_esp_idf, ) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 8f23735fff..1530ffb882 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -3,8 +3,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import only_on_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( KEY_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, @@ -13,6 +12,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + only_on_variant, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 497740b8d2..6e4bff6431 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -1,7 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display, spi -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( CONF_DE_PIN, CONF_HSYNC_BACK_PORCH, @@ -161,7 +161,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(spi.spi_device_schema(cs_pin_required=False, default_data_rate=1e6)) ), - only_on_variant(supported=[const.VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3]), cv.only_with_esp_idf, ) diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 72afc18387..90043e969c 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,10 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_component, + add_idf_sdkconfig_option, ) import esphome.config_validation as cv from esphome.const import CONF_ID diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ee926b1b6d..c52b791120 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1744,8 +1744,7 @@ class SplitDefault(Optional): def default(self): keys = [] if CORE.is_esp32: - from esphome.components.esp32 import get_esp32_variant - from esphome.components.esp32.const import VARIANT_ESP32 + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() framework = CORE.target_framework.replace("esp-", "") diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 91e96f24d6..68bd3a5965 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -17,8 +17,7 @@ def test_esp32_config( ) -> None: set_core_config(PlatformFramework.ESP32_IDF) - from esphome.components.esp32 import CONFIG_SCHEMA - from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_FRIENDLY + from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY # Example ESP32 configuration config = { diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 86bc29cc84..0924e66adc 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32C2, diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 73b15aaadf..c9d7b7486e 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -6,7 +6,7 @@ import pytest import voluptuous as vol from esphome import config_validation -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, @@ -221,7 +221,7 @@ def hex_int__valid(value): ], ) def test_split_default(framework, platform, variant, full, idf, arduino, simple): - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32 import KEY_ESP32 from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 670d6c16fc..bd14395037 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -35,7 +35,7 @@ from esphome.__main__ import ( upload_program, upload_using_esptool, ) -from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, CONF_BROKER, From f4d1c9df714b0478557683153c6c142157ead80a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Dec 2025 10:56:11 -0500 Subject: [PATCH 253/320] [remote_receiver] Fix Zephyr clang tidy (#12299) Co-authored-by: Claude --- esphome/components/remote_receiver/remote_receiver.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index fabf0a481a..3d9199a904 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -90,12 +90,14 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, std::string error_string_{""}; #endif +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) + RemoteReceiverComponentStore store_; +#endif + #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) HighFrequencyLoopRequester high_freq_; #endif - RemoteReceiverComponentStore store_; - uint32_t buffer_size_{}; uint32_t filter_us_{10}; uint32_t idle_us_{10000}; From 27fcff2092293442459741483f723c5d4b0b3671 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 10:27:41 -0600 Subject: [PATCH 254/320] [api] Simplify MessageCreator to trivially copyable type (#12295) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_connection.cpp | 6 ++--- esphome/components/api/api_connection.h | 27 ++++------------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9ad45dc6b7..31f90d9474 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1662,13 +1662,13 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c for (auto &item : items) { if (item.entity == entity && item.message_type == message_type) { // Replace with new creator - item.creator = std::move(creator); + item.creator = creator; return; } } // No existing item found, add new one - items.emplace_back(entity, std::move(creator), message_type, estimated_size); + items.emplace_back(entity, creator, message_type, estimated_size); } void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, @@ -1677,7 +1677,7 @@ void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCre // This avoids expensive vector::insert which shifts all elements // Note: We only ever have one high-priority message at a time (ping OR disconnect) // If we're disconnecting, pings are blocked, so this simple swap is sufficient - items.emplace_back(entity, std::move(creator), message_type, estimated_size); + items.emplace_back(entity, creator, message_type, estimated_size); if (items.size() > 1) { // Swap the new high-priority item to the front std::swap(items.front(), items.back()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 05af0ccde7..6bf4f45a5c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -505,28 +505,9 @@ class APIConnection final : public APIServerConnection { class MessageCreator { public: - // Constructor for function pointer MessageCreator(MessageCreatorPtr ptr) { data_.function_ptr = ptr; } - - // 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; - MessageCreator &operator=(const MessageCreator &other) = delete; - - // Move constructor - MessageCreator(MessageCreator &&other) noexcept : data_(other.data_) { other.data_.function_ptr = nullptr; } - - // Move assignment - MessageCreator &operator=(MessageCreator &&other) noexcept { - if (this != &other) { - data_ = other.data_; - other.data_.function_ptr = nullptr; - } - return *this; - } - // Call operator - uses message_type to determine union type uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, uint8_t message_type) const; @@ -535,7 +516,7 @@ class APIConnection final : public APIServerConnection { union Data { MessageCreatorPtr function_ptr; const char *const_char_ptr; - } data_; // 4 bytes on 32-bit, 8 bytes on 64-bit - same as before + } data_; // 4 bytes on 32-bit, 8 bytes on 64-bit }; // Generic batching mechanism for both state updates and entity info @@ -548,7 +529,7 @@ class APIConnection final : public APIServerConnection { // Constructor for creating BatchItem BatchItem(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) - : entity(entity), creator(std::move(creator)), message_type(message_type), estimated_size(estimated_size) {} + : entity(entity), creator(creator), message_type(message_type), estimated_size(estimated_size) {} }; std::vector items; @@ -716,12 +697,12 @@ class APIConnection final : public APIServerConnection { } // Fall back to scheduled batching - return this->schedule_message_(entity, std::move(creator), message_type, estimated_size); + return this->schedule_message_(entity, 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); + this->deferred_batch_.add_item(entity, creator, message_type, estimated_size); return this->schedule_batch_(); } From 1a308583b339965b23003607ed100d3815fadac2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:16:19 -0500 Subject: [PATCH 255/320] [esp32] Add support for ESP32-C61 variant (#12285) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/adc/__init__.py | 10 ++++ esphome/components/adc/adc_sensor_esp32.cpp | 21 +++++---- esphome/components/deep_sleep/__init__.py | 4 ++ .../deep_sleep/deep_sleep_component.h | 2 +- .../deep_sleep/deep_sleep_esp32.cpp | 10 ++-- esphome/components/esp32/__init__.py | 2 + esphome/components/esp32/boards.py | 2 + esphome/components/esp32/const.py | 3 ++ esphome/components/esp32/gpio.py | 6 +++ esphome/components/esp32/gpio_esp32_c61.py | 46 +++++++++++++++++++ esphome/components/esp32_can/canbus.py | 3 ++ esphome/components/esp32_can/esp32_can.cpp | 5 +- esphome/components/i2s_audio/__init__.py | 2 + .../improv_serial/improv_serial_component.cpp | 7 +-- .../improv_serial/improv_serial_component.h | 4 +- .../internal_temperature.cpp | 16 +++---- esphome/components/logger/__init__.py | 2 + esphome/components/spi/__init__.py | 2 + esphome/core/defines.h | 4 +- 19 files changed, 119 insertions(+), 32 deletions(-) create mode 100644 esphome/components/esp32/gpio_esp32_c61.py diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 62c1a5fffa..96c8334a6d 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -6,6 +6,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -100,6 +101,13 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 5: adc_channel_t.ADC_CHANNEL_5, 6: adc_channel_t.ADC_CHANNEL_6, }, + # https://docs.espressif.com/projects/esp-idf/en/latest/esp32c61/api-reference/peripherals/gpio.html + VARIANT_ESP32C61: { + 1: adc_channel_t.ADC_CHANNEL_0, + 3: adc_channel_t.ADC_CHANNEL_1, + 4: adc_channel_t.ADC_CHANNEL_2, + 5: adc_channel_t.ADC_CHANNEL_3, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h VARIANT_ESP32H2: { 1: adc_channel_t.ADC_CHANNEL_0, @@ -175,6 +183,8 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { VARIANT_ESP32C5: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h VARIANT_ESP32C6: {}, # no ADC2 + # ESP32-C61 has no ADC2 + VARIANT_ESP32C61: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h VARIANT_ESP32H2: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32p4/include/soc/adc_channel.h diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index e25b275cd6..120cb1c926 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -42,10 +42,11 @@ void ADCSensor::setup() { adc_oneshot_unit_init_cfg_t init_config = {}; // Zero initialize init_config.unit_id = this->adc_unit_; init_config.ulp_mode = ADC_ULP_MODE_DISABLE; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 init_config.clk_src = ADC_DIGI_CLK_SRC_DEFAULT; #endif // USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || - // USE_ESP32_VARIANT_ESP32H2 + // USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 esp_err_t err = adc_oneshot_new_unit(&init_config, &ADCSensor::shared_adc_handles[this->adc_unit_]); if (err != ESP_OK) { ESP_LOGE(TAG, "Error initializing %s: %d", LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), err); @@ -74,7 +75,7 @@ void ADCSensor::setup() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 // RISC-V variants and S3 use curve fitting calibration adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) @@ -111,7 +112,7 @@ void ADCSensor::setup() { ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err); this->setup_flags_.calibration_complete = false; } -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32H2 || ESP32P4 || ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3 } this->setup_flags_.init_complete = true; @@ -186,11 +187,11 @@ float ADCSensor::sample_fixed_attenuation_() { ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); if (this->calibration_handle_ != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else // Other ESP32 variants use line fitting calibration adc_cali_delete_scheme_line_fitting(this->calibration_handle_); -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32H2 || ESP32P4 || ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3 this->calibration_handle_ = nullptr; } } @@ -219,7 +220,7 @@ float ADCSensor::sample_autorange_() { if (this->calibration_handle_ != nullptr) { // Delete old calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else adc_cali_delete_scheme_line_fitting(this->calibration_handle_); @@ -231,7 +232,7 @@ float ADCSensor::sample_autorange_() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_curve_fitting_config_t cali_config = {}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -266,7 +267,7 @@ float ADCSensor::sample_autorange_() { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); if (handle != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); @@ -288,7 +289,7 @@ float ADCSensor::sample_autorange_() { } // Clean up calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 75affd7843..fa3ea449e2 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -6,6 +6,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -55,6 +56,7 @@ WAKEUP_PINS = { VARIANT_ESP32C2: [0, 1, 2, 3, 4, 5], VARIANT_ESP32C3: [0, 1, 2, 3, 4, 5], VARIANT_ESP32C6: [0, 1, 2, 3, 4, 5, 6, 7], + VARIANT_ESP32C61: [0, 1, 2, 3, 4, 5, 6], VARIANT_ESP32H2: [7, 8, 9, 10, 11, 12, 13, 14], VARIANT_ESP32S2: [ 0, @@ -123,6 +125,7 @@ def _validate_ex1_wakeup_mode(value): esp32.only_on_variant( supported=[ VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -219,6 +222,7 @@ CONFIG_SCHEMA = cv.All( VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, ], msg_prefix="Wakeup from touch", diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 80381e767c..bca3aa5e4d 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -81,7 +81,7 @@ class DeepSleepComponent : public Component { #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void set_touch_wakeup(bool touch_wakeup); #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index b93d9ce601..833be8e76c 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -18,6 +18,7 @@ namespace deep_sleep { // | ESP32-C3 | | | | ✓ | // | ESP32-C5 | | (✓) | | (✓) | // | ESP32-C6 | | ✓ | | ✓ | +// | ESP32-C61 | | ✓ | | ✓ | // | ESP32-H2 | | ✓ | | | // // Notes: @@ -55,7 +56,7 @@ void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wa #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif @@ -121,8 +122,9 @@ void DeepSleepComponent::deep_sleep_() { } #endif - // GPIO wakeup - C2, C3, C6 only -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) + // GPIO wakeup - C2, C3, C6, C61 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -155,7 +157,7 @@ void DeepSleepComponent::deep_sleep_() { // Touch wakeup - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { esp_sleep_enable_touchpad_wakeup(); esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1d05e16ebd..94280308bd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -59,6 +59,7 @@ from .const import ( # noqa VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -126,6 +127,7 @@ CPU_FREQUENCIES = { VARIANT_ESP32C3: get_cpu_frequencies(80, 160), VARIANT_ESP32C5: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), + VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index cbb314650a..7107874a5b 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -4,6 +4,7 @@ from .const import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -17,6 +18,7 @@ STANDARD_BOARDS = { VARIANT_ESP32C3: "esp32-c3-devkitm-1", VARIANT_ESP32C5: "esp32-c5-devkitc-1", VARIANT_ESP32C6: "esp32-c6-devkitm-1", + VARIANT_ESP32C61: "esp32-c61-devkitc1-n8r2", VARIANT_ESP32H2: "esp32-h2-devkitm-1", VARIANT_ESP32P4: "esp32-p4-evboard", VARIANT_ESP32S2: "esp32-s2-kaluga-1", diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 4358a4b712..dfb736f615 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -17,6 +17,7 @@ VARIANT_ESP32C2 = "ESP32C2" VARIANT_ESP32C3 = "ESP32C3" VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" +VARIANT_ESP32C61 = "ESP32C61" VARIANT_ESP32H2 = "ESP32H2" VARIANT_ESP32P4 = "ESP32P4" VARIANT_ESP32S2 = "ESP32S2" @@ -27,6 +28,7 @@ VARIANTS = [ VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -39,6 +41,7 @@ VARIANT_FRIENDLY = { VARIANT_ESP32C3: "ESP32-C3", VARIANT_ESP32C5: "ESP32-C5", VARIANT_ESP32C6: "ESP32-C6", + VARIANT_ESP32C61: "ESP32-C61", VARIANT_ESP32H2: "ESP32-H2", VARIANT_ESP32P4: "ESP32-P4", VARIANT_ESP32S2: "ESP32-S2", diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 954891ea8d..c0803f40a8 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -29,6 +29,7 @@ from .const import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -40,6 +41,7 @@ from .gpio_esp32_c2 import esp32_c2_validate_gpio_pin, esp32_c2_validate_support from .gpio_esp32_c3 import esp32_c3_validate_gpio_pin, esp32_c3_validate_supports from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_supports from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports +from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports @@ -110,6 +112,10 @@ _esp32_validations = { pin_validation=esp32_c6_validate_gpio_pin, usage_validation=esp32_c6_validate_supports, ), + VARIANT_ESP32C61: ESP32ValidationFunctions( + pin_validation=esp32_c61_validate_gpio_pin, + usage_validation=esp32_c61_validate_supports, + ), VARIANT_ESP32H2: ESP32ValidationFunctions( pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py new file mode 100644 index 0000000000..77be42db3e --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -0,0 +1,46 @@ +import logging + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# GPIO14-17, GPIO19-21 are used for SPI flash/PSRAM +_ESP32C61_SPI_PSRAM_PINS = { + 14: "SPICS0", + 15: "SPICLK", + 16: "SPID", + 17: "SPIQ", + 19: "SPIWP", + 20: "SPIHD", + 21: "VDD_SPI", +} + +_ESP32C61_STRAPPING_PINS = {8, 9} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_c61_validate_gpio_pin(value): + if value < 0 or value > 29: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-29)") + if value in _ESP32C61_SPI_PSRAM_PINS: + raise cv.Invalid( + f"This pin cannot be used on ESP32-C61s and is already used by the SPI/PSRAM interface (function: {_ESP32C61_SPI_PSRAM_PINS[value]})" + ) + + return value + + +def esp32_c61_validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 29: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-29)") + if is_input: + # All ESP32-C61 pins support input mode + pass + + check_strapping_pin(value, _ESP32C61_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 8708c6fb36..000ef303fe 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -8,6 +8,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -59,6 +60,7 @@ CAN_SPEEDS_ESP32_S2 = { CAN_SPEEDS_ESP32_S3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C6 = {**CAN_SPEEDS_ESP32_S2} +CAN_SPEEDS_ESP32_C61 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_H2 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_P4 = {**CAN_SPEEDS_ESP32_S2} @@ -66,6 +68,7 @@ CAN_SPEEDS = { VARIANT_ESP32: CAN_SPEEDS_ESP32, VARIANT_ESP32C3: CAN_SPEEDS_ESP32_C3, VARIANT_ESP32C6: CAN_SPEEDS_ESP32_C6, + VARIANT_ESP32C61: CAN_SPEEDS_ESP32_C61, VARIANT_ESP32H2: CAN_SPEEDS_ESP32_H2, VARIANT_ESP32P4: CAN_SPEEDS_ESP32_P4, VARIANT_ESP32S2: CAN_SPEEDS_ESP32_S2, diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index c10ad01450..d50964187d 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -16,8 +16,9 @@ static const char *const TAG = "esp32_can"; static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config) { switch (bitrate) { -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) case canbus::CAN_1KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_1KBITS(); return true; diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 802db06f48..61c5ca4ec1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -5,6 +5,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -72,6 +73,7 @@ I2S_PORTS = { VARIANT_ESP32C3: 1, VARIANT_ESP32C5: 1, VARIANT_ESP32C6: 1, + VARIANT_ESP32C61: 1, VARIANT_ESP32H2: 1, VARIANT_ESP32P4: 3, VARIANT_ESP32S2: 1, diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 70260eeab3..281e95d12b 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -70,9 +70,10 @@ optional ImprovSerialComponent::read_byte_() { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 +#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && + // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 if (this->uart_num_ >= 0) { size_t available; uart_get_buffered_data_len(this->uart_num_, &available); @@ -137,7 +138,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) case logger::UART_SELECTION_UART2: #endif uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index abe50b87f2..dd8f5e4719 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -11,8 +11,8 @@ #ifdef USE_ESP32 #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32S3) #include #include #endif diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature.cpp index 6365392ce9..2ef8cf2649 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature.cpp @@ -8,8 +8,8 @@ extern "C" { uint8_t temprature_sens_read(); } #elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ - defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "driver/temperature_sensor.h" #endif // USE_ESP32_VARIANT #endif // USE_ESP32 @@ -28,8 +28,8 @@ namespace internal_temperature { static const char *const TAG = "internal_temperature"; #ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32_VARIANT #endif // USE_ESP32 @@ -44,8 +44,8 @@ void InternalTemperatureSensor::update() { temperature = (raw - 32) / 1.8f; success = (raw != 128); #elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ - defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); success = (result == ESP_OK); if (!success) { @@ -82,8 +82,8 @@ void InternalTemperatureSensor::update() { void InternalTemperatureSensor::setup() { #ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &tsensNew); diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 7369e99c85..fb0ce92cc9 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -9,6 +9,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -105,6 +106,7 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C5: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32S2: [UART0, UART1, USB_CDC], diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 1530ffb882..0b531b9ed6 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -8,6 +8,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -129,6 +130,7 @@ def get_hw_interface_list(): VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, ]: return [["spi", "spi2"]] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5d3bca55a2..358334d7b3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -234,8 +234,8 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ - defined(USE_ESP32_VARIANT_ESP32S3) + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif From 7f7c913a853db92d269cc7e3f63040d0b112317c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 11:47:54 -0600 Subject: [PATCH 256/320] [light] Fix schedule_show not enabling loop for idle addressable lights (#12302) --- esphome/components/light/addressable_light.h | 2 +- esphome/components/light/light_state.cpp | 3 +-- esphome/components/light/light_state.h | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 2e4b984ce4..fcaf07f578 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -70,7 +70,7 @@ class AddressableLight : public LightOutput, public Component { this->state_parent_ = state; } void update_state(LightState *state) override; - void schedule_show() { this->state_parent_->next_write_ = true; } + void schedule_show() { this->state_parent_->schedule_write_(); } #ifdef USE_POWER_SUPPLY void set_power_supply(power_supply::PowerSupply *power_supply) { this->power_.set_parent(power_supply); } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index af619a426a..5a50bae50b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -305,8 +305,7 @@ void LightState::set_immediately_(const LightColorValues &target, bool set_remot this->remote_values = target; } this->output_->update_state(this); - this->next_write_ = true; - this->enable_loop(); + this->schedule_write_(); } void LightState::disable_loop_if_idle_() { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 7ea72306f9..a21c2c7693 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -277,6 +277,12 @@ class LightState : public EntityBase, public Component { /// Disable loop if neither transformer nor effect is active void disable_loop_if_idle_(); + /// Schedule a write to the light output and enable the loop to process it + void schedule_write_() { + this->next_write_ = true; + this->enable_loop(); + } + /// Store the output to allow effects to have more access. LightOutput *output_; /// The currently active transformer for this light (transition/flash). From 78bef42473a049781cdcc115d9dc7d3a916c045a Mon Sep 17 00:00:00 2001 From: c0mputerguru Date: Fri, 5 Dec 2025 10:33:00 -0800 Subject: [PATCH 257/320] [sps30] Add idle mode functionality (#12255) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/sps30/automation.h | 17 +++++--- esphome/components/sps30/sensor.py | 20 ++++++++-- esphome/components/sps30/sps30.cpp | 56 +++++++++++++++++++++++++++ esphome/components/sps30/sps30.h | 7 ++++ tests/components/sps30/common.yaml | 1 + 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index 67af813687..5eafc1b6c2 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -1,20 +1,25 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/helpers.h" #include "sps30.h" namespace esphome { namespace sps30 { -template class StartFanAction : public Action { +template class StartFanAction : public Action, public Parented { public: - explicit StartFanAction(SPS30Component *sps30) : sps30_(sps30) {} + void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } +}; - void play(const Ts &...x) override { this->sps30_->start_fan_cleaning(); } +template class StartMeasurementAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->start_measurement(); } +}; - protected: - SPS30Component *sps30_; +template class StopMeasurementAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; } // namespace sps30 diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index d4f91b4188..3c967fc01b 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -38,8 +38,11 @@ SPS30Component = sps30_ns.class_( # Actions StartFanAction = sps30_ns.class_("StartFanAction", automation.Action) +StartMeasurementAction = sps30_ns.class_("StartMeasurementAction", automation.Action) +StopMeasurementAction = sps30_ns.class_("StopMeasurementAction", automation.Action) CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" +CONF_IDLE_INTERVAL = "idle_interval" CONFIG_SCHEMA = ( cv.Schema( @@ -109,6 +112,7 @@ CONFIG_SCHEMA = ( state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AUTO_CLEANING_INTERVAL): cv.update_interval, + cv.Optional(CONF_IDLE_INTERVAL): cv.update_interval, } ) .extend(cv.polling_component_schema("60s")) @@ -164,6 +168,9 @@ async def to_code(config): if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) + if CONF_IDLE_INTERVAL in config: + cg.add(var.set_idle_interval(config[CONF_IDLE_INTERVAL])) + SPS30_ACTION_SCHEMA = maybe_simple_id( { @@ -175,6 +182,13 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "sps30.start_fan_autoclean", StartFanAction, SPS30_ACTION_SCHEMA ) -async def sps30_fan_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +@automation.register_action( + "sps30.start_measurement", StartMeasurementAction, SPS30_ACTION_SCHEMA +) +@automation.register_action( + "sps30.stop_measurement", StopMeasurementAction, SPS30_ACTION_SCHEMA +) +async def sps30_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 21a782e49a..dbb44743d2 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -20,6 +20,7 @@ static const uint16_t SPS30_CMD_START_FAN_CLEANING = 0x5607; static const uint16_t SPS30_CMD_SOFT_RESET = 0xD304; static const size_t SERIAL_NUMBER_LENGTH = 8; static const uint8_t MAX_SKIPPED_DATA_CYCLES_BEFORE_ERROR = 5; +static const uint32_t SPS30_WARM_UP_SEC = 30; void SPS30Component::setup() { this->write_command(SPS30_CMD_SOFT_RESET); @@ -63,6 +64,8 @@ void SPS30Component::setup() { this->status_clear_warning(); this->skipped_data_read_cycles_ = 0; this->start_continuous_measurement_(); + this->next_state_ms_ = millis() + SPS30_WARM_UP_SEC * 1000; + this->next_state_ = READ; this->setup_complete_ = true; }); }); @@ -101,6 +104,9 @@ void SPS30Component::dump_config() { " Serial number: %s\n" " Firmware version v%0d.%0d", this->serial_number_, this->raw_firmware_version_ >> 8, this->raw_firmware_version_ & 0xFF); + if (this->idle_interval_.has_value()) { + ESP_LOGCONFIG(TAG, " Idle interval: %us", this->idle_interval_.value() / 1000); + } LOG_SENSOR(" ", "PM1.0 Weight Concentration", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5 Weight Concentration", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM4 Weight Concentration", this->pm_4_0_sensor_); @@ -132,6 +138,26 @@ void SPS30Component::update() { } return; } + + // If its not time to take an action, do nothing. + const uint32_t update_start_ms = millis(); + if (this->next_state_ != NONE && (int32_t) (this->next_state_ms_ - update_start_ms) > 0) { + ESP_LOGD(TAG, "Sensor waiting for %ums before transitioning to state %d.", (this->next_state_ms_ - update_start_ms), + this->next_state_); + return; + } + + switch (this->next_state_) { + case WAKE: + this->start_measurement(); + return; + case NONE: + return; + case READ: + // Read logic continues below + break; + } + /// Check if measurement is ready before reading the value if (!this->write_command(SPS30_CMD_GET_DATA_READY_STATUS)) { this->status_set_warning(); @@ -211,6 +237,16 @@ void SPS30Component::update() { this->status_clear_warning(); this->skipped_data_read_cycles_ = 0; + + // Stop measurements and wait if we have an idle interval. If not using idle mode, let the next state just execute + // on next update. + if (this->idle_interval_.has_value()) { + this->stop_measurement(); + this->next_state_ms_ = millis() + this->idle_interval_.value(); + this->next_state_ = WAKE; + } else { + this->next_state_ms_ = millis(); + } }); } @@ -219,6 +255,26 @@ bool SPS30Component::start_continuous_measurement_() { ESP_LOGE(TAG, "Error initiating measurements"); return false; } + ESP_LOGD(TAG, "Started measurements"); + + // Notify the state machine to wait the warm up interval before reading + this->next_state_ms_ = millis() + SPS30_WARM_UP_SEC * 1000; + this->next_state_ = READ; + return true; +} + +bool SPS30Component::start_measurement() { return start_continuous_measurement_(); } + +bool SPS30Component::stop_measurement() { + if (!write_command(SPS30_CMD_STOP_MEASUREMENTS)) { + ESP_LOGE(TAG, "Error stopping measurements"); + return false; + } else { + ESP_LOGD(TAG, "Stopped measurements"); + // Exit the state machine if measurement is stopped. + this->next_state_ms_ = 0; + this->next_state_ = NONE; + } return true; } diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 18847e16d9..4e9b90ba7e 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -23,17 +23,23 @@ class SPS30Component : public PollingComponent, public sensirion_common::Sensiri void set_pm_size_sensor(sensor::Sensor *pm_size) { pm_size_sensor_ = pm_size; } void set_auto_cleaning_interval(uint32_t auto_cleaning_interval) { fan_interval_ = auto_cleaning_interval; } + void set_idle_interval(uint32_t idle_interval) { idle_interval_ = idle_interval; } void setup() override; void update() override; void dump_config() override; bool start_fan_cleaning(); + bool stop_measurement(); + bool start_measurement(); protected: bool setup_complete_{false}; uint16_t raw_firmware_version_; char serial_number_[17] = {0}; /// Terminating NULL character uint8_t skipped_data_read_cycles_ = 0; + uint32_t next_state_ms_ = 0; + + enum NextState : uint8_t { WAKE, READ, NONE } next_state_{NONE}; bool start_continuous_measurement_(); @@ -58,6 +64,7 @@ class SPS30Component : public PollingComponent, public sensirion_common::Sensiri sensor::Sensor *pmc_10_0_sensor_{nullptr}; sensor::Sensor *pm_size_sensor_{nullptr}; optional fan_interval_; + optional idle_interval_; }; } // namespace sps30 diff --git a/tests/components/sps30/common.yaml b/tests/components/sps30/common.yaml index d40cd16b6d..a83477b764 100644 --- a/tests/components/sps30/common.yaml +++ b/tests/components/sps30/common.yaml @@ -30,3 +30,4 @@ sensor: id: workshop_PMC_10_0 address: 0x69 update_interval: 10s + idle_interval: 5min From 7421f31160142f9bfd726ad75dcf1ba3c9c199d3 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Fri, 5 Dec 2025 10:51:32 -0800 Subject: [PATCH 258/320] [hub75] HUB75 display component (#11153) Co-authored-by: J. Nick Koston --- .clang-tidy.hash | 2 +- CODEOWNERS | 1 + esphome/components/hub75/__init__.py | 6 + esphome/components/hub75/boards/__init__.py | 80 +++ esphome/components/hub75/boards/adafruit.py | 23 + esphome/components/hub75/boards/apollo.py | 41 ++ esphome/components/hub75/boards/huidu.py | 22 + esphome/components/hub75/boards/trinity.py | 24 + esphome/components/hub75/display.py | 578 ++++++++++++++++++ esphome/components/hub75/hub75.cpp | 192 ++++++ esphome/components/hub75/hub75_component.h | 55 ++ platformio.ini | 2 + tests/components/hub75/test.esp32-idf.yaml | 39 ++ .../hub75/test.esp32-s3-idf-board.yaml | 26 + tests/components/hub75/test.esp32-s3-idf.yaml | 39 ++ 15 files changed, 1129 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hub75/__init__.py create mode 100644 esphome/components/hub75/boards/__init__.py create mode 100644 esphome/components/hub75/boards/adafruit.py create mode 100644 esphome/components/hub75/boards/apollo.py create mode 100644 esphome/components/hub75/boards/huidu.py create mode 100644 esphome/components/hub75/boards/trinity.py create mode 100644 esphome/components/hub75/display.py create mode 100644 esphome/components/hub75/hub75.cpp create mode 100644 esphome/components/hub75/hub75_component.h create mode 100644 tests/components/hub75/test.esp32-idf.yaml create mode 100644 tests/components/hub75/test.esp32-s3-idf-board.yaml create mode 100644 tests/components/hub75/test.esp32-s3-idf.yaml diff --git a/.clang-tidy.hash b/.clang-tidy.hash index ab3217b5e5..7dabee48f1 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -29270eecb86ffa07b2b1d2a4ca56dd7f84762ddc89c6248dbf3f012eca8780b6 +c01eec15857a784dd603c0afd194ab3b29a632422fe6f6b0a806ad4d81b5efc0 diff --git a/CODEOWNERS b/CODEOWNERS index 65405f79d1..4f9fb7ef55 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -227,6 +227,7 @@ esphome/components/hte501/* @Stock-M esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz esphome/components/htu31d/* @betterengineering +esphome/components/hub75/* @stuartparmenter esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core diff --git a/esphome/components/hub75/__init__.py b/esphome/components/hub75/__init__.py new file mode 100644 index 0000000000..cd5441f749 --- /dev/null +++ b/esphome/components/hub75/__init__.py @@ -0,0 +1,6 @@ +from esphome.cpp_generator import MockObj + +CODEOWNERS = ["@stuartparmenter"] + +# Use fully-qualified namespace to avoid collision with external hub75 library's global ::hub75 namespace +hub75_ns = MockObj("::esphome::hub75", "::") diff --git a/esphome/components/hub75/boards/__init__.py b/esphome/components/hub75/boards/__init__.py new file mode 100644 index 0000000000..52f8864c60 --- /dev/null +++ b/esphome/components/hub75/boards/__init__.py @@ -0,0 +1,80 @@ +"""Board presets for HUB75 displays. + +Each board preset defines standard pin mappings for HUB75 controller boards. +""" + +from dataclasses import dataclass, field +import importlib +import pkgutil +from typing import ClassVar + + +class BoardRegistry: + """Global registry for board configurations.""" + + _boards: ClassVar[dict[str, "BoardConfig"]] = {} + + @classmethod + def register(cls, board: "BoardConfig") -> None: + """Register a board configuration.""" + cls._boards[board.name] = board + + @classmethod + def get_boards(cls) -> dict[str, "BoardConfig"]: + """Return all registered boards.""" + return cls._boards + + +@dataclass +class BoardConfig: + """Board configuration storing HUB75 pin mappings.""" + + name: str + r1_pin: int + g1_pin: int + b1_pin: int + r2_pin: int + g2_pin: int + b2_pin: int + a_pin: int + b_pin: int + c_pin: int + d_pin: int + e_pin: int | None + lat_pin: int + oe_pin: int + clk_pin: int + ignore_strapping_pins: tuple[str, ...] = () # e.g., ("a_pin", "clk_pin") + + # Derived field for pin lookup + pins: dict[str, int | None] = field(default_factory=dict, init=False, repr=False) + + def __post_init__(self): + """Initialize derived fields and register board.""" + self.name = self.name.lower() + self.pins = { + "r1": self.r1_pin, + "g1": self.g1_pin, + "b1": self.b1_pin, + "r2": self.r2_pin, + "g2": self.g2_pin, + "b2": self.b2_pin, + "a": self.a_pin, + "b": self.b_pin, + "c": self.c_pin, + "d": self.d_pin, + "e": self.e_pin, + "lat": self.lat_pin, + "oe": self.oe_pin, + "clk": self.clk_pin, + } + BoardRegistry.register(self) + + def get_pin(self, pin_name: str) -> int | None: + """Get pin number for a given pin name.""" + return self.pins.get(pin_name) + + +# Dynamically import all board definition modules +for module_info in pkgutil.iter_modules(__path__): + importlib.import_module(f".{module_info.name}", package=__package__) diff --git a/esphome/components/hub75/boards/adafruit.py b/esphome/components/hub75/boards/adafruit.py new file mode 100644 index 0000000000..e27eeb9379 --- /dev/null +++ b/esphome/components/hub75/boards/adafruit.py @@ -0,0 +1,23 @@ +"""Adafruit Matrix Portal board definitions.""" + +from . import BoardConfig + +# Adafruit Matrix Portal S3 +BoardConfig( + "adafruit-matrix-portal-s3", + r1_pin=42, + g1_pin=41, + b1_pin=40, + r2_pin=38, + g2_pin=39, + b2_pin=37, + a_pin=45, + b_pin=36, + c_pin=48, + d_pin=35, + e_pin=21, + lat_pin=47, + oe_pin=14, + clk_pin=2, + ignore_strapping_pins=("a_pin",), # GPIO45 is a strapping pin +) diff --git a/esphome/components/hub75/boards/apollo.py b/esphome/components/hub75/boards/apollo.py new file mode 100644 index 0000000000..4b8b2c1f0a --- /dev/null +++ b/esphome/components/hub75/boards/apollo.py @@ -0,0 +1,41 @@ +"""Apollo Automation M1 board definitions.""" + +from . import BoardConfig + +# Apollo Automation M1 Rev4 +BoardConfig( + "apollo-automation-m1-rev4", + r1_pin=42, + g1_pin=41, + b1_pin=40, + r2_pin=38, + g2_pin=39, + b2_pin=37, + a_pin=45, + b_pin=36, + c_pin=48, + d_pin=35, + e_pin=21, + lat_pin=47, + oe_pin=14, + clk_pin=2, +) + +# Apollo Automation M1 Rev6 +BoardConfig( + "apollo-automation-m1-rev6", + r1_pin=1, + g1_pin=5, + b1_pin=6, + r2_pin=7, + g2_pin=13, + b2_pin=9, + a_pin=16, + b_pin=48, + c_pin=47, + d_pin=21, + e_pin=38, + lat_pin=8, + oe_pin=4, + clk_pin=18, +) diff --git a/esphome/components/hub75/boards/huidu.py b/esphome/components/hub75/boards/huidu.py new file mode 100644 index 0000000000..52744d397e --- /dev/null +++ b/esphome/components/hub75/boards/huidu.py @@ -0,0 +1,22 @@ +"""Huidu board definitions.""" + +from . import BoardConfig + +# Huidu HD-WF2 +BoardConfig( + "huidu-hd-wf2", + r1_pin=2, + g1_pin=6, + b1_pin=10, + r2_pin=3, + g2_pin=7, + b2_pin=11, + a_pin=39, + b_pin=38, + c_pin=37, + d_pin=36, + e_pin=21, + lat_pin=33, + oe_pin=35, + clk_pin=34, +) diff --git a/esphome/components/hub75/boards/trinity.py b/esphome/components/hub75/boards/trinity.py new file mode 100644 index 0000000000..bfad779ad0 --- /dev/null +++ b/esphome/components/hub75/boards/trinity.py @@ -0,0 +1,24 @@ +"""ESP32 Trinity board definitions.""" + +from . import BoardConfig + +# ESP32 Trinity +# https://esp32trinity.com/ +# Pin assignments from: https://github.com/witnessmenow/ESP32-Trinity/blob/master/FAQ.md +BoardConfig( + "esp32-trinity", + r1_pin=25, + g1_pin=26, + b1_pin=27, + r2_pin=14, + g2_pin=12, + b2_pin=13, + a_pin=23, + b_pin=19, + c_pin=5, + d_pin=17, + e_pin=18, + lat_pin=4, + oe_pin=15, + clk_pin=16, +) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py new file mode 100644 index 0000000000..81dd4ffc1c --- /dev/null +++ b/esphome/components/hub75/display.py @@ -0,0 +1,578 @@ +from typing import Any + +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +from esphome.components.esp32 import add_idf_component +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_BIT_DEPTH, + CONF_BOARD, + CONF_BRIGHTNESS, + CONF_CLK_PIN, + CONF_GAMMA_CORRECT, + CONF_ID, + CONF_LAMBDA, + CONF_OE_PIN, + CONF_UPDATE_INTERVAL, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import boards, hub75_ns + +DEPENDENCIES = ["esp32"] +CODEOWNERS = ["@stuartparmenter"] + +# Load all board presets +BOARDS = boards.BoardRegistry.get_boards() + +# Constants +CONF_HUB75_ID = "hub75_id" + +# Panel dimensions +CONF_PANEL_WIDTH = "panel_width" +CONF_PANEL_HEIGHT = "panel_height" + +# Multi-panel layout +CONF_LAYOUT_ROWS = "layout_rows" +CONF_LAYOUT_COLS = "layout_cols" +CONF_LAYOUT = "layout" + +# Panel hardware +CONF_SCAN_WIRING = "scan_wiring" +CONF_SHIFT_DRIVER = "shift_driver" + +# RGB pins +CONF_R1_PIN = "r1_pin" +CONF_G1_PIN = "g1_pin" +CONF_B1_PIN = "b1_pin" +CONF_R2_PIN = "r2_pin" +CONF_G2_PIN = "g2_pin" +CONF_B2_PIN = "b2_pin" + +# Address pins +CONF_A_PIN = "a_pin" +CONF_B_PIN = "b_pin" +CONF_C_PIN = "c_pin" +CONF_D_PIN = "d_pin" +CONF_E_PIN = "e_pin" + +# Control pins +CONF_LAT_PIN = "lat_pin" + +NEVER = 4294967295 # uint32_t max - value used when update_interval is "never" + +# Pin mapping from config keys to board keys +PIN_MAPPING = { + CONF_R1_PIN: "r1", + CONF_G1_PIN: "g1", + CONF_B1_PIN: "b1", + CONF_R2_PIN: "r2", + CONF_G2_PIN: "g2", + CONF_B2_PIN: "b2", + CONF_A_PIN: "a", + CONF_B_PIN: "b", + CONF_C_PIN: "c", + CONF_D_PIN: "d", + CONF_E_PIN: "e", + CONF_LAT_PIN: "lat", + CONF_OE_PIN: "oe", + CONF_CLK_PIN: "clk", +} + +# Required pins (E pin is optional) +REQUIRED_PINS = [key for key in PIN_MAPPING if key != CONF_E_PIN] + +# Configuration +CONF_CLOCK_SPEED = "clock_speed" +CONF_LATCH_BLANKING = "latch_blanking" +CONF_CLOCK_PHASE = "clock_phase" +CONF_DOUBLE_BUFFER = "double_buffer" +CONF_MIN_REFRESH_RATE = "min_refresh_rate" + +# Map to hub75 library enums (in global namespace) +ShiftDriver = cg.global_ns.enum("ShiftDriver", is_class=True) +SHIFT_DRIVERS = { + "GENERIC": ShiftDriver.GENERIC, + "FM6126A": ShiftDriver.FM6126A, + "ICN2038S": ShiftDriver.ICN2038S, + "FM6124": ShiftDriver.FM6124, + "MBI5124": ShiftDriver.MBI5124, + "DP3246": ShiftDriver.DP3246, +} + +PanelLayout = cg.global_ns.enum("PanelLayout", is_class=True) +PANEL_LAYOUTS = { + "HORIZONTAL": PanelLayout.HORIZONTAL, + "TOP_LEFT_DOWN": PanelLayout.TOP_LEFT_DOWN, + "TOP_RIGHT_DOWN": PanelLayout.TOP_RIGHT_DOWN, + "BOTTOM_LEFT_UP": PanelLayout.BOTTOM_LEFT_UP, + "BOTTOM_RIGHT_UP": PanelLayout.BOTTOM_RIGHT_UP, + "TOP_LEFT_DOWN_ZIGZAG": PanelLayout.TOP_LEFT_DOWN_ZIGZAG, + "TOP_RIGHT_DOWN_ZIGZAG": PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, + "BOTTOM_LEFT_UP_ZIGZAG": PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, + "BOTTOM_RIGHT_UP_ZIGZAG": PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, +} + +ScanPattern = cg.global_ns.enum("ScanPattern", is_class=True) +SCAN_PATTERNS = { + "STANDARD_TWO_SCAN": ScanPattern.STANDARD_TWO_SCAN, + "FOUR_SCAN_16PX_HIGH": ScanPattern.FOUR_SCAN_16PX_HIGH, + "FOUR_SCAN_32PX_HIGH": ScanPattern.FOUR_SCAN_32PX_HIGH, + "FOUR_SCAN_64PX_HIGH": ScanPattern.FOUR_SCAN_64PX_HIGH, +} + +Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True) +CLOCK_SPEEDS = { + "8MHZ": Hub75ClockSpeed.HZ_8M, + "10MHZ": Hub75ClockSpeed.HZ_10M, + "16MHZ": Hub75ClockSpeed.HZ_16M, + "20MHZ": Hub75ClockSpeed.HZ_20M, +} + +HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) +Hub75Config = cg.global_ns.struct("Hub75Config") +Hub75Pins = cg.global_ns.struct("Hub75Pins") + + +def _merge_board_pins(config: ConfigType) -> ConfigType: + """Merge board preset pins with explicit pin overrides.""" + board_name = config.get(CONF_BOARD) + + if board_name is None: + # No board specified - validate that all required pins are present + errs = [ + cv.Invalid( + f"Required pin '{pin_name}' is missing. " + f"Either specify a board preset or provide all pin mappings manually.", + path=[pin_name], + ) + for pin_name in REQUIRED_PINS + if pin_name not in config + ] + + if errs: + raise cv.MultipleInvalid(errs) + + # E_PIN is optional + return config + + # Get board configuration + if board_name not in BOARDS: + raise cv.Invalid( + f"Unknown board '{board_name}'. Available boards: {', '.join(sorted(BOARDS.keys()))}" + ) + + board = BOARDS[board_name] + + # Merge board pins with explicit overrides + # Explicit pins in config take precedence over board defaults + for conf_key, board_key in PIN_MAPPING.items(): + if conf_key in config or (board_pin := board.get_pin(board_key)) is None: + continue + # Create pin config + pin_config = {"number": board_pin} + if conf_key in board.ignore_strapping_pins: + pin_config["ignore_strapping_warning"] = True + + # Validate through pin schema to add required fields (id, etc.) + config[conf_key] = pins.gpio_output_pin_schema(pin_config) + + return config + + +def _validate_config(config: ConfigType) -> ConfigType: + """Validate driver and layout requirements.""" + errs: list[cv.Invalid] = [] + + # MBI5124 requires inverted clock phase + driver = config.get(CONF_SHIFT_DRIVER, "GENERIC") + if driver == "MBI5124" and not config.get(CONF_CLOCK_PHASE, False): + errs.append( + cv.Invalid( + "MBI5124 shift driver requires 'clock_phase: true' to be set", + path=[CONF_CLOCK_PHASE], + ) + ) + + # Prevent conflicting min_refresh_rate + update_interval configuration + # min_refresh_rate is auto-calculated from update_interval unless using LVGL mode + update_interval = config.get(CONF_UPDATE_INTERVAL) + if CONF_MIN_REFRESH_RATE in config and update_interval is not None: + # Handle both integer (NEVER) and time object cases + interval_ms = ( + update_interval + if isinstance(update_interval, int) + else update_interval.total_milliseconds + ) + if interval_ms != NEVER: + errs.append( + cv.Invalid( + "Cannot set both 'min_refresh_rate' and 'update_interval' (except 'never'). " + "Refresh rate is auto-calculated from update_interval. " + "Remove 'min_refresh_rate' or use 'update_interval: never' for LVGL mode.", + path=[CONF_MIN_REFRESH_RATE], + ) + ) + + # Validate layout configuration (validate effective config including C++ defaults) + layout = config.get(CONF_LAYOUT, "HORIZONTAL") + layout_rows = config.get(CONF_LAYOUT_ROWS, 1) + layout_cols = config.get(CONF_LAYOUT_COLS, 1) + is_zigzag = "ZIGZAG" in layout + + # Single panel (1x1) should use HORIZONTAL + if layout_rows == 1 and layout_cols == 1 and layout != "HORIZONTAL": + errs.append( + cv.Invalid( + f"Single panel (layout_rows=1, layout_cols=1) should use 'layout: HORIZONTAL' (got {layout})", + path=[CONF_LAYOUT], + ) + ) + + # HORIZONTAL layout requires single row + if layout == "HORIZONTAL" and layout_rows != 1: + errs.append( + cv.Invalid( + f"HORIZONTAL layout requires 'layout_rows: 1' (got {layout_rows}). " + "For multi-row grids, use TOP_LEFT_DOWN or other grid layouts.", + path=[CONF_LAYOUT_ROWS], + ) + ) + + # Grid layouts (non-HORIZONTAL) require more than one panel + if layout != "HORIZONTAL" and layout_rows == 1 and layout_cols == 1: + errs.append( + cv.Invalid( + f"Grid layout '{layout}' requires multiple panels (layout_rows > 1 or layout_cols > 1)", + path=[CONF_LAYOUT], + ) + ) + + # Serpentine layouts (non-ZIGZAG) require multiple rows + # Serpentine physically rotates alternate rows upside down (Y-coordinate inversion) + # Single-row chains should use HORIZONTAL or ZIGZAG variants + if not is_zigzag and layout != "HORIZONTAL" and layout_rows == 1: + errs.append( + cv.Invalid( + f"Serpentine layout '{layout}' requires layout_rows > 1 " + f"(got layout_rows={layout_rows}). " + "Serpentine wiring physically rotates alternate rows upside down. " + "For single-row chains, use 'layout: HORIZONTAL' or add '_ZIGZAG' suffix.", + path=[CONF_LAYOUT_ROWS], + ) + ) + + # ZIGZAG layouts require actual grid (both rows AND cols > 1) + if is_zigzag and (layout_rows == 1 or layout_cols == 1): + errs.append( + cv.Invalid( + f"ZIGZAG layout '{layout}' requires both layout_rows > 1 AND layout_cols > 1 " + f"(got rows={layout_rows}, cols={layout_cols}). " + "For single row/column chains, use non-zigzag layouts or HORIZONTAL.", + path=[CONF_LAYOUT], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) + + return config + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate requirements when using HUB75 display.""" + # Local imports to avoid circular dependencies + from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import VARIANT_ESP32P4 + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + + full_config = fv.full_config.get() + errs: list[cv.Invalid] = [] + + # ESP32-P4 requires PSRAM + variant = get_esp32_variant() + if variant == VARIANT_ESP32P4 and PSRAM_DOMAIN not in full_config: + errs.append( + cv.Invalid( + "HUB75 display on ESP32-P4 requires PSRAM. Add 'psram:' to your configuration.", + path=[CONF_ID], + ) + ) + + # LVGL-specific validation + if LVGL_DOMAIN in full_config: + # Check update_interval (converted from "never" to NEVER constant) + update_interval = config.get(CONF_UPDATE_INTERVAL) + if update_interval is not None: + # Handle both integer (NEVER) and time object cases + interval_ms = ( + update_interval + if isinstance(update_interval, int) + else update_interval.total_milliseconds + ) + if interval_ms != NEVER: + errs.append( + cv.Invalid( + "HUB75 display with LVGL must have 'update_interval: never'. " + "LVGL manages its own refresh timing.", + path=[CONF_UPDATE_INTERVAL], + ) + ) + + # Check auto_clear_enabled + auto_clear = config[CONF_AUTO_CLEAR_ENABLED] + if auto_clear is not False: + errs.append( + cv.Invalid( + f"HUB75 display with LVGL must have 'auto_clear_enabled: false' (got '{auto_clear}'). " + "LVGL manages screen clearing.", + path=[CONF_AUTO_CLEAR_ENABLED], + ) + ) + + # Check double_buffer (C++ default: false) + double_buffer = config.get(CONF_DOUBLE_BUFFER, False) + if double_buffer is not False: + errs.append( + cv.Invalid( + f"HUB75 display with LVGL must have 'double_buffer: false' (got '{double_buffer}'). " + "LVGL uses its own buffering strategy.", + path=[CONF_DOUBLE_BUFFER], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) + + return config + + +FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) + + +CONFIG_SCHEMA = cv.All( + display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(HUB75Display), + # Board preset (optional - provides default pin mappings) + cv.Optional(CONF_BOARD): cv.one_of(*BOARDS.keys(), lower=True), + # Panel dimensions + cv.Required(CONF_PANEL_WIDTH): cv.positive_int, + cv.Required(CONF_PANEL_HEIGHT): cv.positive_int, + # Multi-panel layout + cv.Optional(CONF_LAYOUT_ROWS): cv.positive_int, + cv.Optional(CONF_LAYOUT_COLS): cv.positive_int, + cv.Optional(CONF_LAYOUT): cv.enum(PANEL_LAYOUTS, upper=True, space="_"), + # Panel hardware configuration + cv.Optional(CONF_SCAN_WIRING): cv.enum( + SCAN_PATTERNS, upper=True, space="_" + ), + cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True), + # Display configuration + cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean, + cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255), + cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=6, max=12), + cv.Optional(CONF_GAMMA_CORRECT): cv.enum( + {"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True + ), + cv.Optional(CONF_MIN_REFRESH_RATE): cv.int_range(min=40, max=200), + # RGB data pins + cv.Optional(CONF_R1_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_G1_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_B1_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_R2_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_G2_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_B2_PIN): pins.gpio_output_pin_schema, + # Address pins + cv.Optional(CONF_A_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_B_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_C_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_D_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_E_PIN): pins.gpio_output_pin_schema, + # Control pins + cv.Optional(CONF_LAT_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_OE_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_CLK_PIN): pins.gpio_output_pin_schema, + # Timing configuration + cv.Optional(CONF_CLOCK_SPEED): cv.enum(CLOCK_SPEEDS, upper=True), + cv.Optional(CONF_LATCH_BLANKING): cv.positive_int, + cv.Optional(CONF_CLOCK_PHASE): cv.boolean, + } + ), + _merge_board_pins, + _validate_config, +) + + +DEFAULT_REFRESH_RATE = 60 # Hz + + +def _calculate_min_refresh_rate(config: ConfigType) -> int: + """Calculate minimum refresh rate for the display. + + Priority: + 1. Explicit min_refresh_rate setting (user override) + 2. Derived from update_interval (ms to Hz conversion) + 3. Default 60 Hz (for LVGL or unspecified interval) + """ + if CONF_MIN_REFRESH_RATE in config: + return config[CONF_MIN_REFRESH_RATE] + + update_interval = config.get(CONF_UPDATE_INTERVAL) + if update_interval is None: + return DEFAULT_REFRESH_RATE + + # update_interval can be TimePeriod object or NEVER constant (int) + interval_ms = ( + update_interval + if isinstance(update_interval, int) + else update_interval.total_milliseconds + ) + + # "never" or zero means external refresh (e.g., LVGL) + if interval_ms in (NEVER, 0): + return DEFAULT_REFRESH_RATE + + # Convert ms interval to Hz, clamped to valid range [40, 200] + return max(40, min(200, int(round(1000 / interval_ms)))) + + +def _build_pins_struct( + pin_expressions: dict[str, Any], e_pin_num: int | cg.RawExpression +) -> cg.StructInitializer: + """Build Hub75Pins struct from pin expressions.""" + + def pin_cast(pin): + return cg.RawExpression(f"static_cast({pin.get_pin()})") + + return cg.StructInitializer( + Hub75Pins, + ("r1", pin_cast(pin_expressions["r1"])), + ("g1", pin_cast(pin_expressions["g1"])), + ("b1", pin_cast(pin_expressions["b1"])), + ("r2", pin_cast(pin_expressions["r2"])), + ("g2", pin_cast(pin_expressions["g2"])), + ("b2", pin_cast(pin_expressions["b2"])), + ("a", pin_cast(pin_expressions["a"])), + ("b", pin_cast(pin_expressions["b"])), + ("c", pin_cast(pin_expressions["c"])), + ("d", pin_cast(pin_expressions["d"])), + ("e", e_pin_num), + ("lat", pin_cast(pin_expressions["lat"])), + ("oe", pin_cast(pin_expressions["oe"])), + ("clk", pin_cast(pin_expressions["clk"])), + ) + + +def _append_config_fields( + config: ConfigType, + field_mapping: list[tuple[str, str]], + config_fields: list[tuple[str, Any]], +) -> None: + """Append config fields from mapping if present in config.""" + for conf_key, struct_field in field_mapping: + if conf_key in config: + config_fields.append((struct_field, config[conf_key])) + + +def _build_config_struct( + config: ConfigType, pins_struct: cg.StructInitializer, min_refresh: int +) -> cg.StructInitializer: + """Build Hub75Config struct from config. + + Fields must be added in declaration order (see hub75_types.h) to satisfy + C++ designated initializer requirements. The order is: + 1. fields_before_pins (panel_width through layout) + 2. pins + 3. output_clock_speed + 4. min_refresh_rate + 5. fields_after_min_refresh (latch_blanking through brightness) + """ + fields_before_pins = [ + (CONF_PANEL_WIDTH, "panel_width"), + (CONF_PANEL_HEIGHT, "panel_height"), + # scan_pattern - auto-calculated, not set + (CONF_SCAN_WIRING, "scan_wiring"), + (CONF_SHIFT_DRIVER, "shift_driver"), + (CONF_LAYOUT_ROWS, "layout_rows"), + (CONF_LAYOUT_COLS, "layout_cols"), + (CONF_LAYOUT, "layout"), + ] + fields_after_min_refresh = [ + (CONF_LATCH_BLANKING, "latch_blanking"), + (CONF_DOUBLE_BUFFER, "double_buffer"), + (CONF_CLOCK_PHASE, "clk_phase_inverted"), + (CONF_BRIGHTNESS, "brightness"), + ] + + config_fields: list[tuple[str, Any]] = [] + + _append_config_fields(config, fields_before_pins, config_fields) + + config_fields.append(("pins", pins_struct)) + + if CONF_CLOCK_SPEED in config: + config_fields.append(("output_clock_speed", config[CONF_CLOCK_SPEED])) + + config_fields.append(("min_refresh_rate", min_refresh)) + + _append_config_fields(config, fields_after_min_refresh, config_fields) + + return cg.StructInitializer(Hub75Config, *config_fields) + + +async def to_code(config: ConfigType) -> None: + add_idf_component( + name="esphome/esp-hub75", + ref="0.1.6", + ) + + # Set compile-time configuration via defines + if CONF_BIT_DEPTH in config: + cg.add_define("HUB75_BIT_DEPTH", config[CONF_BIT_DEPTH]) + + if CONF_GAMMA_CORRECT in config: + cg.add_define("HUB75_GAMMA_MODE", config[CONF_GAMMA_CORRECT]) + + # Await all pin expressions + pin_expressions = { + "r1": await cg.gpio_pin_expression(config[CONF_R1_PIN]), + "g1": await cg.gpio_pin_expression(config[CONF_G1_PIN]), + "b1": await cg.gpio_pin_expression(config[CONF_B1_PIN]), + "r2": await cg.gpio_pin_expression(config[CONF_R2_PIN]), + "g2": await cg.gpio_pin_expression(config[CONF_G2_PIN]), + "b2": await cg.gpio_pin_expression(config[CONF_B2_PIN]), + "a": await cg.gpio_pin_expression(config[CONF_A_PIN]), + "b": await cg.gpio_pin_expression(config[CONF_B_PIN]), + "c": await cg.gpio_pin_expression(config[CONF_C_PIN]), + "d": await cg.gpio_pin_expression(config[CONF_D_PIN]), + "lat": await cg.gpio_pin_expression(config[CONF_LAT_PIN]), + "oe": await cg.gpio_pin_expression(config[CONF_OE_PIN]), + "clk": await cg.gpio_pin_expression(config[CONF_CLK_PIN]), + } + + # E pin is optional + if CONF_E_PIN in config: + e_pin = await cg.gpio_pin_expression(config[CONF_E_PIN]) + e_pin_num = cg.RawExpression(f"static_cast({e_pin.get_pin()})") + else: + e_pin_num = -1 + + # Build structs + min_refresh = _calculate_min_refresh_rate(config) + pins_struct = _build_pins_struct(pin_expressions, e_pin_num) + hub75_config = _build_config_struct(config, pins_struct, min_refresh) + + # Create display and register + var = cg.new_Pvariable(config[CONF_ID], hub75_config) + await display.register_display(var, config) + + if CONF_LAMBDA in config: + lambda_ = await cg.process_lambda( + config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp new file mode 100644 index 0000000000..e023e446c4 --- /dev/null +++ b/esphome/components/hub75/hub75.cpp @@ -0,0 +1,192 @@ +#include "hub75_component.h" +#include "esphome/core/application.h" + +#ifdef USE_ESP32 + +namespace esphome::hub75 { + +static const char *const TAG = "hub75"; + +// ======================================== +// Constructor +// ======================================== + +HUB75Display::HUB75Display(const Hub75Config &config) : config_(config) { + // Initialize runtime state from config + this->brightness_ = config.brightness; + this->enabled_ = (config.brightness > 0); +} + +// ======================================== +// Core Component methods +// ======================================== + +void HUB75Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up HUB75Display..."); + + // Create driver with pre-configured config + driver_ = new Hub75Driver(config_); + if (!driver_->begin()) { + ESP_LOGE(TAG, "Failed to initialize HUB75 driver!"); + return; + } + + this->enabled_ = true; +} + +void HUB75Display::dump_config() { + LOG_DISPLAY("", "HUB75", this); + + ESP_LOGCONFIG(TAG, + " Panel: %dx%d pixels\n" + " Layout: %dx%d panels\n" + " Virtual Display: %dx%d pixels", + config_.panel_width, config_.panel_height, config_.layout_cols, config_.layout_rows, + config_.panel_width * config_.layout_cols, config_.panel_height * config_.layout_rows); + + ESP_LOGCONFIG(TAG, + " Scan Wiring: %d\n" + " Shift Driver: %d", + static_cast(config_.scan_wiring), static_cast(config_.shift_driver)); + + ESP_LOGCONFIG(TAG, + " Pins: R1:%i, G1:%i, B1:%i, R2:%i, G2:%i, B2:%i\n" + " Pins: A:%i, B:%i, C:%i, D:%i, E:%i\n" + " Pins: LAT:%i, OE:%i, CLK:%i", + config_.pins.r1, config_.pins.g1, config_.pins.b1, config_.pins.r2, config_.pins.g2, config_.pins.b2, + config_.pins.a, config_.pins.b, config_.pins.c, config_.pins.d, config_.pins.e, config_.pins.lat, + config_.pins.oe, config_.pins.clk); + + ESP_LOGCONFIG(TAG, + " Clock Speed: %u MHz\n" + " Latch Blanking: %i\n" + " Clock Phase: %s\n" + " Min Refresh Rate: %i Hz\n" + " Bit Depth: %i\n" + " Double Buffer: %s", + static_cast(config_.output_clock_speed) / 1000000, config_.latch_blanking, + TRUEFALSE(config_.clk_phase_inverted), config_.min_refresh_rate, HUB75_BIT_DEPTH, + YESNO(config_.double_buffer)); +} + +// ======================================== +// Display/PollingComponent methods +// ======================================== + +void HUB75Display::update() { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + this->do_update_(); + + if (config_.double_buffer) { + driver_->flip_buffer(); + } +} + +void HUB75Display::fill(Color color) { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + // Special case: black (off) - use fast hardware clear + if (!color.is_on()) { + driver_->clear(); + return; + } + + // For non-black colors, fall back to base class (pixel-by-pixel) + Display::fill(color); +} + +void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] + return; + + driver_->set_pixel(x, y, color.r, color.g, color.b); + App.feed_wdt(); +} + +void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + // Map ESPHome enums to hub75 enums + Hub75PixelFormat format; + Hub75ColorOrder color_order = Hub75ColorOrder::RGB; + int bytes_per_pixel; + + // Determine format based on bitness + if (bitness == ColorBitness::COLOR_BITNESS_565) { + format = Hub75PixelFormat::RGB565; + bytes_per_pixel = 2; + } else if (bitness == ColorBitness::COLOR_BITNESS_888) { +#ifdef USE_LVGL +#if LV_COLOR_DEPTH == 32 + // 32-bit: 4 bytes per pixel with padding byte (LVGL mode) + format = Hub75PixelFormat::RGB888_32; + bytes_per_pixel = 4; + + // Map ESPHome ColorOrder to Hub75ColorOrder + // ESPHome ColorOrder is typically BGR for little-endian 32-bit + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; +#elif LV_COLOR_DEPTH == 24 + // 24-bit: 3 bytes per pixel, tightly packed + format = Hub75PixelFormat::RGB888; + bytes_per_pixel = 3; + // Note: 24-bit is always RGB order in LVGL +#else + ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); + return; +#endif +#else + // Non-LVGL mode: standard 24-bit RGB888 + format = Hub75PixelFormat::RGB888; + bytes_per_pixel = 3; + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; +#endif + } else { + ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); + return; + } + + // Check if buffer is tightly packed (no stride) + const int stride_px = x_offset + w + x_pad; + const bool is_packed = (x_offset == 0 && x_pad == 0 && y_offset == 0); + + if (is_packed) { + // Tightly packed buffer - single bulk call for best performance + driver_->draw_pixels(x_start, y_start, w, h, ptr, format, color_order, big_endian); + } else { + // Buffer has stride (padding between rows) - draw row by row + for (int yy = 0; yy < h; ++yy) { + const size_t row_offset = ((y_offset + yy) * stride_px + x_offset) * bytes_per_pixel; + const uint8_t *row_ptr = ptr + row_offset; + + driver_->draw_pixels(x_start, y_start + yy, w, 1, row_ptr, format, color_order, big_endian); + } + } +} + +void HUB75Display::set_brightness(int brightness) { + this->brightness_ = brightness; + this->enabled_ = (brightness > 0); + if (this->driver_ != nullptr) { + this->driver_->set_brightness(brightness); + } +} + +} // namespace esphome::hub75 + +#endif diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h new file mode 100644 index 0000000000..49d4274483 --- /dev/null +++ b/esphome/components/hub75/hub75_component.h @@ -0,0 +1,55 @@ +#pragma once + +#ifdef USE_ESP32 + +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "hub75.h" // hub75 library + +namespace esphome::hub75 { + +using esphome::display::ColorBitness; +using esphome::display::ColorOrder; + +class HUB75Display : public display::Display { + public: + // Constructor accepting config + explicit HUB75Display(const Hub75Config &config); + + // Core Component methods + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + // Display/PollingComponent methods + void update() override; + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + // Brightness control (runtime mutable) + void set_brightness(int brightness); + + protected: + // Display internal methods + int get_width_internal() override { return config_.panel_width * config_.layout_cols; } + int get_height_internal() override { return config_.panel_height * config_.layout_rows; } + + // Member variables + Hub75Driver *driver_{nullptr}; + Hub75Config config_; // Immutable configuration + + // Runtime state (mutable) + int brightness_{128}; + bool enabled_{false}; +}; + +} // namespace esphome::hub75 + +#endif diff --git a/platformio.ini b/platformio.ini index 81f8b3295b..9095d27af8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -152,6 +152,7 @@ lib_deps = esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.2 ; wireguard esphome/esp-audio-libs@2.0.1 ; audio + esphome/esp-hub75@0.1.6 ; hub75 build_flags = ${common:arduino.build_flags} @@ -175,6 +176,7 @@ lib_deps = droscy/esp_wireguard@0.4.2 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word esphome/esp-audio-libs@2.0.1 ; audio + esphome/esp-hub75@0.1.6 ; hub75 build_flags = ${common:idf.build_flags} -Wno-nonnull-compare diff --git a/tests/components/hub75/test.esp32-idf.yaml b/tests/components/hub75/test.esp32-idf.yaml new file mode 100644 index 0000000000..c275d24187 --- /dev/null +++ b/tests/components/hub75/test.esp32-idf.yaml @@ -0,0 +1,39 @@ +esp32: + board: esp32dev + framework: + type: esp-idf + +display: + - platform: hub75 + id: my_hub75 + panel_width: 64 + panel_height: 32 + double_buffer: true + brightness: 128 + r1_pin: GPIO25 + g1_pin: GPIO26 + b1_pin: GPIO27 + r2_pin: GPIO14 + g2_pin: GPIO12 + b2_pin: GPIO13 + a_pin: GPIO23 + b_pin: GPIO19 + c_pin: GPIO5 + d_pin: GPIO17 + e_pin: GPIO21 + lat_pin: GPIO4 + oe_pin: GPIO15 + clk_pin: GPIO16 + pages: + - id: page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: page1 + to: page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/hub75/test.esp32-s3-idf-board.yaml b/tests/components/hub75/test.esp32-s3-idf-board.yaml new file mode 100644 index 0000000000..9568ccf3aa --- /dev/null +++ b/tests/components/hub75/test.esp32-s3-idf-board.yaml @@ -0,0 +1,26 @@ +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +display: + - platform: hub75 + id: hub75_display_board + board: adafruit-matrix-portal-s3 + panel_width: 64 + panel_height: 32 + double_buffer: true + brightness: 128 + pages: + - id: page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: page1 + to: page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/hub75/test.esp32-s3-idf.yaml b/tests/components/hub75/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..db678c98a4 --- /dev/null +++ b/tests/components/hub75/test.esp32-s3-idf.yaml @@ -0,0 +1,39 @@ +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +display: + - platform: hub75 + id: my_hub75 + panel_width: 64 + panel_height: 32 + double_buffer: true + brightness: 128 + r1_pin: GPIO42 + g1_pin: GPIO41 + b1_pin: GPIO40 + r2_pin: GPIO38 + g2_pin: GPIO39 + b2_pin: GPIO37 + a_pin: GPIO45 + b_pin: GPIO36 + c_pin: GPIO48 + d_pin: GPIO35 + e_pin: GPIO21 + lat_pin: GPIO47 + oe_pin: GPIO14 + clk_pin: GPIO2 + pages: + - id: page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: page1 + to: page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); From 1fa7adbe8dd63e33c334a57179b6730ecc13e8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 5 Dec 2025 21:24:57 +0100 Subject: [PATCH 259/320] [mipi_spi] Add M5CORE2 model (#12301) --- esphome/components/mipi_spi/models/ili.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 0102c0f665..60a25c32a9 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -148,6 +148,19 @@ ILI9341 = DriverChip( ), ), ) +# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation +ILI9341.extend( + "M5CORE2", + width=320, + height=240, + mirror_x=False, + cs_pin=5, + dc_pin=15, + invert_colors=True, + pixel_mode="18bit", + data_rate="40MHz", +) + DriverChip( "ILI9481", mirror_x=True, From bbb71b5359e459b6a4091ab95704b628d4c32802 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:16:55 -0600 Subject: [PATCH 260/320] Bump peter-evans/create-pull-request from 7.0.9 to 7.0.11 (#12303) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ea81a1e013..2c3219e38e 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From 10b54df77194eea0f75116d76eeeff6fa6edc1e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:17:10 -0600 Subject: [PATCH 261/320] Bump github/codeql-action from 4.31.6 to 4.31.7 (#12304) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d9b6bcdcca..481ad0ec34 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 + uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 + uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7 with: category: "/language:${{matrix.language}}" From a517e0ec80a3b8d8b82e57e53c92ebfc1f61e7a1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:28:24 -0500 Subject: [PATCH 262/320] [esp32] Add missing variant support (#12305) Co-authored-by: Claude --- esphome/components/deep_sleep/__init__.py | 7 +++++++ esphome/components/esp32_can/canbus.py | 3 +++ esphome/components/ethernet/__init__.py | 12 +++++++++++- esphome/components/ethernet/ethernet_component.cpp | 4 ++-- esphome/components/spi/__init__.py | 2 ++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index fa3ea449e2..8849fad7d6 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -5,9 +5,11 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, get_esp32_variant, @@ -55,9 +57,11 @@ WAKEUP_PINS = { ], VARIANT_ESP32C2: [0, 1, 2, 3, 4, 5], VARIANT_ESP32C3: [0, 1, 2, 3, 4, 5], + VARIANT_ESP32C5: [0, 1, 2, 3, 4, 5, 6, 7], VARIANT_ESP32C6: [0, 1, 2, 3, 4, 5, 6, 7], VARIANT_ESP32C61: [0, 1, 2, 3, 4, 5, 6], VARIANT_ESP32H2: [7, 8, 9, 10, 11, 12, 13, 14], + VARIANT_ESP32P4: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], VARIANT_ESP32S2: [ 0, 1, @@ -124,9 +128,11 @@ def _validate_ex1_wakeup_mode(value): if value == "ANY_LOW": esp32.only_on_variant( supported=[ + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ], @@ -221,6 +227,7 @@ CONFIG_SCHEMA = cv.All( unsupported=[ VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 000ef303fe..0899a0dc2b 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -7,6 +7,7 @@ from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, @@ -59,6 +60,7 @@ CAN_SPEEDS_ESP32_S2 = { CAN_SPEEDS_ESP32_S3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C3 = {**CAN_SPEEDS_ESP32_S2} +CAN_SPEEDS_ESP32_C5 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C6 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C61 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_H2 = {**CAN_SPEEDS_ESP32_S2} @@ -67,6 +69,7 @@ CAN_SPEEDS_ESP32_P4 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS = { VARIANT_ESP32: CAN_SPEEDS_ESP32, VARIANT_ESP32C3: CAN_SPEEDS_ESP32_C3, + VARIANT_ESP32C5: CAN_SPEEDS_ESP32_C5, VARIANT_ESP32C6: CAN_SPEEDS_ESP32_C6, VARIANT_ESP32C61: CAN_SPEEDS_ESP32_C61, VARIANT_ESP32H2: CAN_SPEEDS_ESP32_H2, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 39af1ff4b9..b4b1fcd9f6 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -5,6 +5,9 @@ import esphome.codegen as cg from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -301,7 +304,14 @@ def _final_validate_spi(config): return if spi_configs := fv.full_config.get().get(CONF_SPI): variant = get_esp32_variant() - if variant in (VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3): + if variant in ( + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + ): spi_host = "SPI2_HOST" else: spi_host = "SPI3_HOST" diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 757e358db3..793ebdec42 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -87,8 +87,8 @@ void EthernetComponent::setup() { .intr_flags = 0, }; -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) auto host = SPI2_HOST; #else auto host = SPI3_HOST; diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 0b531b9ed6..88bb3406e1 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -7,6 +7,7 @@ from esphome.components.esp32 import ( KEY_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, @@ -129,6 +130,7 @@ def get_hw_interface_list(): if get_target_variant() in [ VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, From 6716194e47a1d85aa495eb7b913430edd7087713 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 09:59:29 +1100 Subject: [PATCH 263/320] [binary_sensor] Fix reporting of 'unknown' (#12296) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../binary_sensor/binary_sensor.cpp | 13 +- .../components/binary_sensor/binary_sensor.h | 2 + esphome/core/entity_base.h | 16 +- tests/integration/README.md | 24 ++- .../binary_sensor_invalidate_state.yaml | 39 +++++ tests/integration/state_utils.py | 63 ++++++++ .../test_binary_sensor_invalidate_state.py | 138 ++++++++++++++++++ 7 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/binary_sensor_invalidate_state.yaml create mode 100644 tests/integration/test_binary_sensor_invalidate_state.py diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 92b8db5c51..86b7350aa8 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -34,13 +34,20 @@ void BinarySensor::publish_initial_state(bool new_state) { void BinarySensor::send_state_internal(bool new_state) { // copy the new state to the visible property for backwards compatibility, before any callbacks this->state = new_state; - // Note that set_state_ de-dups and will only trigger callbacks if the state has actually changed - if (this->set_state_(new_state)) { - ESP_LOGD(TAG, "'%s': New state is %s", this->get_name().c_str(), ONOFF(new_state)); + // Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed + this->set_new_state(new_state); +} + +bool BinarySensor::set_new_state(const optional &new_state) { + if (StatefulEntityBase::set_new_state(new_state)) { + // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_binary_sensor_update(this); #endif + ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); + return true; } + return false; } void BinarySensor::add_filter(Filter *filter) { diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 0dca3e1520..83c992bfed 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -61,6 +61,8 @@ class BinarySensor : public StatefulEntityBase, public EntityBase_DeviceCl protected: Filter *filter_list_{nullptr}; + + bool set_new_state(const optional &new_state) override; }; class BinarySensorInitiallyOff : public BinarySensor { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index aa9b92877a..fdf3f6300a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -205,7 +205,7 @@ template class StatefulEntityBase : public EntityBase { virtual bool has_state() const { return this->state_.has_value(); } virtual const T &get_state() const { return this->state_.value(); } virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); } - void invalidate_state() { this->set_state_({}); } + void invalidate_state() { this->set_new_state({}); } void add_full_state_callback(std::function previous, optional current)> &&callback) { if (this->full_state_callbacks_ == nullptr) @@ -227,20 +227,20 @@ template class StatefulEntityBase : public EntityBase { /** * Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous. * - * @param state The new state. + * @param new_state The new state. * @return True if the state was changed, false if it was the same as before. */ - bool set_state_(const optional &state) { - if (this->state_ != state) { + virtual bool set_new_state(const optional &new_state) { + if (this->state_ != new_state) { // call the full state callbacks with the previous and new state if (this->full_state_callbacks_ != nullptr) - this->full_state_callbacks_->call(this->state_, state); + this->full_state_callbacks_->call(this->state_, new_state); // trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or // the previous state was valid auto had_state = this->has_state(); - this->state_ = state; - if (this->state_callbacks_ != nullptr && state.has_value() && (this->trigger_on_initial_state_ || had_state)) - this->state_callbacks_->call(state.value()); + this->state_ = new_state; + if (this->state_callbacks_ != nullptr && new_state.has_value() && (this->trigger_on_initial_state_ || had_state)) + this->state_callbacks_->call(new_state.value()); return true; } return false; diff --git a/tests/integration/README.md b/tests/integration/README.md index 2a6b6fe564..f99139db00 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -7,7 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te - `conftest.py` - Common fixtures and utilities - `const.py` - Constants used throughout the integration tests - `types.py` - Type definitions for fixtures and functions -- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `build_key_to_entity_mapping`) +- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`) - `fixtures/` - YAML configuration files for tests - `test_*.py` - Individual test files @@ -53,6 +53,28 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe **Future work:** Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts. +#### Entity Lookup Helpers (`state_utils.py`) + +Two helper functions simplify finding entities in test code: + +**`find_entity(entities, object_id_substring, entity_type=None)`** +- Finds an entity by searching for a substring in its `object_id` (case-insensitive) +- Optionally filters by entity type (e.g., `BinarySensorInfo`) +- Returns `None` if not found + +**`require_entity(entities, object_id_substring, entity_type=None, description=None)`** +- Same as `find_entity` but raises `AssertionError` if not found +- Use `description` parameter for clearer error messages + +```python +from aioesphomeapi import BinarySensorInfo +from .state_utils import require_entity + +# Find entities with clear error messages +binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) +button = require_entity(entities, "set_true", description="Set True button") +``` + ### Writing Tests The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures: diff --git a/tests/integration/fixtures/binary_sensor_invalidate_state.yaml b/tests/integration/fixtures/binary_sensor_invalidate_state.yaml new file mode 100644 index 0000000000..4016cfe281 --- /dev/null +++ b/tests/integration/fixtures/binary_sensor_invalidate_state.yaml @@ -0,0 +1,39 @@ +esphome: + name: test-binary-sensor-invalidate + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Template binary sensor that we can control +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + +# Buttons to control the binary sensor state +button: + - platform: template + name: "Set True" + id: set_true_button + on_press: + - binary_sensor.template.publish: + id: test_binary_sensor + state: true + + - platform: template + name: "Set False" + id: set_false_button + on_press: + - binary_sensor.template.publish: + id: test_binary_sensor + state: false + + - platform: template + name: "Invalidate State" + id: invalidate_button + on_press: + - binary_sensor.invalidate_state: + id: test_binary_sensor diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 6434a41ddf..b649056f2b 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -4,11 +4,74 @@ from __future__ import annotations import asyncio import logging +from typing import TypeVar from aioesphomeapi import ButtonInfo, EntityInfo, EntityState _LOGGER = logging.getLogger(__name__) +T = TypeVar("T", bound=EntityInfo) + + +def find_entity( + entities: list[EntityInfo], + object_id_substring: str, + entity_type: type[T] | None = None, +) -> T | EntityInfo | None: + """Find an entity by object_id substring and optionally by type. + + Args: + entities: List of entity info objects from the API + object_id_substring: Substring to search for in object_id (case-insensitive) + entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + + Returns: + The first matching entity, or None if not found + + Example: + binary_sensor = find_entity(entities, "test_binary_sensor", BinarySensorInfo) + button = find_entity(entities, "set_true") # Any entity type + """ + substring_lower = object_id_substring.lower() + for entity in entities: + if substring_lower in entity.object_id.lower() and ( + entity_type is None or isinstance(entity, entity_type) + ): + return entity + return None + + +def require_entity( + entities: list[EntityInfo], + object_id_substring: str, + entity_type: type[T] | None = None, + description: str | None = None, +) -> T | EntityInfo: + """Find an entity or raise AssertionError if not found. + + Args: + entities: List of entity info objects from the API + object_id_substring: Substring to search for in object_id (case-insensitive) + entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + description: Human-readable description for error message + + Returns: + The first matching entity + + Raises: + AssertionError: If no matching entity is found + + Example: + binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) + button = require_entity(entities, "set_true", description="Set True button") + """ + entity = find_entity(entities, object_id_substring, entity_type) + if entity is None: + desc = description or f"entity with '{object_id_substring}' in object_id" + type_info = f" of type {entity_type.__name__}" if entity_type else "" + raise AssertionError(f"{desc}{type_info} not found in entities") + return entity + def build_key_to_entity_mapping( entities: list[EntityInfo], entity_names: list[str] diff --git a/tests/integration/test_binary_sensor_invalidate_state.py b/tests/integration/test_binary_sensor_invalidate_state.py new file mode 100644 index 0000000000..ee9e57319c --- /dev/null +++ b/tests/integration/test_binary_sensor_invalidate_state.py @@ -0,0 +1,138 @@ +"""Integration test for binary_sensor.invalidate_state() functionality. + +This tests the fix in PR #12296 where invalidate_state() was not properly +reporting the 'unknown' state to the API. The binary sensor should report +missing_state=True when invalidated. + +Regression test for: https://github.com/esphome/esphome/issues/12252 +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_binary_sensor_invalidate_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that binary_sensor.invalidate_state() reports unknown to the API. + + This verifies that: + 1. Binary sensor starts with missing_state=True (no initial state) + 2. Publishing true sets missing_state=False and state=True + 3. Publishing false sets missing_state=False and state=False + 4. Invalidating state sets missing_state=True (unknown state) + """ + loop = asyncio.get_running_loop() + + # Track state changes + states_received: list[BinarySensorState] = [] + state_future: asyncio.Future[BinarySensorState] = loop.create_future() + + def on_state(state: EntityState) -> None: + """Track binary sensor state changes.""" + if isinstance(state, BinarySensorState): + states_received.append(state) + if not state_future.done(): + state_future.set_result(state) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-binary-sensor-invalidate" + + # Get entities + entities, _ = await client.list_entities_services() + + # Find our binary sensor and buttons using helper + binary_sensor = require_entity(entities, "test_binary_sensor", BinarySensorInfo) + set_true_button = require_entity( + entities, "set_true", description="Set True button" + ) + set_false_button = require_entity( + entities, "set_false", description="Set False button" + ) + invalidate_button = require_entity( + entities, "invalidate", description="Invalidate button" + ) + + # Set up initial state helper to handle the initial state broadcast + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Check initial state - should be missing (unknown) + initial_state = initial_state_helper.initial_states.get(binary_sensor.key) + assert initial_state is not None, "No initial state received for binary sensor" + assert isinstance(initial_state, BinarySensorState) + assert initial_state.missing_state is True, ( + f"Initial state should have missing_state=True, got {initial_state}" + ) + + # Test 1: Set state to true + states_received.clear() + state_future = loop.create_future() + client.button_command(set_true_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for state=true") + + assert state.missing_state is False, ( + f"After setting true, missing_state should be False, got {state}" + ) + assert state.state is True, f"Expected state=True, got {state}" + + # Test 2: Set state to false + states_received.clear() + state_future = loop.create_future() + client.button_command(set_false_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for state=false") + + assert state.missing_state is False, ( + f"After setting false, missing_state should be False, got {state}" + ) + assert state.state is False, f"Expected state=False, got {state}" + + # Test 3: Invalidate state (set to unknown) + # This is the critical test for the bug fix + states_received.clear() + state_future = loop.create_future() + client.button_command(invalidate_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for invalidated state - " + "binary_sensor.invalidate_state() may not be reporting to the API. " + "See issue #12252." + ) + + assert state.missing_state is True, ( + f"After invalidate_state(), missing_state should be True (unknown), " + f"got {state}. This is the regression from issue #12252." + ) From 6220427524bbbf85401ba08f5ac612c509a90fa5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:32:20 -0500 Subject: [PATCH 264/320] [cc1101] Use Hz and cv.frequency instead of kHz (#12313) --- esphome/components/cc1101/__init__.py | 10 +++++----- esphome/components/cc1101/cc1101.cpp | 17 ++++++++--------- esphome/components/cc1101/cc1101defs.h | 2 +- tests/components/cc1101/common.yaml | 8 ++++---- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 0f5743d0cd..e6b31b84f8 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -152,12 +152,12 @@ CONFIG_MAP = { CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0), CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False), CONF_DC_BLOCKING_FILTER: cv.boolean, - CONF_FREQUENCY: cv.float_range(min=300000.0, max=928000.0), - CONF_IF_FREQUENCY: cv.float_range(min=25, max=788), - CONF_FILTER_BANDWIDTH: cv.float_range(min=58.0, max=812.0), + CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300000000, max=928000000)), + CONF_IF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), + CONF_FILTER_BANDWIDTH: cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), CONF_CHANNEL: cv.uint8_t, - CONF_CHANNEL_SPACING: cv.float_range(min=25, max=405), - CONF_FSK_DEVIATION: cv.float_range(min=1.5, max=381), + CONF_CHANNEL_SPACING: cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), + CONF_FSK_DEVIATION: cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), CONF_MSK_DEVIATION: cv.int_range(min=1, max=8), CONF_SYMBOL_RATE: cv.float_range(min=600, max=500000), CONF_SYNC_MODE: cv.enum(SYNC_MODE, upper=False), diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 1a758e415a..3cbf09ded8 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -160,18 +160,17 @@ void CC1101Component::dump_config() { "4-FSK", "UNUSED", "UNUSED", "MSK"}; int32_t freq = static_cast(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) * XTAL_FREQUENCY / (1 << 16); - float symbol_rate = - (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY * 1000.0f; + float symbol_rate = (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY; float bw = XTAL_FREQUENCY / (8.0f * (4 + this->state_.CHANBW_M) * (1 << this->state_.CHANBW_E)); ESP_LOGCONFIG(TAG, "CC1101:"); LOG_PIN(" CS Pin: ", this->cs_); ESP_LOGCONFIG(TAG, " Chip ID: 0x%04X\n" - " Frequency: %" PRId32 " kHz\n" + " Frequency: %" PRId32 " Hz\n" " Channel: %u\n" " Modulation: %s\n" " Symbol Rate: %.0f baud\n" - " Filter Bandwidth: %.1f kHz\n" + " Filter Bandwidth: %.1f Hz\n" " Output Power: %.1f dBm", this->chip_id_, freq, this->state_.CHANNR, MODULATION_NAMES[this->state_.MOD_FORMAT & 0x07], symbol_rate, bw, this->output_power_effective_); @@ -289,13 +288,13 @@ void CC1101Component::set_output_power(float value) { int32_t freq = static_cast(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) * XTAL_FREQUENCY / (1 << 16); uint8_t a = 0xC0; - if (freq >= 300000 && freq <= 348000) { + if (freq >= 300000000 && freq <= 348000000) { a = PowerTableItem::find(PA_TABLE_315, sizeof(PA_TABLE_315) / sizeof(PA_TABLE_315[0]), value); - } else if (freq >= 378000 && freq <= 464000) { + } else if (freq >= 378000000 && freq <= 464000000) { a = PowerTableItem::find(PA_TABLE_433, sizeof(PA_TABLE_433) / sizeof(PA_TABLE_433[0]), value); - } else if (freq >= 779000 && freq < 900000) { + } else if (freq >= 779000000 && freq < 900000000) { a = PowerTableItem::find(PA_TABLE_868, sizeof(PA_TABLE_868) / sizeof(PA_TABLE_868[0]), value); - } else if (freq >= 900000 && freq <= 928000) { + } else if (freq >= 900000000 && freq <= 928000000) { a = PowerTableItem::find(PA_TABLE_915, sizeof(PA_TABLE_915) / sizeof(PA_TABLE_915[0]), value); } @@ -401,7 +400,7 @@ void CC1101Component::set_msk_deviation(uint8_t value) { void CC1101Component::set_symbol_rate(float value) { uint8_t e; uint32_t m; - split_float(value * (1 << 28) / (XTAL_FREQUENCY * 1000), 8, e, m); + split_float(value * (1 << 28) / XTAL_FREQUENCY, 8, e, m); this->state_.DRATE_E = e; this->state_.DRATE_M = static_cast(m); if (this->initialized_) { diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index 52f15cb85a..afeb5f1d77 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -4,7 +4,7 @@ namespace esphome::cc1101 { -static constexpr float XTAL_FREQUENCY = 26000; +static constexpr float XTAL_FREQUENCY = 26000000; static constexpr uint8_t BUS_BURST = 0x40; static constexpr uint8_t BUS_READ = 0x80; diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml index 7fd265ca4a..9373ca43e1 100644 --- a/tests/components/cc1101/common.yaml +++ b/tests/components/cc1101/common.yaml @@ -1,11 +1,11 @@ cc1101: id: transceiver cs_pin: ${cs_pin} - frequency: 433920 - if_frequency: 153 - filter_bandwidth: 203 + frequency: 433.92MHz + if_frequency: 153kHz + filter_bandwidth: 203kHz channel: 0 - channel_spacing: 200 + channel_spacing: 200kHz symbol_rate: 5000 modulation_type: ASK/OOK From 7eae0a49725c00fe68dfca20f6648b432a6611a6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:46:39 +1100 Subject: [PATCH 265/320] [image] Add USE_IMAGE in defines.h (#12317) --- esphome/core/defines.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 358334d7b3..eea92f77ac 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -46,6 +46,7 @@ #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT +#define USE_IMAGE #define USE_IMPROV_SERIAL_NEXT_URL #define USE_JSON #define USE_LIGHT From 3c7d6b7fc64cb5037d56434b847bf922a6b9861c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:49:23 +1100 Subject: [PATCH 266/320] [ci-custom] Fix after switch from string to path (#12314) --- script/ci-custom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/ci-custom.py b/script/ci-custom.py index 106aa438fe..609d89403f 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -554,10 +554,10 @@ def convert_path_to_relative(abspath, current): "esphome/components/web_server/__init__.py", ], ) -def lint_relative_py_import(fname, line, col, content): +def lint_relative_py_import(fname: Path, line, col, content): import_line = content.splitlines()[line] abspath = import_line[col:].split(" ")[0] - current = fname.removesuffix(".py").replace(os.path.sep, ".") + current = str(fname).removesuffix(".py").replace(os.path.sep, ".") replacement = convert_path_to_relative(abspath, current) newline = import_line.replace(abspath, replacement) return ( From 75c41b11d19b22320e6c84e1423a7c13964e2485 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 7 Dec 2025 01:49:15 +1100 Subject: [PATCH 267/320] [lvgl] Number saves value on interactive change (#12315) --- esphome/components/lvgl/number/lvgl_number.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 7bc44c9e20..d9885bc7fb 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -29,15 +29,18 @@ class LVGLNumber : public number::Number, public Component { this->publish_state(value); } - void on_value() { this->publish_state(this->value_lambda_()); } + void on_value() { this->publish_(this->value_lambda_()); } protected: - void control(float value) override { - this->control_lambda_(value); + void publish_(float value) { this->publish_state(value); if (this->restore_) this->pref_.save(&value); } + void control(float value) override { + this->control_lambda_(value); + this->publish_(value); + } std::function control_lambda_; std::function value_lambda_; lv_event_code_t event_; From f20aaf398162cf9c19cf99ad7a22343ff04bef5e Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Sun, 7 Dec 2025 04:47:57 +1300 Subject: [PATCH 268/320] [api] Device defined action responses (#12136) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/__init__.py | 241 ++++++++++++++-- esphome/components/api/api.proto | 24 ++ esphome/components/api/api_connection.cpp | 42 +++ esphome/components/api/api_connection.h | 7 + esphome/components/api/api_pb2.cpp | 37 +++ esphome/components/api/api_pb2.h | 43 ++- esphome/components/api/api_pb2_dump.cpp | 34 +++ esphome/components/api/api_server.cpp | 84 +++++- esphome/components/api/api_server.h | 33 ++- esphome/components/api/custom_api_device.h | 5 +- esphome/components/api/list_entities.cpp | 3 + esphome/components/api/user_services.h | 212 ++++++++++++-- esphome/core/defines.h | 2 + requirements.txt | 2 +- tests/components/api/common-base.yaml | 93 +++++++ tests/integration/README.md | 2 +- .../fixtures/api_action_responses.yaml | 93 +++++++ .../fixtures/api_action_timeout.yaml | 45 +++ .../integration/test_api_action_responses.py | 258 ++++++++++++++++++ tests/integration/test_api_action_timeout.py | 172 ++++++++++++ .../test_api_conditional_memory.py | 4 +- tests/integration/test_api_custom_services.py | 12 +- tests/integration/test_api_homeassistant.py | 2 +- tests/integration/test_api_string_lambda.py | 10 +- .../test_automation_wait_actions.py | 6 +- tests/integration/test_automations.py | 4 +- .../integration/test_continuation_actions.py | 18 +- .../test_scheduler_bulk_cleanup.py | 2 +- .../test_scheduler_defer_cancel.py | 2 +- .../test_scheduler_defer_cancel_regular.py | 2 +- .../test_scheduler_defer_fifo_simple.py | 4 +- .../test_scheduler_defer_stress.py | 2 +- .../integration/test_scheduler_heap_stress.py | 2 +- tests/integration/test_scheduler_null_name.py | 2 +- tests/integration/test_scheduler_pool.py | 16 +- .../test_scheduler_rapid_cancellation.py | 2 +- .../test_scheduler_recursive_timeout.py | 2 +- .../test_scheduler_removed_item_race.py | 2 +- .../test_scheduler_simultaneous_callbacks.py | 2 +- .../test_scheduler_string_lifetime.py | 12 +- .../test_scheduler_string_name_stress.py | 2 +- tests/integration/test_script_delay_params.py | 2 +- tests/integration/test_script_queued.py | 10 +- .../test_wait_until_mid_loop_timing.py | 2 +- tests/integration/test_wait_until_on_boot.py | 2 +- tests/integration/test_wait_until_ordering.py | 2 +- 46 files changed, 1455 insertions(+), 105 deletions(-) create mode 100644 tests/integration/fixtures/api_action_responses.yaml create mode 100644 tests/integration/fixtures/api_action_timeout.yaml create mode 100644 tests/integration/test_api_action_responses.py create mode 100644 tests/integration/test_api_action_timeout.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 2910643dfb..d349cf3867 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -27,12 +27,13 @@ from esphome.const import ( CONF_SERVICE, CONF_SERVICES, CONF_TAG, + CONF_THEN, CONF_TRIGGER_ID, CONF_VARIABLES, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import TemplateArgsType -from esphome.types import ConfigType +from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigFragmentType, ConfigType _LOGGER = logging.getLogger(__name__) @@ -63,17 +64,21 @@ HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger ) APIConnectedCondition = api_ns.class_("APIConnectedCondition", Condition) +APIRespondAction = api_ns.class_("APIRespondAction", automation.Action) +APIUnregisterServiceCallAction = api_ns.class_( + "APIUnregisterServiceCallAction", automation.Action +) UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger) ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument") -SERVICE_ARG_NATIVE_TYPES = { - "bool": bool, +SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { + "bool": cg.bool_, "int": cg.int32, - "float": float, + "float": cg.float_, "string": cg.std_string, - "bool[]": cg.FixedVector.template(bool).operator("const").operator("ref"), + "bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"), "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"), - "float[]": cg.FixedVector.template(float).operator("const").operator("ref"), + "float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"), "string[]": cg.FixedVector.template(cg.std_string) .operator("const") .operator("ref"), @@ -102,6 +107,85 @@ def validate_encryption_key(value): return value +CONF_SUPPORTS_RESPONSE = "supports_response" + +# Enum values in api::enums namespace +enums_ns = api_ns.namespace("enums") +SUPPORTS_RESPONSE_OPTIONS = { + "none": enums_ns.SUPPORTS_RESPONSE_NONE, + "optional": enums_ns.SUPPORTS_RESPONSE_OPTIONAL, + "only": enums_ns.SUPPORTS_RESPONSE_ONLY, + "status": enums_ns.SUPPORTS_RESPONSE_STATUS, +} + + +def _auto_detect_supports_response(config: ConfigType) -> ConfigType: + """Auto-detect supports_response based on api.respond usage in the action's then block. + + - If api.respond with data found: set to "optional" (unless user explicitly set) + - If api.respond without data found: set to "status" (unless user explicitly set) + - If no api.respond found: set to "none" (unless user explicitly set) + """ + + def scan_actions(items: ConfigFragmentType) -> tuple[bool, bool]: + """Recursively scan actions for api.respond. + + Returns: (found, has_data) tuple - has_data is True if ANY api.respond has data + """ + found_any = False + has_data_any = False + + if isinstance(items, list): + for item in items: + found, has_data = scan_actions(item) + if found: + found_any = True + has_data_any = has_data_any or has_data + elif isinstance(items, dict): + # Check if this is an api.respond action + if "api.respond" in items: + respond_config = items["api.respond"] + has_data = isinstance(respond_config, dict) and "data" in respond_config + return True, has_data + # Recursively check all values + for value in items.values(): + found, has_data = scan_actions(value) + if found: + found_any = True + has_data_any = has_data_any or has_data + + return found_any, has_data_any + + then = config.get(CONF_THEN, []) + action_name = config.get(CONF_ACTION) + found, has_data = scan_actions(then) + + # If user explicitly set supports_response, validate and use that + if CONF_SUPPORTS_RESPONSE in config: + user_value = config[CONF_SUPPORTS_RESPONSE] + # Validate: "only" requires api.respond with data + if user_value == "only" and not has_data: + raise cv.Invalid( + f"Action '{action_name}' has supports_response=only but no api.respond " + "action with 'data:' was found. Use 'status' for responses without data, " + "or add 'data:' to your api.respond action." + ) + return config + + # Auto-detect based on api.respond usage + if found: + config[CONF_SUPPORTS_RESPONSE] = "optional" if has_data else "status" + else: + config[CONF_SUPPORTS_RESPONSE] = "none" + + return config + + +def _validate_supports_response(value): + """Validate supports_response after auto-detection has set the value.""" + return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) + + ACTIONS_SCHEMA = automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger), @@ -112,10 +196,20 @@ ACTIONS_SCHEMA = automation.validate_automation( cv.validate_id_name: cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), } ), + # No default - auto-detected by _auto_detect_supports_response + cv.Optional(CONF_SUPPORTS_RESPONSE): cv.enum( + SUPPORTS_RESPONSE_OPTIONS, lower=True + ), }, cv.All( cv.has_exactly_one_key(CONF_SERVICE, CONF_ACTION), cv.rename_key(CONF_SERVICE, CONF_ACTION), + _auto_detect_supports_response, + # Re-validate supports_response after auto-detection sets it + cv.Schema( + {cv.Required(CONF_SUPPORTS_RESPONSE): _validate_supports_response}, + extra=cv.ALLOW_EXTRA, + ), ), ) @@ -242,7 +336,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.WEB) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -279,20 +373,61 @@ async def to_code(config): # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.Pvariable] = [] for conf in actions: - template_args = [] - func_args = [] - service_arg_names = [] + func_args: list[tuple[MockObj, str]] = [] + service_template_args: list[MockObj] = [] # User service argument types + + # Determine supports_response mode + # cv.enum returns the key with enum_value attribute containing the MockObj + supports_response_key = conf[CONF_SUPPORTS_RESPONSE] + supports_response = supports_response_key.enum_value + is_none = supports_response_key == "none" + is_optional = supports_response_key == "optional" + + # Add call_id and return_response based on supports_response mode + # These must match the C++ Trigger template arguments + # - none: no extra args + # - status: call_id only (for reporting success/error without data) + # - only: call_id only (response always expected with data) + # - optional: call_id + return_response (client decides) + if not is_none: + # call_id is present for "optional", "only", and "status" + func_args.append((cg.uint32, "call_id")) + # return_response only present for "optional" + if is_optional: + func_args.append((cg.bool_, "return_response")) + + service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): native = SERVICE_ARG_NATIVE_TYPES[var_] - template_args.append(native) + service_template_args.append(native) func_args.append((native, name)) service_arg_names.append(name) - templ = cg.TemplateArguments(*template_args) + # Template args: supports_response mode, then user service arg types + templ = cg.TemplateArguments(supports_response, *service_template_args) trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], templ, conf[CONF_ACTION], service_arg_names + conf[CONF_TRIGGER_ID], + templ, + conf[CONF_ACTION], + service_arg_names, ) triggers.append(trigger) - await automation.build_automation(trigger, func_args, conf) + auto = await automation.build_automation(trigger, func_args, conf) + + # For non-none response modes, automatically append unregister action + # This ensures the call is unregistered after all actions complete (including async ones) + if not is_none: + arg_types = [arg[0] for arg in func_args] + action_templ = cg.TemplateArguments(*arg_types) + unregister_id = ID( + f"{conf[CONF_TRIGGER_ID]}__unregister", + is_declaration=True, + type=APIUnregisterServiceCallAction.template(action_templ), + ) + unregister_action = cg.new_Pvariable( + unregister_id, + var, + ) + cg.add(auto.add_actions([unregister_action])) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) @@ -538,6 +673,80 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg return var +CONF_SUCCESS = "success" +CONF_ERROR_MESSAGE = "error_message" + + +def _validate_api_respond_data(config): + """Set flag during validation so AUTO_LOAD can include json component.""" + if CONF_DATA in config: + CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True + return config + + +API_RESPOND_ACTION_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(APIServer), + cv.Optional(CONF_SUCCESS, default=True): cv.templatable(cv.boolean), + cv.Optional(CONF_ERROR_MESSAGE, default=""): cv.templatable(cv.string), + cv.Optional(CONF_DATA): cv.lambda_, + } + ), + _validate_api_respond_data, +) + + +@automation.register_action( + "api.respond", + APIRespondAction, + API_RESPOND_ACTION_SCHEMA, +) +async def api_respond_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Validate that api.respond is used inside an API action context. + # We can't easily validate this at config time since the schema validation + # doesn't have access to the parent action context. Validating here in to_code + # is still much better than a cryptic C++ compile error. + has_call_id = any(name == "call_id" for _, name in args) + if not has_call_id: + raise EsphomeError( + "api.respond can only be used inside an API action's 'then:' block. " + "The 'call_id' variable is required to send a response." + ) + + cg.add_define("USE_API_USER_DEFINED_ACTION_RESPONSES") + serv = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, serv) + + # Check if we're in optional mode (has return_response arg) + is_optional = any(name == "return_response" for _, name in args) + if is_optional: + cg.add(var.set_is_optional_mode(True)) + + templ = await cg.templatable(config[CONF_SUCCESS], args, cg.bool_) + cg.add(var.set_success(templ)) + + templ = await cg.templatable(config[CONF_ERROR_MESSAGE], args, cg.std_string) + cg.add(var.set_error_message(templ)) + + if CONF_DATA in config: + cg.add_define("USE_API_USER_DEFINED_ACTION_RESPONSES_JSON") + # Lambda populates the JsonObject root - no return value needed + lambda_ = await cg.process_lambda( + config[CONF_DATA], + args + [(cg.JsonObject, "root")], + return_type=cg.void, + ) + cg.add(var.set_data(lambda_)) + + return var + + API_CONNECTED_CONDITION_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(APIServer), diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 5450c2536c..3fc2e1fed8 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -855,6 +855,14 @@ enum ServiceArgType { SERVICE_ARG_TYPE_FLOAT_ARRAY = 6; SERVICE_ARG_TYPE_STRING_ARRAY = 7; } +enum SupportsResponseType { + SUPPORTS_RESPONSE_NONE = 0; + SUPPORTS_RESPONSE_OPTIONAL = 1; + SUPPORTS_RESPONSE_ONLY = 2; + // Status-only response - reports success/error without data payload + // Value is higher to avoid conflicts with future Home Assistant values + SUPPORTS_RESPONSE_STATUS = 100; +} message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; @@ -868,6 +876,7 @@ message ListEntitiesServicesResponse { string name = 1; fixed32 key = 2; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; + SupportsResponseType supports_response = 4; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; @@ -890,6 +899,21 @@ message ExecuteServiceRequest { fixed32 key = 1; repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true]; + uint32 call_id = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"]; + bool return_response = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"]; +} + +// Message sent by ESPHome to Home Assistant with service execution response data +message ExecuteServiceResponse { + option (id) = 131; + option (source) = SOURCE_SERVER; + option (no_delay) = true; + option (ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"; + + uint32 call_id = 1; // Matches the call_id from ExecuteServiceRequest + bool success = 2; // Whether the service execution succeeded + string error_message = 3; // Error message if success = false + bytes response_data = 4 [(pointer_to_buffer) = true, (field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES_JSON"]; } // ==================== CAMERA ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 31f90d9474..f0428546de 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -6,6 +6,9 @@ #ifdef USE_API_PLAINTEXT #include "api_frame_helper_plaintext.h" #endif +#ifdef USE_API_USER_DEFINED_ACTIONS +#include "user_services.h" +#endif #include #include #include @@ -1554,15 +1557,54 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes #ifdef USE_API_USER_DEFINED_ACTIONS void APIConnection::execute_service(const ExecuteServiceRequest &msg) { bool found = false; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Register the call and get a unique server-generated action_call_id + // This avoids collisions when multiple clients use the same call_id + uint32_t action_call_id = 0; + if (msg.call_id != 0) { + action_call_id = this->parent_->register_active_action_call(msg.call_id, this); + } + // Use the overload that passes action_call_id separately (avoids copying msg) + for (auto *service : this->parent_->get_user_services()) { + if (service->execute_service(msg, action_call_id)) { + found = true; + } + } +#else for (auto *service : this->parent_->get_user_services()) { if (service->execute_service(msg)) { found = true; } } +#endif if (!found) { ESP_LOGV(TAG, "Could not find service"); } + // Note: For services with supports_response != none, the call is unregistered + // by an automatically appended APIUnregisterServiceCallAction at the end of + // the action list. This ensures async actions (delays, waits) complete first. } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +void APIConnection::send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message) { + ExecuteServiceResponse resp; + resp.call_id = call_id; + resp.success = success; + resp.set_error_message(StringRef(error_message)); + this->send_message(resp, ExecuteServiceResponse::MESSAGE_TYPE); +} +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +void APIConnection::send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len) { + ExecuteServiceResponse resp; + resp.call_id = call_id; + resp.success = success; + resp.set_error_message(StringRef(error_message)); + resp.response_data = response_data; + resp.response_data_len = response_data_len; + this->send_message(resp, ExecuteServiceResponse::MESSAGE_TYPE); +} +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 6bf4f45a5c..b50be5d0d4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,6 +223,13 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_API_USER_DEFINED_ACTIONS void execute_service(const ExecuteServiceRequest &msg) override; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + void send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + void send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len); +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) override; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c131815456..a3da6591f4 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1010,11 +1010,13 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->args) { buffer.encode_message(3, it, true); } + buffer.encode_uint32(4, static_cast(this->supports_response)); } void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); size.add_fixed32(1, this->key); size.add_repeated_message(1, this->args); + size.add_uint32(1, static_cast(this->supports_response)); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1075,6 +1077,23 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->string_array.init(count_string_array); ProtoDecodableMessage::decode(buffer, length); } +bool ExecuteServiceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + case 3: + this->call_id = value.as_uint32(); + break; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + case 4: + this->return_response = value.as_bool(); + break; +#endif + default: + return false; + } + return true; +} bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: @@ -1102,6 +1121,24 @@ void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode(buffer, length); } #endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +void ExecuteServiceResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, this->call_id); + buffer.encode_bool(2, this->success); + buffer.encode_string(3, this->error_message_ref_); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + buffer.encode_bytes(4, this->response_data, this->response_data_len); +#endif +} +void ExecuteServiceResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->call_id); + size.add_bool(1, this->success); + size.add_length(1, this->error_message_ref_.size()); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + size.add_length(4, this->response_data_len); +#endif +} +#endif #ifdef USE_CAMERA void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 74d3834bf5..7e41cd8a22 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -75,6 +75,12 @@ enum ServiceArgType : uint32_t { SERVICE_ARG_TYPE_FLOAT_ARRAY = 6, SERVICE_ARG_TYPE_STRING_ARRAY = 7, }; +enum SupportsResponseType : uint32_t { + SUPPORTS_RESPONSE_NONE = 0, + SUPPORTS_RESPONSE_OPTIONAL = 1, + SUPPORTS_RESPONSE_ONLY = 2, + SUPPORTS_RESPONSE_STATUS = 100, +}; #endif #ifdef USE_CLIMATE enum ClimateMode : uint32_t { @@ -1257,7 +1263,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage { class ListEntitiesServicesResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 41; - static constexpr uint8_t ESTIMATED_SIZE = 48; + static constexpr uint8_t ESTIMATED_SIZE = 50; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_services_response"; } #endif @@ -1265,6 +1271,7 @@ class ListEntitiesServicesResponse final : public ProtoMessage { void set_name(const StringRef &ref) { this->name_ref_ = ref; } uint32_t key{0}; FixedVector args{}; + enums::SupportsResponseType supports_response{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1297,12 +1304,18 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { class ExecuteServiceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 42; - static constexpr uint8_t ESTIMATED_SIZE = 39; + static constexpr uint8_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "execute_service_request"; } #endif uint32_t key{0}; FixedVector args{}; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + uint32_t call_id{0}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + bool return_response{false}; +#endif void decode(const uint8_t *buffer, size_t length) override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1311,6 +1324,32 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +class ExecuteServiceResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 131; + static constexpr uint8_t ESTIMATED_SIZE = 34; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "execute_service_response"; } +#endif + uint32_t call_id{0}; + bool success{false}; + StringRef error_message_ref_{}; + void set_error_message(const StringRef &ref) { this->error_message_ref_ = ref; } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + const uint8_t *response_data{nullptr}; + uint16_t response_data_len{0}; +#endif + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: }; #endif #ifdef USE_CAMERA diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bea7fc53c4..59fc1367fe 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -231,6 +231,20 @@ template<> const char *proto_enum_to_string(enums::Servic return "UNKNOWN"; } } +template<> const char *proto_enum_to_string(enums::SupportsResponseType value) { + switch (value) { + case enums::SUPPORTS_RESPONSE_NONE: + return "SUPPORTS_RESPONSE_NONE"; + case enums::SUPPORTS_RESPONSE_OPTIONAL: + return "SUPPORTS_RESPONSE_OPTIONAL"; + case enums::SUPPORTS_RESPONSE_ONLY: + return "SUPPORTS_RESPONSE_ONLY"; + case enums::SUPPORTS_RESPONSE_STATUS: + return "SUPPORTS_RESPONSE_STATUS"; + default: + return "UNKNOWN"; + } +} #endif #ifdef USE_CLIMATE template<> const char *proto_enum_to_string(enums::ClimateMode value) { @@ -1194,6 +1208,7 @@ void ListEntitiesServicesResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + dump_field(out, "supports_response", static_cast(this->supports_response)); } void ExecuteServiceArgument::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ExecuteServiceArgument"); @@ -1223,6 +1238,25 @@ void ExecuteServiceRequest::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + dump_field(out, "call_id", this->call_id); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + dump_field(out, "return_response", this->return_response); +#endif +} +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +void ExecuteServiceResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ExecuteServiceResponse"); + dump_field(out, "call_id", this->call_id); + dump_field(out, "success", this->success); + dump_field(out, "error_message", this->error_message_ref_); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + out.append(" response_data: "); + out.append(format_hex_pretty(this->response_data, this->response_data_len)); + out.append("\n"); +#endif } #endif #ifdef USE_CAMERA diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 565714a4e5..1921ca95d4 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -4,8 +4,8 @@ #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" -#include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -186,6 +186,9 @@ void APIServer::loop() { // Rare case: handle disconnection #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER this->client_disconnected_trigger_->trigger(client->client_info_.name, client->client_info_.peername); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + this->unregister_active_action_calls_for_connection(client.get()); #endif ESP_LOGV(TAG, "Remove connection %s", client->client_info_.name.c_str()); @@ -585,5 +588,84 @@ bool APIServer::teardown() { return this->clients_.empty(); } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +// Timeout for action calls - matches aioesphomeapi client timeout (default 30s) +// Can be overridden via USE_API_ACTION_CALL_TIMEOUT_MS define for testing +#ifndef USE_API_ACTION_CALL_TIMEOUT_MS +#define USE_API_ACTION_CALL_TIMEOUT_MS 30000 // NOLINT +#endif + +uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConnection *conn) { + uint32_t action_call_id = this->next_action_call_id_++; + // Handle wraparound (skip 0 as it means "no call") + if (this->next_action_call_id_ == 0) { + this->next_action_call_id_ = 1; + } + this->active_action_calls_.push_back({action_call_id, client_call_id, conn}); + + // Schedule automatic cleanup after timeout (client will have given up by then) + this->set_timeout(str_sprintf("action_call_%u", action_call_id), USE_API_ACTION_CALL_TIMEOUT_MS, + [this, action_call_id]() { + ESP_LOGD(TAG, "Action call %u timed out", action_call_id); + this->unregister_active_action_call(action_call_id); + }); + + return action_call_id; +} + +void APIServer::unregister_active_action_call(uint32_t action_call_id) { + // Cancel the timeout for this action call + this->cancel_timeout(str_sprintf("action_call_%u", action_call_id)); + + // Swap-and-pop is more efficient than remove_if for unordered vectors + for (size_t i = 0; i < this->active_action_calls_.size(); i++) { + if (this->active_action_calls_[i].action_call_id == action_call_id) { + std::swap(this->active_action_calls_[i], this->active_action_calls_.back()); + this->active_action_calls_.pop_back(); + return; + } + } +} + +void APIServer::unregister_active_action_calls_for_connection(APIConnection *conn) { + // Remove all active action calls for disconnected connection using swap-and-pop + for (size_t i = 0; i < this->active_action_calls_.size();) { + if (this->active_action_calls_[i].connection == conn) { + // Cancel the timeout for this action call + this->cancel_timeout(str_sprintf("action_call_%u", this->active_action_calls_[i].action_call_id)); + + std::swap(this->active_action_calls_[i], this->active_action_calls_.back()); + this->active_action_calls_.pop_back(); + // Don't increment i - need to check the swapped element + } else { + i++; + } + } +} + +void APIServer::send_action_response(uint32_t action_call_id, bool success, const std::string &error_message) { + for (auto &call : this->active_action_calls_) { + if (call.action_call_id == action_call_id) { + call.connection->send_execute_service_response(call.client_call_id, success, error_message); + return; + } + } + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); +} +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +void APIServer::send_action_response(uint32_t action_call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len) { + for (auto &call : this->active_action_calls_) { + if (call.action_call_id == action_call_id) { + call.connection->send_execute_service_response(call.client_call_id, success, error_message, response_data, + response_data_len); + return; + } + } + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); +} +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES + } // namespace esphome::api #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index eb495afde7..2175d047eb 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -12,9 +12,6 @@ #include "esphome/core/log.h" #include "list_entities.h" #include "subscribe_state.h" -#ifdef USE_API_USER_DEFINED_ACTIONS -#include "user_services.h" -#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -22,11 +19,15 @@ #include "esphome/components/camera/camera.h" #endif -#include #include namespace esphome::api { +#ifdef USE_API_USER_DEFINED_ACTIONS +// Forward declaration - full definition in user_services.h +class UserServiceDescriptor; +#endif + #ifdef USE_API_NOISE struct SavedNoisePsk { psk_t psk; @@ -154,6 +155,19 @@ class APIServer : public Component, // Only compile push_back method when custom_services: true (external components) void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } #endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Action call context management - supports concurrent calls from multiple clients + // Returns server-generated action_call_id to avoid collisions when clients use same call_id + uint32_t register_active_action_call(uint32_t client_call_id, APIConnection *conn); + void unregister_active_action_call(uint32_t action_call_id); + void unregister_active_action_calls_for_connection(APIConnection *conn); + // Send response for a specific action call (uses action_call_id, sends client_call_id in response) + void send_action_response(uint32_t action_call_id, bool success, const std::string &error_message); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + void send_action_response(uint32_t action_call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len); +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_HOMEASSISTANT_TIME void request_time(); @@ -230,6 +244,17 @@ class APIServer : public Component, #endif #ifdef USE_API_USER_DEFINED_ACTIONS std::vector user_services_; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Active action calls - supports concurrent calls from multiple clients + // Uses server-generated action_call_id to avoid collisions when multiple clients use same call_id + struct ActiveActionCall { + uint32_t action_call_id; // Server-generated unique ID (passed to actions) + uint32_t client_call_id; // Client's original call_id (used in response) + APIConnection *connection; + }; + std::vector active_action_calls_; + uint32_t next_action_call_id_{1}; // Counter for generating unique action_call_ids +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES struct PendingActionResponse { diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 1006d07533..5e9165326d 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -16,7 +16,10 @@ template class CustomAPIDeviceService : public UserS : UserServiceDynamic(name, arg_names), obj_(obj), callback_(callback) {} protected: - void execute(Ts... x) override { (this->obj_->*this->callback_)(x...); } // NOLINT + // CustomAPIDevice services don't support action responses - ignore call_id and return_response + void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { + (this->obj_->*this->callback_)(x...); // NOLINT + } T *obj_; void (T::*callback_)(Ts...); diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index e18fc17801..b4d1454153 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -5,6 +5,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_API_USER_DEFINED_ACTIONS +#include "user_services.h" +#endif namespace esphome::api { diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index d9c13c520b..001add626f 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -1,20 +1,31 @@ #pragma once +#include #include #include -#include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "api_pb2.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#include "esphome/components/json/json_util.h" +#endif #ifdef USE_API_USER_DEFINED_ACTIONS namespace esphome::api { +// Forward declaration - full definition in api_server.h +class APIServer; + class UserServiceDescriptor { public: virtual ListEntitiesServicesResponse encode_list_service_response() = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Overload that accepts server-generated action_call_id (avoids client call_id collisions) + virtual bool execute_service(const ExecuteServiceRequest &req, uint32_t action_call_id) = 0; +#endif bool is_internal() { return false; } }; @@ -27,8 +38,9 @@ template enums::ServiceArgType to_service_arg_type(); // Stores only pointers to string literals in flash - no heap allocation template class UserServiceBase : public UserServiceDescriptor { public: - UserServiceBase(const char *name, const std::array &arg_names) - : name_(name), arg_names_(arg_names) { + UserServiceBase(const char *name, const std::array &arg_names, + enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE) + : name_(name), arg_names_(arg_names), supports_response_(supports_response) { this->key_ = fnv1_hash(name); } @@ -36,6 +48,7 @@ template class UserServiceBase : public UserServiceDescriptor { ListEntitiesServicesResponse msg; msg.set_name(StringRef(this->name_)); msg.key = this->key_; + msg.supports_response = this->supports_response_; std::array arg_types = {to_service_arg_type()...}; msg.args.init(sizeof...(Ts)); for (size_t i = 0; i < sizeof...(Ts); i++) { @@ -51,21 +64,37 @@ template class UserServiceBase : public UserServiceDescriptor { return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, std::make_index_sequence{}); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + this->execute_(req.args, req.call_id, req.return_response, std::make_index_sequence{}); +#else + this->execute_(req.args, 0, false, std::make_index_sequence{}); +#endif return true; } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + bool execute_service(const ExecuteServiceRequest &req, uint32_t action_call_id) override { + if (req.key != this->key_) + return false; + if (req.args.size() != sizeof...(Ts)) + return false; + this->execute_(req.args, action_call_id, req.return_response, std::make_index_sequence{}); + return true; + } +#endif + protected: - virtual void execute(Ts... x) = 0; + virtual void execute(uint32_t call_id, bool return_response, Ts... x) = 0; template - void execute_(const ArgsContainer &args, std::index_sequence type) { - this->execute((get_execute_arg_value(args[S]))...); + void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence /*type*/) { + this->execute(call_id, return_response, (get_execute_arg_value(args[S]))...); } // Pointers to string literals in flash - no heap allocation const char *name_; std::array arg_names_; uint32_t key_{0}; + enums::SupportsResponseType supports_response_{enums::SUPPORTS_RESPONSE_NONE}; }; // Separate class for custom_api_device services (rare case) @@ -81,6 +110,7 @@ template class UserServiceDynamic : public UserServiceDescriptor ListEntitiesServicesResponse msg; msg.set_name(StringRef(this->name_)); msg.key = this->key_; + msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet std::array arg_types = {to_service_arg_type()...}; msg.args.init(sizeof...(Ts)); for (size_t i = 0; i < sizeof...(Ts); i++) { @@ -96,15 +126,31 @@ template class UserServiceDynamic : public UserServiceDescriptor return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, std::make_index_sequence{}); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + this->execute_(req.args, req.call_id, req.return_response, std::make_index_sequence{}); +#else + this->execute_(req.args, 0, false, std::make_index_sequence{}); +#endif return true; } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Dynamic services don't support responses yet, but need to implement the interface + bool execute_service(const ExecuteServiceRequest &req, uint32_t action_call_id) override { + if (req.key != this->key_) + return false; + if (req.args.size() != sizeof...(Ts)) + return false; + this->execute_(req.args, action_call_id, req.return_response, std::make_index_sequence{}); + return true; + } +#endif + protected: - virtual void execute(Ts... x) = 0; + virtual void execute(uint32_t call_id, bool return_response, Ts... x) = 0; template - void execute_(const ArgsContainer &args, std::index_sequence type) { - this->execute((get_execute_arg_value(args[S]))...); + void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence /*type*/) { + this->execute(call_id, return_response, (get_execute_arg_value(args[S]))...); } // Heap-allocated strings for runtime-generated names @@ -113,15 +159,149 @@ template class UserServiceDynamic : public UserServiceDescriptor uint32_t key_{0}; }; -template class UserServiceTrigger : public UserServiceBase, public Trigger { +// Primary template declaration +template class UserServiceTrigger; + +// Specialization for NONE - no extra trigger arguments +template +class UserServiceTrigger : public UserServiceBase, public Trigger { public: - // Constructor for static names (YAML-defined services - used by code generator) UserServiceTrigger(const char *name, const std::array &arg_names) - : UserServiceBase(name, arg_names) {} + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} protected: - void execute(Ts... x) override { this->trigger(x...); } // NOLINT + void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { this->trigger(x...); } +}; + +// Specialization for OPTIONAL - call_id and return_response trigger arguments +template +class UserServiceTrigger : public UserServiceBase, + public Trigger { + public: + UserServiceTrigger(const char *name, const std::array &arg_names) + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} + + protected: + void execute(uint32_t call_id, bool return_response, Ts... x) override { + this->trigger(call_id, return_response, x...); + } +}; + +// Specialization for ONLY - just call_id trigger argument +template +class UserServiceTrigger : public UserServiceBase, + public Trigger { + public: + UserServiceTrigger(const char *name, const std::array &arg_names) + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} + + protected: + void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } +}; + +// Specialization for STATUS - just call_id trigger argument (reports success/error without data) +template +class UserServiceTrigger : public UserServiceBase, + public Trigger { + public: + UserServiceTrigger(const char *name, const std::array &arg_names) + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} + + protected: + void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } }; } // namespace esphome::api #endif // USE_API_USER_DEFINED_ACTIONS + +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +// Include full definition of APIServer for template implementation +// Must be outside namespace to avoid including STL headers inside namespace +#include "api_server.h" + +namespace esphome::api { + +template class APIRespondAction : public Action { + public: + explicit APIRespondAction(APIServer *parent) : parent_(parent) {} + + template void set_success(V success) { this->success_ = success; } + template void set_error_message(V error) { this->error_message_ = error; } + void set_is_optional_mode(bool is_optional) { this->is_optional_mode_ = is_optional; } + +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + void set_data(std::function func) { + this->json_builder_ = std::move(func); + this->has_data_ = true; + } +#endif + + void play(const Ts &...x) override { + // Extract call_id from first argument - it's always first for optional/only/status modes + auto args = std::make_tuple(x...); + uint32_t call_id = std::get<0>(args); + + bool success = this->success_.value(x...); + std::string error_message = this->error_message_.value(x...); + +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + if (this->has_data_) { + // For optional mode, check return_response (second arg) to decide if client wants data + // Use nested if constexpr to avoid compile error when tuple doesn't have enough elements + // (std::tuple_element_t is evaluated before the && short-circuit, so we must nest) + if constexpr (sizeof...(Ts) >= 2) { + if constexpr (std::is_same_v>, bool>) { + if (this->is_optional_mode_) { + bool return_response = std::get<1>(args); + if (!return_response) { + // Client doesn't want response data, just send success/error + this->parent_->send_action_response(call_id, success, error_message); + return; + } + } + } + } + // Build and send JSON response + json::JsonBuilder builder; + this->json_builder_(x..., builder.root()); + std::string json_str = builder.serialize(); + this->parent_->send_action_response(call_id, success, error_message, + reinterpret_cast(json_str.data()), json_str.size()); + return; + } +#endif + this->parent_->send_action_response(call_id, success, error_message); + } + + protected: + APIServer *parent_; + TemplatableValue success_{true}; + TemplatableValue error_message_{""}; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + std::function json_builder_; + bool has_data_{false}; +#endif + bool is_optional_mode_{false}; +}; + +// Action to unregister a service call after execution completes +// Automatically appended to the end of action lists for non-none response modes +template class APIUnregisterServiceCallAction : public Action { + public: + explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} + + void play(const Ts &...x) override { + // Extract call_id from first argument - same convention as APIRespondAction + auto args = std::make_tuple(x...); + uint32_t call_id = std::get<0>(args); + if (call_id != 0) { + this->parent_->unregister_active_action_call(call_id); + } + } + + protected: + APIServer *parent_; +}; + +} // namespace esphome::api +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/core/defines.h b/esphome/core/defines.h index eea92f77ac..021240cc40 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -129,6 +129,8 @@ #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES +#define USE_API_USER_DEFINED_ACTION_RESPONSES +#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 #define USE_MD5 #define USE_SHA256 diff --git a/requirements.txt b/requirements.txt index 5d824a6859..0bad48716e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.10.0 +aioesphomeapi==43.0.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 0416cebf9b..c766b61b13 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -181,6 +181,99 @@ api: else: - logger.log: "Skipped loops" - logger.log: "After combined test" + # ========================================================================== + # supports_response: status (auto-detected - api.respond without data) + # Has call_id only - reports success/error without data payload + # ========================================================================== + - action: test_respond_status + then: + - api.respond: + success: true + - logger.log: + format: "Status response sent (call_id=%d)" + args: [call_id] + + - action: test_respond_status_error + variables: + error_msg: string + then: + - api.respond: + success: false + error_message: !lambda 'return error_msg;' + + # ========================================================================== + # supports_response: optional (auto-detected - api.respond with data) + # Has call_id and return_response - client decides if it wants response + # ========================================================================== + - action: test_respond_optional + variables: + sensor_name: string + value: float + then: + - logger.log: + format: "Optional response (call_id=%d, return_response=%d)" + args: [call_id, return_response] + - api.respond: + data: !lambda |- + root["sensor"] = sensor_name; + root["value"] = value; + root["unit"] = "°C"; + + - action: test_respond_optional_conditional + variables: + do_succeed: bool + then: + - if: + condition: + lambda: 'return do_succeed;' + then: + - api.respond: + success: true + data: !lambda |- + root["status"] = "ok"; + else: + - api.respond: + success: false + error_message: "Operation failed" + + # ========================================================================== + # supports_response: only (explicit - always expects data response) + # Has call_id only - response is always expected with data + # ========================================================================== + - action: test_respond_only + supports_response: only + variables: + input: string + then: + - logger.log: + format: "Only response (call_id=%d)" + args: [call_id] + - api.respond: + data: !lambda |- + root["input"] = input; + root["processed"] = true; + + - action: test_respond_only_nested + supports_response: only + then: + - api.respond: + data: !lambda |- + root["config"]["wifi"] = "connected"; + root["config"]["api"] = true; + root["items"][0] = "item1"; + root["items"][1] = "item2"; + + # ========================================================================== + # supports_response: none (no api.respond action) + # No call_id or return_response - just user variables + # ========================================================================== + - action: test_no_response + variables: + message: string + then: + - logger.log: + format: "No response action: %s" + args: [message.c_str()] event: - platform: template diff --git a/tests/integration/README.md b/tests/integration/README.md index f99139db00..4de08777b0 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -252,7 +252,7 @@ my_service = next((s for s in services if s.name == "my_service"), None) assert my_service is not None # Execute with parameters -client.execute_service(my_service, {"param1": "value1", "param2": 42}) +await client.execute_service(my_service, {"param1": "value1", "param2": 42}) ``` ##### Multiple Entity Tracking diff --git a/tests/integration/fixtures/api_action_responses.yaml b/tests/integration/fixtures/api_action_responses.yaml new file mode 100644 index 0000000000..755623b7bb --- /dev/null +++ b/tests/integration/fixtures/api_action_responses.yaml @@ -0,0 +1,93 @@ +esphome: + name: api-action-responses-test + +host: + +logger: + level: DEBUG + +api: + actions: + # ========================================================================== + # supports_response: none (default - no api.respond action) + # No call_id or return_response - just user variables + # ========================================================================== + - action: action_no_response + variables: + message: string + then: + - logger.log: + format: "ACTION_NO_RESPONSE called with: %s" + args: [message.c_str()] + + # ========================================================================== + # supports_response: status (auto-detected - api.respond without data) + # Has call_id only - reports success/error without data payload + # ========================================================================== + - action: action_status_response + variables: + should_succeed: bool + then: + - if: + condition: + lambda: 'return should_succeed;' + then: + - api.respond: + success: true + - logger.log: + format: "ACTION_STATUS_RESPONSE success (call_id=%d)" + args: [call_id] + else: + - api.respond: + success: false + error_message: "Intentional failure for testing" + - logger.log: + format: "ACTION_STATUS_RESPONSE error (call_id=%d)" + args: [call_id] + + # ========================================================================== + # supports_response: optional (auto-detected - api.respond with data) + # Has call_id and return_response - client decides if it wants response + # ========================================================================== + - action: action_optional_response + variables: + value: int + then: + - logger.log: + format: "ACTION_OPTIONAL_RESPONSE (call_id=%d, return_response=%d, value=%d)" + args: [call_id, return_response, value] + - api.respond: + data: !lambda |- + root["input"] = value; + root["doubled"] = value * 2; + + # ========================================================================== + # supports_response: only (explicit - always expects data response) + # Has call_id only - response is always expected with data + # ========================================================================== + - action: action_only_response + supports_response: only + variables: + name: string + then: + - logger.log: + format: "ACTION_ONLY_RESPONSE (call_id=%d, name=%s)" + args: [call_id, name.c_str()] + - api.respond: + data: !lambda |- + root["greeting"] = "Hello, " + name + "!"; + root["length"] = name.length(); + + # Test action with nested JSON response + - action: action_nested_json + supports_response: only + then: + - logger.log: + format: "ACTION_NESTED_JSON (call_id=%d)" + args: [call_id] + - api.respond: + data: !lambda |- + root["config"]["wifi"]["connected"] = true; + root["config"]["api"]["port"] = 6053; + root["items"][0] = "first"; + root["items"][1] = "second"; diff --git a/tests/integration/fixtures/api_action_timeout.yaml b/tests/integration/fixtures/api_action_timeout.yaml new file mode 100644 index 0000000000..405d9d0e2b --- /dev/null +++ b/tests/integration/fixtures/api_action_timeout.yaml @@ -0,0 +1,45 @@ +esphome: + name: api-action-timeout-test + # Use a short timeout for testing (500ms instead of 30s) + platformio_options: + build_flags: + - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" + +host: + +logger: + level: DEBUG + +api: + actions: + # Action that responds immediately - should work fine + - action: action_immediate + supports_response: only + then: + - logger.log: "ACTION_IMMEDIATE responding" + - api.respond: + data: !lambda |- + root["status"] = "immediate"; + + # Action that delays 200ms before responding - should work (within 500ms timeout) + - action: action_short_delay + supports_response: only + then: + - logger.log: "ACTION_SHORT_DELAY starting" + - delay: 200ms + - logger.log: "ACTION_SHORT_DELAY responding" + - api.respond: + data: !lambda |- + root["status"] = "short_delay"; + + # Action that delays 1s before responding - should fail (exceeds 500ms timeout) + # The api.respond will log a warning because the action call was already cleaned up + - action: action_long_delay + supports_response: only + then: + - logger.log: "ACTION_LONG_DELAY starting" + - delay: 1s + - logger.log: "ACTION_LONG_DELAY responding (after timeout)" + - api.respond: + data: !lambda |- + root["status"] = "long_delay"; diff --git a/tests/integration/test_api_action_responses.py b/tests/integration/test_api_action_responses.py new file mode 100644 index 0000000000..d441a231aa --- /dev/null +++ b/tests/integration/test_api_action_responses.py @@ -0,0 +1,258 @@ +"""Integration test for API action responses feature. + +Tests the supports_response modes: none, status, optional, only. +""" + +from __future__ import annotations + +import asyncio +import json +import re + +from aioesphomeapi import SupportsResponseType, UserService, UserServiceArgType +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_action_responses( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API action response modes work correctly.""" + loop = asyncio.get_running_loop() + + # Track log messages for each action type + no_response_future = loop.create_future() + status_success_future = loop.create_future() + status_error_future = loop.create_future() + optional_response_future = loop.create_future() + only_response_future = loop.create_future() + nested_json_future = loop.create_future() + + # Patterns to match in logs + no_response_pattern = re.compile(r"ACTION_NO_RESPONSE called with: test_message") + status_success_pattern = re.compile( + r"ACTION_STATUS_RESPONSE success \(call_id=\d+\)" + ) + status_error_pattern = re.compile(r"ACTION_STATUS_RESPONSE error \(call_id=\d+\)") + optional_response_pattern = re.compile( + r"ACTION_OPTIONAL_RESPONSE \(call_id=\d+, return_response=\d+, value=42\)" + ) + only_response_pattern = re.compile( + r"ACTION_ONLY_RESPONSE \(call_id=\d+, name=World\)" + ) + nested_json_pattern = re.compile(r"ACTION_NESTED_JSON \(call_id=\d+\)") + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not no_response_future.done() and no_response_pattern.search(line): + no_response_future.set_result(True) + elif not status_success_future.done() and status_success_pattern.search(line): + status_success_future.set_result(True) + elif not status_error_future.done() and status_error_pattern.search(line): + status_error_future.set_result(True) + elif not optional_response_future.done() and optional_response_pattern.search( + line + ): + optional_response_future.set_result(True) + elif not only_response_future.done() and only_response_pattern.search(line): + only_response_future.set_result(True) + elif not nested_json_future.done() and nested_json_pattern.search(line): + nested_json_future.set_result(True) + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "api-action-responses-test" + + # List services + _, services = await client.list_entities_services() + + # Should have 5 services + assert len(services) == 5, f"Expected 5 services, found {len(services)}" + + # Find our services + action_no_response: UserService | None = None + action_status_response: UserService | None = None + action_optional_response: UserService | None = None + action_only_response: UserService | None = None + action_nested_json: UserService | None = None + + for service in services: + if service.name == "action_no_response": + action_no_response = service + elif service.name == "action_status_response": + action_status_response = service + elif service.name == "action_optional_response": + action_optional_response = service + elif service.name == "action_only_response": + action_only_response = service + elif service.name == "action_nested_json": + action_nested_json = service + + assert action_no_response is not None, "action_no_response not found" + assert action_status_response is not None, "action_status_response not found" + assert action_optional_response is not None, ( + "action_optional_response not found" + ) + assert action_only_response is not None, "action_only_response not found" + assert action_nested_json is not None, "action_nested_json not found" + + # Verify supports_response modes + assert action_no_response.supports_response is None or ( + action_no_response.supports_response == SupportsResponseType.NONE + ), ( + f"action_no_response should have supports_response=NONE, got {action_no_response.supports_response}" + ) + + assert ( + action_status_response.supports_response == SupportsResponseType.STATUS + ), ( + f"action_status_response should have supports_response=STATUS, " + f"got {action_status_response.supports_response}" + ) + + assert ( + action_optional_response.supports_response == SupportsResponseType.OPTIONAL + ), ( + f"action_optional_response should have supports_response=OPTIONAL, " + f"got {action_optional_response.supports_response}" + ) + + assert action_only_response.supports_response == SupportsResponseType.ONLY, ( + f"action_only_response should have supports_response=ONLY, " + f"got {action_only_response.supports_response}" + ) + + assert action_nested_json.supports_response == SupportsResponseType.ONLY, ( + f"action_nested_json should have supports_response=ONLY, " + f"got {action_nested_json.supports_response}" + ) + + # Verify argument types + # action_no_response: string message + assert len(action_no_response.args) == 1 + assert action_no_response.args[0].name == "message" + assert action_no_response.args[0].type == UserServiceArgType.STRING + + # action_status_response: bool should_succeed + assert len(action_status_response.args) == 1 + assert action_status_response.args[0].name == "should_succeed" + assert action_status_response.args[0].type == UserServiceArgType.BOOL + + # action_optional_response: int value + assert len(action_optional_response.args) == 1 + assert action_optional_response.args[0].name == "value" + assert action_optional_response.args[0].type == UserServiceArgType.INT + + # action_only_response: string name + assert len(action_only_response.args) == 1 + assert action_only_response.args[0].name == "name" + assert action_only_response.args[0].type == UserServiceArgType.STRING + + # action_nested_json: no args + assert len(action_nested_json.args) == 0 + + # Test action_no_response (supports_response: none) + # No response expected for this action + response = await client.execute_service( + action_no_response, {"message": "test_message"} + ) + assert response is None, "action_no_response should not return a response" + await asyncio.wait_for(no_response_future, timeout=5.0) + + # Test action_status_response with success (supports_response: status) + response = await client.execute_service( + action_status_response, + {"should_succeed": True}, + return_response=True, + ) + await asyncio.wait_for(status_success_future, timeout=5.0) + assert response is not None, "Expected response for status action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + assert response.error_message == "", ( + f"Expected empty error_message, got '{response.error_message}'" + ) + + # Test action_status_response with error + response = await client.execute_service( + action_status_response, + {"should_succeed": False}, + return_response=True, + ) + await asyncio.wait_for(status_error_future, timeout=5.0) + assert response is not None, "Expected response for status action" + assert response.success is False, ( + f"Expected success=False, got {response.success}" + ) + assert "Intentional failure" in response.error_message, ( + f"Expected error message containing 'Intentional failure', " + f"got '{response.error_message}'" + ) + + # Test action_optional_response (supports_response: optional) + response = await client.execute_service( + action_optional_response, + {"value": 42}, + return_response=True, + ) + await asyncio.wait_for(optional_response_future, timeout=5.0) + assert response is not None, "Expected response for optional action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + # Parse response data as JSON + response_json = json.loads(response.response_data.decode("utf-8")) + assert response_json["input"] == 42, ( + f"Expected input=42, got {response_json.get('input')}" + ) + assert response_json["doubled"] == 84, ( + f"Expected doubled=84, got {response_json.get('doubled')}" + ) + + # Test action_only_response (supports_response: only) + response = await client.execute_service( + action_only_response, + {"name": "World"}, + return_response=True, + ) + await asyncio.wait_for(only_response_future, timeout=5.0) + assert response is not None, "Expected response for only action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + response_json = json.loads(response.response_data.decode("utf-8")) + assert response_json["greeting"] == "Hello, World!", ( + f"Expected greeting='Hello, World!', got {response_json.get('greeting')}" + ) + assert response_json["length"] == 5, ( + f"Expected length=5, got {response_json.get('length')}" + ) + + # Test action_nested_json + response = await client.execute_service( + action_nested_json, + {}, + return_response=True, + ) + await asyncio.wait_for(nested_json_future, timeout=5.0) + assert response is not None, "Expected response for nested json action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + response_json = json.loads(response.response_data.decode("utf-8")) + # Verify nested structure + assert response_json["config"]["wifi"]["connected"] is True + assert response_json["config"]["api"]["port"] == 6053 + assert response_json["items"][0] == "first" + assert response_json["items"][1] == "second" diff --git a/tests/integration/test_api_action_timeout.py b/tests/integration/test_api_action_timeout.py new file mode 100644 index 0000000000..cec0967131 --- /dev/null +++ b/tests/integration/test_api_action_timeout.py @@ -0,0 +1,172 @@ +"""Integration test for API action call timeout functionality. + +Tests that action calls are automatically cleaned up after timeout, +and that late responses are handled gracefully. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import re + +from aioesphomeapi import UserService +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_action_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API action call timeout behavior. + + This test uses a 500ms timeout (set via USE_API_ACTION_CALL_TIMEOUT_MS define) + to verify: + 1. Actions that respond within the timeout work correctly + 2. Actions that exceed the timeout have their calls cleaned up + 3. Late responses log a warning but don't crash + """ + loop = asyncio.get_running_loop() + + # Track log messages + immediate_future = loop.create_future() + short_delay_responding_future = loop.create_future() + long_delay_starting_future = loop.create_future() + long_delay_responding_future = loop.create_future() + timeout_warning_future = loop.create_future() + + # Patterns to match in logs + immediate_pattern = re.compile(r"ACTION_IMMEDIATE responding") + short_delay_responding_pattern = re.compile(r"ACTION_SHORT_DELAY responding") + long_delay_starting_pattern = re.compile(r"ACTION_LONG_DELAY starting") + long_delay_responding_pattern = re.compile( + r"ACTION_LONG_DELAY responding \(after timeout\)" + ) + # This warning is logged when api.respond is called after the action call timed out + timeout_warning_pattern = re.compile( + r"Cannot send response: no active call found for action_call_id" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not immediate_future.done() and immediate_pattern.search(line): + immediate_future.set_result(True) + elif ( + not short_delay_responding_future.done() + and short_delay_responding_pattern.search(line) + ): + short_delay_responding_future.set_result(True) + elif ( + not long_delay_starting_future.done() + and long_delay_starting_pattern.search(line) + ): + long_delay_starting_future.set_result(True) + elif ( + not long_delay_responding_future.done() + and long_delay_responding_pattern.search(line) + ): + long_delay_responding_future.set_result(True) + elif not timeout_warning_future.done() and timeout_warning_pattern.search(line): + timeout_warning_future.set_result(True) + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "api-action-timeout-test" + + # List services + _, services = await client.list_entities_services() + + # Should have 3 services + assert len(services) == 3, f"Expected 3 services, found {len(services)}" + + # Find our services + action_immediate: UserService | None = None + action_short_delay: UserService | None = None + action_long_delay: UserService | None = None + + for service in services: + if service.name == "action_immediate": + action_immediate = service + elif service.name == "action_short_delay": + action_short_delay = service + elif service.name == "action_long_delay": + action_long_delay = service + + assert action_immediate is not None, "action_immediate not found" + assert action_short_delay is not None, "action_short_delay not found" + assert action_long_delay is not None, "action_long_delay not found" + + # Test 1: Immediate response should work + response = await client.execute_service( + action_immediate, + {}, + return_response=True, + ) + await asyncio.wait_for(immediate_future, timeout=1.0) + assert response is not None, "Expected response for immediate action" + assert response.success is True + + # Test 2: Short delay (200ms) should work within the 500ms timeout + response = await client.execute_service( + action_short_delay, + {}, + return_response=True, + ) + await asyncio.wait_for(short_delay_responding_future, timeout=1.0) + assert response is not None, "Expected response for short delay action" + assert response.success is True + + # Test 3: Long delay (1s) should exceed the 500ms timeout + # The server-side timeout will clean up the action call after 500ms + # The client will timeout waiting for the response + # When the action finally tries to respond after 1s, it will log a warning + + # Start the long delay action (don't await it fully - it will timeout) + long_delay_task = asyncio.create_task( + client.execute_service( + action_long_delay, + {}, + return_response=True, + timeout=2.0, # Give client enough time to see the late response attempt + ) + ) + + # Wait for the action to start + await asyncio.wait_for(long_delay_starting_future, timeout=1.0) + + # Wait for the action to try to respond (after 1s delay) + await asyncio.wait_for(long_delay_responding_future, timeout=2.0) + + # Wait for the warning log about no active call + await asyncio.wait_for(timeout_warning_future, timeout=1.0) + + # The client task should complete (either with None response or timeout) + # Client timing out is acceptable - the server-side timeout already cleaned up the call + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(long_delay_task, timeout=1.0) + + # Verify the system is still functional after the timeout + # Call the immediate action again to prove cleanup worked + immediate_future_2 = loop.create_future() + + def check_output_2(line: str) -> None: + if not immediate_future_2.done() and immediate_pattern.search(line): + immediate_future_2.set_result(True) + + response = await client.execute_service( + action_immediate, + {}, + return_response=True, + ) + assert response is not None, "System should still work after timeout" + assert response.success is True diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py index cfa32c431d..349b572859 100644 --- a/tests/integration/test_api_conditional_memory.py +++ b/tests/integration/test_api_conditional_memory.py @@ -88,13 +88,13 @@ async def test_api_conditional_memory( assert arg_types["arg_float"] == UserServiceArgType.FLOAT # Call simple service - client.execute_service(simple_service, {}) + await client.execute_service(simple_service, {}) # Wait for service log await asyncio.wait_for(service_simple_future, timeout=5.0) # Call service with arguments - client.execute_service( + await client.execute_service( service_with_args, { "arg_string": "test_string", diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index 967c504112..cd33b5a1fc 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -114,7 +114,7 @@ async def test_api_custom_services( assert custom_arrays_service is not None, "custom_service_with_arrays not found" # Test YAML service - client.execute_service(yaml_service, {}) + await client.execute_service(yaml_service, {}) await asyncio.wait_for(yaml_service_future, timeout=5.0) # Verify YAML service with args arguments @@ -124,7 +124,7 @@ async def test_api_custom_services( assert yaml_args_types["my_string"] == UserServiceArgType.STRING # Test YAML service with arguments - client.execute_service( + await client.execute_service( yaml_args_service, { "my_int": 123, @@ -144,7 +144,7 @@ async def test_api_custom_services( assert yaml_many_args_types["arg4"] == UserServiceArgType.STRING # Test YAML service with many arguments - client.execute_service( + await client.execute_service( yaml_many_args_service, { "arg1": 42, @@ -156,7 +156,7 @@ async def test_api_custom_services( await asyncio.wait_for(yaml_many_args_future, timeout=5.0) # Test simple CustomAPIDevice service - client.execute_service(custom_service, {}) + await client.execute_service(custom_service, {}) await asyncio.wait_for(custom_service_future, timeout=5.0) # Verify custom_args_service arguments @@ -168,7 +168,7 @@ async def test_api_custom_services( assert arg_types["arg_float"] == UserServiceArgType.FLOAT # Test CustomAPIDevice service with arguments - client.execute_service( + await client.execute_service( custom_args_service, { "arg_string": "test_string", @@ -188,7 +188,7 @@ async def test_api_custom_services( assert array_arg_types["string_array"] == UserServiceArgType.STRING_ARRAY # Test CustomAPIDevice service with arrays - client.execute_service( + await client.execute_service( custom_arrays_service, { "bool_array": [True, False], diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index f69838396d..98901fb3f9 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -163,7 +163,7 @@ async def test_api_homeassistant( assert trigger_service is not None, "trigger_all_tests service not found" # Execute all tests - client.execute_service(trigger_service, {}) + await client.execute_service(trigger_service, {}) # Wait for all tests to complete with appropriate timeouts try: diff --git a/tests/integration/test_api_string_lambda.py b/tests/integration/test_api_string_lambda.py index f4ef77bad8..ece8b192a2 100644 --- a/tests/integration/test_api_string_lambda.py +++ b/tests/integration/test_api_string_lambda.py @@ -75,10 +75,12 @@ async def test_api_string_lambda( assert char_ptr_service is not None, "test_char_ptr_lambda service not found" # Execute all four services to test different lambda return types - client.execute_service(string_service, {"input_string": "STRING_FROM_LAMBDA"}) - client.execute_service(int_service, {"input_number": 42}) - client.execute_service(float_service, {"input_float": 3.14}) - client.execute_service( + await client.execute_service( + string_service, {"input_string": "STRING_FROM_LAMBDA"} + ) + await client.execute_service(int_service, {"input_number": 42}) + await client.execute_service(float_service, {"input_float": 3.14}) + await client.execute_service( char_ptr_service, {"input_number": 123, "input_string": "test_string"} ) diff --git a/tests/integration/test_automation_wait_actions.py b/tests/integration/test_automation_wait_actions.py index adcb8ba487..f4db247231 100644 --- a/tests/integration/test_automation_wait_actions.py +++ b/tests/integration/test_automation_wait_actions.py @@ -71,7 +71,7 @@ async def test_automation_wait_actions( # Test 1: wait_until in automation - trigger 5 times rapidly test_service = next((s for s in services if s.name == "test_wait_until"), None) assert test_service is not None, "test_wait_until service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test1_complete, timeout=3.0) # Verify Test 1: All 5 triggers should complete @@ -82,7 +82,7 @@ async def test_automation_wait_actions( # Test 2: script.wait in automation - trigger 5 times rapidly test_service = next((s for s in services if s.name == "test_script_wait"), None) assert test_service is not None, "test_script_wait service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test2_complete, timeout=3.0) # Verify Test 2: All 5 triggers should complete @@ -95,7 +95,7 @@ async def test_automation_wait_actions( (s for s in services if s.name == "test_wait_timeout"), None ) assert test_service is not None, "test_wait_timeout service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test3_complete, timeout=3.0) # Verify Test 3: All 5 triggers should timeout and complete diff --git a/tests/integration/test_automations.py b/tests/integration/test_automations.py index 83268c1eea..ffd7f5c587 100644 --- a/tests/integration/test_automations.py +++ b/tests/integration/test_automations.py @@ -67,7 +67,7 @@ async def test_delay_action_cancellation( assert test_service is not None, "start_delay_then_restart service not found" # Execute the test sequence - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for the second script to start await asyncio.wait_for(second_script_started, timeout=5.0) @@ -138,7 +138,7 @@ async def test_parallel_script_delays( 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, {}) + await 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) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py index 1069ee7581..e6020c711a 100644 --- a/tests/integration/test_continuation_actions.py +++ b/tests/integration/test_continuation_actions.py @@ -142,7 +142,7 @@ async def test_continuation_actions( # Test 1: IfAction with then branch test_service = next((s for s in services if s.name == "test_if_action"), None) assert test_service is not None, "test_if_action service not found" - client.execute_service(test_service, {"condition": True, "value": 42}) + await client.execute_service(test_service, {"condition": True, "value": 42}) await asyncio.wait_for(test1_complete, timeout=2.0) assert test_results["if_then"], "IfAction then branch not executed" assert test_results["if_complete"], "IfAction did not complete" @@ -150,7 +150,7 @@ async def test_continuation_actions( # Test 1b: IfAction with else branch test1_complete = loop.create_future() test_results["if_complete"] = False - client.execute_service(test_service, {"condition": False, "value": 99}) + await client.execute_service(test_service, {"condition": False, "value": 99}) await asyncio.wait_for(test1_complete, timeout=2.0) assert test_results["if_else"], "IfAction else branch not executed" assert test_results["if_complete"], "IfAction did not complete" @@ -160,14 +160,14 @@ async def test_continuation_actions( assert test_service is not None, "test_nested_if service not found" # Both true - client.execute_service(test_service, {"outer": True, "inner": True}) + await client.execute_service(test_service, {"outer": True, "inner": True}) await asyncio.wait_for(test2_complete, timeout=2.0) assert test_results["nested_both_true"], "Nested both true not executed" # Outer true, inner false test2_complete = loop.create_future() test_results["nested_complete"] = False - client.execute_service(test_service, {"outer": True, "inner": False}) + await client.execute_service(test_service, {"outer": True, "inner": False}) await asyncio.wait_for(test2_complete, timeout=2.0) assert test_results["nested_outer_true_inner_false"], ( "Nested outer true inner false not executed" @@ -176,7 +176,7 @@ async def test_continuation_actions( # Outer false test2_complete = loop.create_future() test_results["nested_complete"] = False - client.execute_service(test_service, {"outer": False, "inner": True}) + await client.execute_service(test_service, {"outer": False, "inner": True}) await asyncio.wait_for(test2_complete, timeout=2.0) assert test_results["nested_outer_false"], "Nested outer false not executed" @@ -185,7 +185,7 @@ async def test_continuation_actions( (s for s in services if s.name == "test_while_action"), None ) assert test_service is not None, "test_while_action service not found" - client.execute_service(test_service, {"max_count": 3}) + await client.execute_service(test_service, {"max_count": 3}) await asyncio.wait_for(test3_complete, timeout=2.0) assert test_results["while_iterations"] == 3, ( f"WhileAction expected 3 iterations, got {test_results['while_iterations']}" @@ -197,7 +197,7 @@ async def test_continuation_actions( (s for s in services if s.name == "test_repeat_action"), None ) assert test_service is not None, "test_repeat_action service not found" - client.execute_service(test_service, {"count": 5}) + await client.execute_service(test_service, {"count": 5}) await asyncio.wait_for(test4_complete, timeout=2.0) assert test_results["repeat_iterations"] == 5, ( f"RepeatAction expected 5 iterations, got {test_results['repeat_iterations']}" @@ -207,7 +207,7 @@ async def test_continuation_actions( # Test 5: Combined (if + repeat + while) test_service = next((s for s in services if s.name == "test_combined"), None) assert test_service is not None, "test_combined service not found" - client.execute_service(test_service, {"do_loop": True, "loop_count": 2}) + await client.execute_service(test_service, {"do_loop": True, "loop_count": 2}) await asyncio.wait_for(test5_complete, timeout=2.0) # Should execute: repeat 2 times, each iteration does while from iteration down to 0 # iteration 0: while 0 times = 0 @@ -221,7 +221,7 @@ async def test_continuation_actions( # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) test_service = next((s for s in services if s.name == "test_rapid_if"), None) assert test_service is not None, "test_rapid_if service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test6_complete, timeout=2.0) # Values 1, 2 should hit else (<=2), values 3, 4, 5 should hit then (>2) assert test_results["rapid_else"] == 2, ( diff --git a/tests/integration/test_scheduler_bulk_cleanup.py b/tests/integration/test_scheduler_bulk_cleanup.py index b52a4a3496..973f59b838 100644 --- a/tests/integration/test_scheduler_bulk_cleanup.py +++ b/tests/integration/test_scheduler_bulk_cleanup.py @@ -98,7 +98,7 @@ async def test_scheduler_bulk_cleanup( ) # Execute the test - client.execute_service(trigger_bulk_cleanup_service, {}) + await client.execute_service(trigger_bulk_cleanup_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_defer_cancel.py b/tests/integration/test_scheduler_defer_cancel.py index 34c46bab82..bf34de9677 100644 --- a/tests/integration/test_scheduler_defer_cancel.py +++ b/tests/integration/test_scheduler_defer_cancel.py @@ -81,7 +81,7 @@ async def test_scheduler_defer_cancel( client.subscribe_states(on_state) # Execute the test - client.execute_service(test_defer_cancel_service, {}) + await client.execute_service(test_defer_cancel_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_defer_cancel_regular.py b/tests/integration/test_scheduler_defer_cancel_regular.py index c93d814fbe..4c37062844 100644 --- a/tests/integration/test_scheduler_defer_cancel_regular.py +++ b/tests/integration/test_scheduler_defer_cancel_regular.py @@ -59,7 +59,7 @@ async def test_scheduler_defer_cancels_regular( assert test_service is not None, "test_defer_cancels_regular service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_defer_fifo_simple.py b/tests/integration/test_scheduler_defer_fifo_simple.py index 3502302368..4c5c2b56de 100644 --- a/tests/integration/test_scheduler_defer_fifo_simple.py +++ b/tests/integration/test_scheduler_defer_fifo_simple.py @@ -84,7 +84,7 @@ async def test_scheduler_defer_fifo_simple( client.subscribe_states(on_state) # Test 1: Test set_timeout(0) - client.execute_service(test_set_timeout_service, {}) + await client.execute_service(test_set_timeout_service, {}) # Wait for first test completion try: @@ -102,7 +102,7 @@ async def test_scheduler_defer_fifo_simple( test_result_future = loop.create_future() # Test 2: Test defer() - client.execute_service(test_defer_service, {}) + await client.execute_service(test_defer_service, {}) # Wait for second test completion try: diff --git a/tests/integration/test_scheduler_defer_stress.py b/tests/integration/test_scheduler_defer_stress.py index 6f4d997307..345ba9434c 100644 --- a/tests/integration/test_scheduler_defer_stress.py +++ b/tests/integration/test_scheduler_defer_stress.py @@ -92,7 +92,7 @@ async def test_scheduler_defer_stress( assert run_stress_test_service is not None, "run_stress_test service not found" # Call the run_stress_test service to start the test - client.execute_service(run_stress_test_service, {}) + await client.execute_service(run_stress_test_service, {}) # Wait for all defers to execute (should be quick) try: diff --git a/tests/integration/test_scheduler_heap_stress.py b/tests/integration/test_scheduler_heap_stress.py index 2d55b8ae89..cceadd0661 100644 --- a/tests/integration/test_scheduler_heap_stress.py +++ b/tests/integration/test_scheduler_heap_stress.py @@ -99,7 +99,7 @@ async def test_scheduler_heap_stress( ) # Call the run_heap_stress_test service to start the test - client.execute_service(run_stress_test_service, {}) + await client.execute_service(run_stress_test_service, {}) # Wait for all callbacks to execute (should be quick, but give more time for scheduling) try: diff --git a/tests/integration/test_scheduler_null_name.py b/tests/integration/test_scheduler_null_name.py index 75864ea2d2..9eeb648d59 100644 --- a/tests/integration/test_scheduler_null_name.py +++ b/tests/integration/test_scheduler_null_name.py @@ -48,7 +48,7 @@ async def test_scheduler_null_name( assert test_null_name_service is not None, "test_null_name service not found" # Execute the test - client.execute_service(test_null_name_service, {}) + await client.execute_service(test_null_name_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index b5f9f12631..021917cc25 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -120,42 +120,42 @@ async def test_scheduler_pool( try: # Phase 1: Component lifecycle - client.execute_service(phase_services[1], {}) + await client.execute_service(phase_services[1], {}) await asyncio.wait_for(phase_futures[1], timeout=1.0) await asyncio.sleep(0.05) # Let timeouts complete # Phase 2: Sensor polling - client.execute_service(phase_services[2], {}) + await client.execute_service(phase_services[2], {}) await asyncio.wait_for(phase_futures[2], timeout=1.0) await asyncio.sleep(0.1) # Let intervals run a bit # Phase 3: Communication patterns - client.execute_service(phase_services[3], {}) + await client.execute_service(phase_services[3], {}) await asyncio.wait_for(phase_futures[3], timeout=1.0) await asyncio.sleep(0.1) # Let heartbeat run # Phase 4: Defer patterns - client.execute_service(phase_services[4], {}) + await client.execute_service(phase_services[4], {}) await asyncio.wait_for(phase_futures[4], timeout=1.0) await asyncio.sleep(0.2) # Let everything settle and recycle # Phase 5: Pool reuse verification - client.execute_service(phase_services[5], {}) + await client.execute_service(phase_services[5], {}) await asyncio.wait_for(phase_futures[5], timeout=1.0) await asyncio.sleep(0.1) # Let Phase 5 timeouts complete and recycle # Phase 6: Full pool reuse verification - client.execute_service(phase_services[6], {}) + await client.execute_service(phase_services[6], {}) await asyncio.wait_for(phase_futures[6], timeout=1.0) await asyncio.sleep(0.1) # Let Phase 6 timeouts complete # Phase 7: Same-named defer optimization - client.execute_service(phase_services[7], {}) + await client.execute_service(phase_services[7], {}) await asyncio.wait_for(phase_futures[7], timeout=1.0) await asyncio.sleep(0.05) # Let the single defer execute # Complete test - client.execute_service(complete_service, {}) + await client.execute_service(complete_service, {}) await asyncio.wait_for(test_complete_future, timeout=0.5) except TimeoutError as e: diff --git a/tests/integration/test_scheduler_rapid_cancellation.py b/tests/integration/test_scheduler_rapid_cancellation.py index 1b7da32aaa..1b67e7fc33 100644 --- a/tests/integration/test_scheduler_rapid_cancellation.py +++ b/tests/integration/test_scheduler_rapid_cancellation.py @@ -108,7 +108,7 @@ async def test_scheduler_rapid_cancellation( ) # Call the service to start the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test to complete with timeout try: diff --git a/tests/integration/test_scheduler_recursive_timeout.py b/tests/integration/test_scheduler_recursive_timeout.py index d98d2ac5ee..7d7131f8f6 100644 --- a/tests/integration/test_scheduler_recursive_timeout.py +++ b/tests/integration/test_scheduler_recursive_timeout.py @@ -79,7 +79,7 @@ async def test_scheduler_recursive_timeout( ) # Call the service to start the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test to complete try: diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py index 3e72bacc0d..5c78f829a4 100644 --- a/tests/integration/test_scheduler_removed_item_race.py +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -81,7 +81,7 @@ async def test_scheduler_removed_item_race( assert run_test_service is not None, "run_test service not found" # Execute the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_simultaneous_callbacks.py b/tests/integration/test_scheduler_simultaneous_callbacks.py index 82fd0fc01e..66b2862eef 100644 --- a/tests/integration/test_scheduler_simultaneous_callbacks.py +++ b/tests/integration/test_scheduler_simultaneous_callbacks.py @@ -98,7 +98,7 @@ async def test_scheduler_simultaneous_callbacks( ) # Call the service to start the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test to complete try: diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py index 7ec5a54373..bfa581129b 100644 --- a/tests/integration/test_scheduler_string_lifetime.py +++ b/tests/integration/test_scheduler_string_lifetime.py @@ -134,27 +134,27 @@ async def test_scheduler_string_lifetime( # Run tests sequentially, waiting for each to complete try: # Test 1 - client.execute_service(test_services["test1"], {}) + await client.execute_service(test_services["test1"], {}) await asyncio.wait_for(test1_complete.wait(), timeout=5.0) # Test 2 - client.execute_service(test_services["test2"], {}) + await client.execute_service(test_services["test2"], {}) await asyncio.wait_for(test2_complete.wait(), timeout=5.0) # Test 3 - client.execute_service(test_services["test3"], {}) + await client.execute_service(test_services["test3"], {}) await asyncio.wait_for(test3_complete.wait(), timeout=5.0) # Test 4 - client.execute_service(test_services["test4"], {}) + await client.execute_service(test_services["test4"], {}) await asyncio.wait_for(test4_complete.wait(), timeout=5.0) # Test 5 - client.execute_service(test_services["test5"], {}) + await client.execute_service(test_services["test5"], {}) await asyncio.wait_for(test5_complete.wait(), timeout=5.0) # Final check - client.execute_service(test_services["final"], {}) + await client.execute_service(test_services["final"], {}) await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) except TimeoutError: diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py index 4c52913e63..56b8998c56 100644 --- a/tests/integration/test_scheduler_string_name_stress.py +++ b/tests/integration/test_scheduler_string_name_stress.py @@ -92,7 +92,7 @@ async def test_scheduler_string_name_stress( ) # Call the service to start the test - client.execute_service(run_stress_test_service, {}) + await client.execute_service(run_stress_test_service, {}) # Wait for test to complete or crash try: diff --git a/tests/integration/test_script_delay_params.py b/tests/integration/test_script_delay_params.py index 1b5d70863b..37c72f0f7d 100644 --- a/tests/integration/test_script_delay_params.py +++ b/tests/integration/test_script_delay_params.py @@ -90,7 +90,7 @@ async def test_script_delay_with_params( assert test_service is not None, "test_repeat_with_delay service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test to complete (10 iterations * ~100ms each + margin) try: diff --git a/tests/integration/test_script_queued.py b/tests/integration/test_script_queued.py index ce1c25b649..c86c289719 100644 --- a/tests/integration/test_script_queued.py +++ b/tests/integration/test_script_queued.py @@ -136,7 +136,7 @@ async def test_script_queued( # Test 1: Queue depth limit test_service = next((s for s in services if s.name == "test_queue_depth"), None) assert test_service is not None, "test_queue_depth service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test1_complete, timeout=2.0) await asyncio.sleep(0.1) # Give time for rejections @@ -151,7 +151,7 @@ async def test_script_queued( # Test 2: Ring buffer order test_service = next((s for s in services if s.name == "test_ring_buffer"), None) assert test_service is not None, "test_ring_buffer service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test2_complete, timeout=2.0) # Verify Test 2 @@ -165,7 +165,7 @@ async def test_script_queued( # Test 3: Stop clears queue test_service = next((s for s in services if s.name == "test_stop_clears"), None) assert test_service is not None, "test_stop_clears service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test3_complete, timeout=2.0) # Verify Test 3 @@ -179,7 +179,7 @@ async def test_script_queued( # Test 4: Rejection enforcement (max_runs=3) test_service = next((s for s in services if s.name == "test_rejection"), None) assert test_service is not None, "test_rejection service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test4_complete, timeout=2.0) await asyncio.sleep(0.1) # Give time for rejections @@ -194,7 +194,7 @@ async def test_script_queued( # Test 5: No parameters test_service = next((s for s in services if s.name == "test_no_params"), None) assert test_service is not None, "test_no_params service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test5_complete, timeout=2.0) # Verify Test 5 diff --git a/tests/integration/test_wait_until_mid_loop_timing.py b/tests/integration/test_wait_until_mid_loop_timing.py index 01cad747ae..b5dd1a0028 100644 --- a/tests/integration/test_wait_until_mid_loop_timing.py +++ b/tests/integration/test_wait_until_mid_loop_timing.py @@ -86,7 +86,7 @@ async def test_wait_until_mid_loop_timing( assert test_service is not None, "test_mid_loop_timeout service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test to complete (100ms delay + 200ms timeout + margins = ~500ms) await asyncio.wait_for(test_complete, timeout=5.0) diff --git a/tests/integration/test_wait_until_on_boot.py b/tests/integration/test_wait_until_on_boot.py index b42c530c54..da21a43200 100644 --- a/tests/integration/test_wait_until_on_boot.py +++ b/tests/integration/test_wait_until_on_boot.py @@ -74,7 +74,7 @@ async def test_wait_until_on_boot( ) assert set_flag_service is not None, "set_test_flag service not found" - client.execute_service(set_flag_service, {}) + await 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 diff --git a/tests/integration/test_wait_until_ordering.py b/tests/integration/test_wait_until_ordering.py index 7c39913e5a..96b3a8aed0 100644 --- a/tests/integration/test_wait_until_ordering.py +++ b/tests/integration/test_wait_until_ordering.py @@ -71,7 +71,7 @@ async def test_wait_until_fifo_ordering( assert test_service is not None, "test_wait_until_fifo service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test to complete try: From aeedfdcaf3c2341cfd0e6d34dd28bcacb248d333 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:31:56 -0600 Subject: [PATCH 269/320] Bump aioesphomeapi from 43.0.0 to 43.1.0 (#12333) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0bad48716e..5596f050af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==43.0.0 +aioesphomeapi==43.1.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From 1f271e7c10e28596ff5002088b7ffae33feb4035 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:32:08 -0600 Subject: [PATCH 270/320] Bump pytest from 9.0.1 to 9.0.2 (#12332) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 16ac131517..60656712b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==9.0.1 +pytest==9.0.2 pytest-cov==7.0.0 pytest-mock==3.15.1 pytest-asyncio==1.3.0 From e7a3cccb4d76e44e9c0bd25248feff1f5bb36717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 13:30:06 -0600 Subject: [PATCH 271/320] [text_sensor] Reduce filter memory usage using const char* (#12334) --- esphome/components/text_sensor/filter.cpp | 25 ++- esphome/components/text_sensor/filter.h | 12 +- .../fixtures/text_sensor_raw_state.yaml | 127 ++++++++++++ .../integration/test_text_sensor_raw_state.py | 190 ++++++++++++++++-- 4 files changed, 326 insertions(+), 28 deletions(-) diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 40a37febee..4cace372ae 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -56,10 +56,16 @@ optional ToLowerFilter::new_value(std::string value) { } // Append -optional AppendFilter::new_value(std::string value) { return value + this->suffix_; } +optional AppendFilter::new_value(std::string value) { + value.append(this->suffix_); + return value; +} // Prepend -optional PrependFilter::new_value(std::string value) { return this->prefix_ + value; } +optional PrependFilter::new_value(std::string value) { + value.insert(0, this->prefix_); + return value; +} // Substitute SubstituteFilter::SubstituteFilter(const std::initializer_list &substitutions) @@ -67,12 +73,15 @@ SubstituteFilter::SubstituteFilter(const std::initializer_list &su optional SubstituteFilter::new_value(std::string value) { for (const auto &sub : this->substitutions_) { + // Compute lengths once per substitution (strlen is fast, called infrequently) + const size_t from_len = strlen(sub.from); + const size_t to_len = strlen(sub.to); std::size_t pos = 0; - while ((pos = value.find(sub.from, pos)) != std::string::npos) { - value.replace(pos, sub.from.size(), sub.to); + while ((pos = value.find(sub.from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, sub.to, to_len); // Advance past the replacement to avoid infinite loop when // the replacement contains the search pattern (e.g., f -> foo) - pos += sub.to.size(); + pos += to_len; } } return value; @@ -83,8 +92,10 @@ MapFilter::MapFilter(const std::initializer_list &mappings) : mapp optional MapFilter::new_value(std::string value) { for (const auto &mapping : this->mappings_) { - if (mapping.from == value) - return mapping.to; + if (value == mapping.from) { + value.assign(mapping.to); + return value; + } } return value; // Pass through if no match } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 85acac5c8d..0f66b753b4 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -92,26 +92,26 @@ class ToLowerFilter : public Filter { /// A simple filter that adds a string to the end of another string class AppendFilter : public Filter { public: - AppendFilter(std::string suffix) : suffix_(std::move(suffix)) {} + explicit AppendFilter(const char *suffix) : suffix_(suffix) {} optional new_value(std::string value) override; protected: - std::string suffix_; + const char *suffix_; }; /// A simple filter that adds a string to the start of another string class PrependFilter : public Filter { public: - PrependFilter(std::string prefix) : prefix_(std::move(prefix)) {} + explicit PrependFilter(const char *prefix) : prefix_(prefix) {} optional new_value(std::string value) override; protected: - std::string prefix_; + const char *prefix_; }; struct Substitution { - std::string from; - std::string to; + const char *from; + const char *to; }; /// A simple filter that replaces a substring with another substring diff --git a/tests/integration/fixtures/text_sensor_raw_state.yaml b/tests/integration/fixtures/text_sensor_raw_state.yaml index 03aece0a04..54ab2e8dcc 100644 --- a/tests/integration/fixtures/text_sensor_raw_state.yaml +++ b/tests/integration/fixtures/text_sensor_raw_state.yaml @@ -20,6 +20,42 @@ text_sensor: filters: - to_upper + # StringRef-based filters (append, prepend, substitute, map) + - platform: template + name: "Append Sensor" + id: append_sensor + filters: + - append: " suffix" + + - platform: template + name: "Prepend Sensor" + id: prepend_sensor + filters: + - prepend: "prefix " + + - platform: template + name: "Substitute Sensor" + id: substitute_sensor + filters: + - substitute: + - foo -> bar + - hello -> world + + - platform: template + name: "Map Sensor" + id: map_sensor + filters: + - map: + - ON -> Active + - OFF -> Inactive + + - platform: template + name: "Chained Sensor" + id: chained_sensor + filters: + - prepend: "[" + - append: "]" + # Button to publish values and log raw_state vs state button: - platform: template @@ -52,3 +88,94 @@ button: args: - id(with_filter_sensor).state.c_str() - id(with_filter_sensor).get_raw_state().c_str() + + - platform: template + name: "Test Append Button" + id: test_append_button + on_press: + - text_sensor.template.publish: + id: append_sensor + state: "test" + - delay: 50ms + - logger.log: + format: "APPEND: state='%s'" + args: + - id(append_sensor).state.c_str() + + - platform: template + name: "Test Prepend Button" + id: test_prepend_button + on_press: + - text_sensor.template.publish: + id: prepend_sensor + state: "test" + - delay: 50ms + - logger.log: + format: "PREPEND: state='%s'" + args: + - id(prepend_sensor).state.c_str() + + - platform: template + name: "Test Substitute Button" + id: test_substitute_button + on_press: + - text_sensor.template.publish: + id: substitute_sensor + state: "foo says hello" + - delay: 50ms + - logger.log: + format: "SUBSTITUTE: state='%s'" + args: + - id(substitute_sensor).state.c_str() + + - platform: template + name: "Test Map ON Button" + id: test_map_on_button + on_press: + - text_sensor.template.publish: + id: map_sensor + state: "ON" + - delay: 50ms + - logger.log: + format: "MAP_ON: state='%s'" + args: + - id(map_sensor).state.c_str() + + - platform: template + name: "Test Map OFF Button" + id: test_map_off_button + on_press: + - text_sensor.template.publish: + id: map_sensor + state: "OFF" + - delay: 50ms + - logger.log: + format: "MAP_OFF: state='%s'" + args: + - id(map_sensor).state.c_str() + + - platform: template + name: "Test Map Unknown Button" + id: test_map_unknown_button + on_press: + - text_sensor.template.publish: + id: map_sensor + state: "UNKNOWN" + - delay: 50ms + - logger.log: + format: "MAP_UNKNOWN: state='%s'" + args: + - id(map_sensor).state.c_str() + + - platform: template + name: "Test Chained Button" + id: test_chained_button + on_press: + - text_sensor.template.publish: + id: chained_sensor + state: "value" + - delay: 50ms + - logger.log: + format: "CHAINED: state='%s'" + args: + - id(chained_sensor).state.c_str() diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py index a53ec8c963..482ebbe9c2 100644 --- a/tests/integration/test_text_sensor_raw_state.py +++ b/tests/integration/test_text_sensor_raw_state.py @@ -1,8 +1,10 @@ -"""Integration test for TextSensor get_raw_state() functionality. +"""Integration test for TextSensor get_raw_state() and StringRef-based filters. -This tests the optimization in PR #12205 where raw_state is only stored -when filters are configured. When no filters exist, get_raw_state() should -return state directly. +This tests: +1. The optimization in PR #12205 where raw_state is only stored when filters + are configured. When no filters exist, get_raw_state() should return state. +2. StringRef-based filters (append, prepend, substitute, map) which store + static string data in flash instead of heap-allocating std::string. """ from __future__ import annotations @@ -21,16 +23,25 @@ async def test_text_sensor_raw_state( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that get_raw_state() works correctly with and without filters. + """Test text sensor filters and raw_state behavior. - Without filters: get_raw_state() should return the same value as state - With filters: get_raw_state() should return the original (unfiltered) value + Tests: + 1. get_raw_state() without filters returns same as state + 2. get_raw_state() with filters returns original (unfiltered) value + 3. StringRef-based filters: append, prepend, substitute, map, chained """ loop = asyncio.get_running_loop() # Futures to track log messages no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + append_future: asyncio.Future[str] = loop.create_future() + prepend_future: asyncio.Future[str] = loop.create_future() + substitute_future: asyncio.Future[str] = loop.create_future() + map_on_future: asyncio.Future[str] = loop.create_future() + map_off_future: asyncio.Future[str] = loop.create_future() + map_unknown_future: asyncio.Future[str] = loop.create_future() + chained_future: asyncio.Future[str] = loop.create_future() # Patterns to match log output # NO_FILTER: state='hello world' raw_state='hello world' @@ -39,18 +50,47 @@ async def test_text_sensor_raw_state( with_filter_pattern = re.compile( r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" ) + # StringRef-based filter patterns + append_pattern = re.compile(r"APPEND: state='([^']*)'") + prepend_pattern = re.compile(r"PREPEND: state='([^']*)'") + substitute_pattern = re.compile(r"SUBSTITUTE: state='([^']*)'") + map_on_pattern = re.compile(r"MAP_ON: state='([^']*)'") + map_off_pattern = re.compile(r"MAP_OFF: state='([^']*)'") + map_unknown_pattern = re.compile(r"MAP_UNKNOWN: state='([^']*)'") + chained_pattern = re.compile(r"CHAINED: state='([^']*)'") def check_output(line: str) -> None: """Check log output for expected messages.""" - if not no_filter_future.done(): - match = no_filter_pattern.search(line) - if match: - no_filter_future.set_result((match.group(1), match.group(2))) + if not no_filter_future.done() and (match := no_filter_pattern.search(line)): + no_filter_future.set_result((match.group(1), match.group(2))) - if not with_filter_future.done(): - match = with_filter_pattern.search(line) - if match: - with_filter_future.set_result((match.group(1), match.group(2))) + if not with_filter_future.done() and ( + match := with_filter_pattern.search(line) + ): + with_filter_future.set_result((match.group(1), match.group(2))) + + if not append_future.done() and (match := append_pattern.search(line)): + append_future.set_result(match.group(1)) + + if not prepend_future.done() and (match := prepend_pattern.search(line)): + prepend_future.set_result(match.group(1)) + + if not substitute_future.done() and (match := substitute_pattern.search(line)): + substitute_future.set_result(match.group(1)) + + if not map_on_future.done() and (match := map_on_pattern.search(line)): + map_on_future.set_result(match.group(1)) + + if not map_off_future.done() and (match := map_off_pattern.search(line)): + map_off_future.set_result(match.group(1)) + + if not map_unknown_future.done() and ( + match := map_unknown_pattern.search(line) + ): + map_unknown_future.set_result(match.group(1)) + + if not chained_future.done() and (match := chained_pattern.search(line)): + chained_future.set_result(match.group(1)) async with ( run_compiled(yaml_config, line_callback=check_output), @@ -112,3 +152,123 @@ async def test_text_sensor_raw_state( f"With filters, state and raw_state should differ. " f"state='{state}', raw_state='{raw_state}'" ) + + # Test 3: Append filter (StringRef-based) + # "test" + " suffix" = "test suffix" + append_button = next( + (e for e in entities if "test_append_button" in e.object_id.lower()), + None, + ) + assert append_button is not None, "Test Append Button not found" + client.button_command(append_button.key) + + try: + state = await asyncio.wait_for(append_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for APPEND log message") + + assert state == "test suffix", ( + f"Append failed: expected 'test suffix', got '{state}'" + ) + + # Test 4: Prepend filter (StringRef-based) + # "prefix " + "test" = "prefix test" + prepend_button = next( + (e for e in entities if "test_prepend_button" in e.object_id.lower()), + None, + ) + assert prepend_button is not None, "Test Prepend Button not found" + client.button_command(prepend_button.key) + + try: + state = await asyncio.wait_for(prepend_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for PREPEND log message") + + assert state == "prefix test", ( + f"Prepend failed: expected 'prefix test', got '{state}'" + ) + + # Test 5: Substitute filter (StringRef-based) + # "foo says hello" with foo->bar, hello->world = "bar says world" + substitute_button = next( + (e for e in entities if "test_substitute_button" in e.object_id.lower()), + None, + ) + assert substitute_button is not None, "Test Substitute Button not found" + client.button_command(substitute_button.key) + + try: + state = await asyncio.wait_for(substitute_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for SUBSTITUTE log message") + + assert state == "bar says world", ( + f"Substitute failed: expected 'bar says world', got '{state}'" + ) + + # Test 6: Map filter - "ON" -> "Active" + map_on_button = next( + (e for e in entities if "test_map_on_button" in e.object_id.lower()), + None, + ) + assert map_on_button is not None, "Test Map ON Button not found" + client.button_command(map_on_button.key) + + try: + state = await asyncio.wait_for(map_on_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for MAP_ON log message") + + assert state == "Active", f"Map ON failed: expected 'Active', got '{state}'" + + # Test 7: Map filter - "OFF" -> "Inactive" + map_off_button = next( + (e for e in entities if "test_map_off_button" in e.object_id.lower()), + None, + ) + assert map_off_button is not None, "Test Map OFF Button not found" + client.button_command(map_off_button.key) + + try: + state = await asyncio.wait_for(map_off_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for MAP_OFF log message") + + assert state == "Inactive", ( + f"Map OFF failed: expected 'Inactive', got '{state}'" + ) + + # Test 8: Map filter - passthrough for unknown values + # "UNKNOWN" -> "UNKNOWN" (no match, passes through unchanged) + map_unknown_button = next( + (e for e in entities if "test_map_unknown_button" in e.object_id.lower()), + None, + ) + assert map_unknown_button is not None, "Test Map Unknown Button not found" + client.button_command(map_unknown_button.key) + + try: + state = await asyncio.wait_for(map_unknown_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for MAP_UNKNOWN log message") + + assert state == "UNKNOWN", ( + f"Map passthrough failed: expected 'UNKNOWN', got '{state}'" + ) + + # Test 9: Chained filters (prepend "[" + append "]") + # "[" + "value" + "]" = "[value]" + chained_button = next( + (e for e in entities if "test_chained_button" in e.object_id.lower()), + None, + ) + assert chained_button is not None, "Test Chained Button not found" + client.button_command(chained_button.key) + + try: + state = await asyncio.wait_for(chained_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for CHAINED log message") + + assert state == "[value]", f"Chained failed: expected '[value]', got '{state}'" From 05826d5ead2b73a8d0c867ce05cd64b03e777583 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 13:30:22 -0600 Subject: [PATCH 272/320] [scheduler] Fix missing lock when recycling items in defer queue processing (#12343) --- esphome/core/scheduler.cpp | 4 ++++ esphome/core/scheduler.h | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5e313f770f..f84495950c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -744,6 +744,10 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, : (a->next_execution_high_ > b->next_execution_high_); } +// Recycle a SchedulerItem back to the pool for reuse. +// IMPORTANT: Caller must hold the scheduler lock before calling this function. +// This protects scheduler_item_pool_ from concurrent access by other threads +// that may be acquiring items from the pool in set_timer_common_(). void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { if (!item) return; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index dcf418c14f..5bf3d19adb 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -275,6 +275,7 @@ class Scheduler { // Helper to recycle a SchedulerItem back to the pool. // IMPORTANT: Only call from main loop context! Recycling clears the callback, // so calling from another thread while the callback is executing causes use-after-free. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. void recycle_item_main_loop_(std::unique_ptr item); // Helper to perform full cleanup when too many items are cancelled @@ -331,7 +332,10 @@ class Scheduler { now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution - this->recycle_item_main_loop_(std::move(item)); + { + LockGuard lock(this->lock_); + this->recycle_item_main_loop_(std::move(item)); + } } // If we've consumed all items up to the snapshot point, clean up the dead space From 4b5435fd93fb314e0125fa001bae1596dc7aade3 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Sun, 7 Dec 2025 21:16:49 +0100 Subject: [PATCH 273/320] [nextion] Use 16-bit id for pics (#12330) Co-authored-by: Szczepan Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/nextion.h | 10 +++++----- esphome/components/nextion/nextion_commands.cpp | 16 ++++++---------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 61068b52fc..7e8f563a96 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -171,7 +171,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * * This will change the image of the component `pic` to the image with ID `4`. */ - void set_component_picture(const char *component, uint8_t picture_id); + void set_component_picture(const char *component, uint8_t picture_id) { set_component_picc(component, picture_id); }; /** * Set the background color of a component. @@ -374,7 +374,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * * This will change the picture id of the component `textview`. */ - void set_component_pic(const char *component, uint8_t pic_id); + void set_component_pic(const char *component, uint16_t pic_id); /** * Set the background picture id of component. @@ -388,7 +388,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * * This will change the background picture id of the component `textview`. */ - void set_component_picc(const char *component, uint8_t pic_id); + void set_component_picc(const char *component, uint16_t pic_id); /** * Set the font color of a component. @@ -910,7 +910,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * Draws a QR code with a Wi-Fi network credentials starting at the given coordinates (25,25). */ void qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size = 200, uint16_t background_color = 65535, - uint16_t foreground_color = 0, uint8_t logo_pic = -1, uint8_t border_width = 8); + uint16_t foreground_color = 0, int32_t logo_pic = -1, uint8_t border_width = 8); /** * Draws a QR code in the screen @@ -935,7 +935,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ void qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, Color background_color = Color(255, 255, 255), Color foreground_color = Color(0, 0, 0), - uint8_t logo_pic = -1, uint8_t border_width = 8); + int32_t logo_pic = -1, uint8_t border_width = 8); /** Set the brightness of the backlight. * diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index cfaae7e3e0..2adf314a2e 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -143,12 +143,12 @@ void Nextion::set_component_pressed_font_color(const char *component, Color colo } // Set picture -void Nextion::set_component_pic(const char *component, uint8_t pic_id) { - this->add_no_result_to_queue_with_printf_("set_component_pic", "%s.pic=%" PRIu8, component, pic_id); +void Nextion::set_component_pic(const char *component, uint16_t pic_id) { + this->add_no_result_to_queue_with_printf_("set_component_pic", "%s.pic=%" PRIu16, component, pic_id); } -void Nextion::set_component_picc(const char *component, uint8_t pic_id) { - this->add_no_result_to_queue_with_printf_("set_component_picc", "%s.picc=%" PRIu8, component, pic_id); +void Nextion::set_component_picc(const char *component, uint16_t pic_id) { + this->add_no_result_to_queue_with_printf_("set_component_picc", "%s.picc=%" PRIu16, component, pic_id); } // Set video @@ -217,10 +217,6 @@ void Nextion::disable_component_touch(const char *component) { this->add_no_result_to_queue_with_printf_("disable_component_touch", "tsw %s,0", component); } -void Nextion::set_component_picture(const char *component, uint8_t picture_id) { - this->add_no_result_to_queue_with_printf_("set_component_picture", "%s.pic=%" PRIu8, component, picture_id); -} - void Nextion::set_component_text(const char *component, const char *text) { this->add_no_result_to_queue_with_printf_("set_component_text", "%s.txt=\"%s\"", component, text); } @@ -330,14 +326,14 @@ void Nextion::filled_circle(uint16_t center_x, uint16_t center_y, uint16_t radiu } void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, uint16_t background_color, - uint16_t foreground_color, uint8_t logo_pic, uint8_t border_width) { + uint16_t foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, y1, size, background_color, foreground_color, logo_pic, border_width, content); } void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, Color background_color, - Color foreground_color, uint8_t logo_pic, uint8_t border_width) { + Color foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, y1, size, display::ColorUtil::color_to_565(background_color), display::ColorUtil::color_to_565(foreground_color), From acda5bcd5aa38d7a3e7b1d5577dcb149d84be725 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 14:34:12 -0600 Subject: [PATCH 274/320] [text] Add component tests with pattern coverage (#12345) --- tests/components/text/common.yaml | 25 +++++++++++++++++++ tests/components/text/test.esp32-idf.yaml | 2 ++ tests/components/text/test.esp8266-ard.yaml | 2 ++ .../fixtures/api_message_size_batching.yaml | 1 + .../test_api_message_size_batching.py | 3 +++ 5 files changed, 33 insertions(+) create mode 100644 tests/components/text/common.yaml create mode 100644 tests/components/text/test.esp32-idf.yaml create mode 100644 tests/components/text/test.esp8266-ard.yaml diff --git a/tests/components/text/common.yaml b/tests/components/text/common.yaml new file mode 100644 index 0000000000..26618be03a --- /dev/null +++ b/tests/components/text/common.yaml @@ -0,0 +1,25 @@ +text: + - platform: template + name: "Test Text" + id: test_text + optimistic: true + min_length: 0 + max_length: 100 + mode: text + + - platform: template + name: "Test Text with Pattern" + id: test_text_pattern + optimistic: true + min_length: 1 + max_length: 50 + pattern: "[A-Za-z0-9 ]+" + mode: text + + - platform: template + name: "Test Password" + id: test_password + optimistic: true + min_length: 8 + max_length: 32 + mode: password diff --git a/tests/components/text/test.esp32-idf.yaml b/tests/components/text/test.esp32-idf.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/text/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/text/test.esp8266-ard.yaml b/tests/components/text/test.esp8266-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/text/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/integration/fixtures/api_message_size_batching.yaml b/tests/integration/fixtures/api_message_size_batching.yaml index c730dc1aa3..0fed311e63 100644 --- a/tests/integration/fixtures/api_message_size_batching.yaml +++ b/tests/integration/fixtures/api_message_size_batching.yaml @@ -143,6 +143,7 @@ text: mode: text min_length: 0 max_length: 255 + pattern: "[A-Za-z0-9 ]+" initial_value: "Initial value" update_interval: 5.0s diff --git a/tests/integration/test_api_message_size_batching.py b/tests/integration/test_api_message_size_batching.py index f7859eb902..5b123318c4 100644 --- a/tests/integration/test_api_message_size_batching.py +++ b/tests/integration/test_api_message_size_batching.py @@ -141,6 +141,9 @@ async def test_api_message_size_batching( assert text_input.max_length == 255, ( f"Expected max_length 255, got {text_input.max_length}" ) + assert text_input.pattern == "[A-Za-z0-9 ]+", ( + f"Expected pattern '[A-Za-z0-9 ]+', got '{text_input.pattern}'" + ) # Verify total entity count - messages of various sizes were batched successfully # We have: 3 selects + 3 text sensors + 1 text input + 1 number = 8 total From f015130f2eca456ee34836ca30af757fc6e59d24 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 7 Dec 2025 21:59:59 +0100 Subject: [PATCH 275/320] [esp8266] Allow use of recvfrom for esphome sockets (#12342) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 6 ++++++ esphome/components/socket/lwip_sockets_impl.cpp | 3 +++ esphome/components/socket/socket.h | 2 -- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index e0d93d8e2f..e57af91b77 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -334,6 +334,12 @@ class LWIPRawImpl : public Socket { } return ret; } + + ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { + errno = ENOTSUP; + return -1; + } + ssize_t internal_write(const void *buf, size_t len) { if (pcb_ == nullptr) { errno = ECONNRESET; diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index f8a1cbc046..d94c1fb2ff 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -113,6 +113,9 @@ class LwIPSocketImpl : public Socket { } int listen(int backlog) override { return lwip_listen(fd_, backlog); } ssize_t read(void *buf, size_t len) override { return lwip_read(fd_, buf, len); } + ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { + return lwip_recvfrom(fd_, buf, len, 0, addr, addr_len); + } ssize_t readv(const struct iovec *iov, int iovcnt) override { return lwip_readv(fd_, iov, iovcnt); } ssize_t write(const void *buf, size_t len) override { return lwip_write(fd_, buf, len); } ssize_t send(void *buf, size_t len, int flags) { return lwip_send(fd_, buf, len, flags); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8f0d28362e..78a89fe008 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -39,9 +39,7 @@ class Socket { virtual int setsockopt(int level, int optname, const void *optval, socklen_t optlen) = 0; virtual int listen(int backlog) = 0; virtual ssize_t read(void *buf, size_t len) = 0; -#ifdef USE_SOCKET_IMPL_BSD_SOCKETS virtual ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) = 0; -#endif virtual ssize_t readv(const struct iovec *iov, int iovcnt) = 0; virtual ssize_t write(const void *buf, size_t len) = 0; virtual ssize_t writev(const struct iovec *iov, int iovcnt) = 0; From 3d5d89ff002f8bc12a6adcf760ea817c76950831 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 15:09:25 -0600 Subject: [PATCH 276/320] [template] Use C++17 nested namespace syntax (#12346) --- .../alarm_control_panel/template_alarm_control_panel.cpp | 6 ++---- .../alarm_control_panel/template_alarm_control_panel.h | 6 ++---- .../template/binary_sensor/template_binary_sensor.cpp | 6 ++---- .../template/binary_sensor/template_binary_sensor.h | 6 ++---- esphome/components/template/button/template_button.h | 6 ++---- esphome/components/template/cover/template_cover.cpp | 6 ++---- esphome/components/template/cover/template_cover.h | 6 ++---- esphome/components/template/datetime/template_date.cpp | 6 ++---- esphome/components/template/datetime/template_date.h | 6 ++---- esphome/components/template/datetime/template_datetime.cpp | 6 ++---- esphome/components/template/datetime/template_datetime.h | 6 ++---- esphome/components/template/datetime/template_time.cpp | 6 ++---- esphome/components/template/datetime/template_time.h | 6 ++---- esphome/components/template/event/template_event.h | 6 ++---- esphome/components/template/fan/template_fan.cpp | 6 ++---- esphome/components/template/fan/template_fan.h | 6 ++---- esphome/components/template/lock/automation.h | 6 ++---- esphome/components/template/lock/template_lock.cpp | 6 ++---- esphome/components/template/lock/template_lock.h | 6 ++---- esphome/components/template/number/template_number.cpp | 6 ++---- esphome/components/template/number/template_number.h | 6 ++---- esphome/components/template/output/template_output.h | 6 ++---- esphome/components/template/select/template_select.cpp | 6 ++---- esphome/components/template/select/template_select.h | 6 ++---- esphome/components/template/sensor/template_sensor.cpp | 6 ++---- esphome/components/template/sensor/template_sensor.h | 6 ++---- esphome/components/template/switch/template_switch.cpp | 6 ++---- esphome/components/template/switch/template_switch.h | 6 ++---- esphome/components/template/text/template_text.cpp | 6 ++---- esphome/components/template/text/template_text.h | 6 ++---- .../template/text_sensor/template_text_sensor.cpp | 6 ++---- .../components/template/text_sensor/template_text_sensor.h | 6 ++---- esphome/components/template/valve/automation.h | 6 ++---- esphome/components/template/valve/template_valve.cpp | 6 ++---- esphome/components/template/valve/template_valve.h | 6 ++---- 35 files changed, 70 insertions(+), 140 deletions(-) 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 f025435261..50e43da8d5 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 @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::alarm_control_panel; @@ -286,5 +285,4 @@ void TemplateAlarmControlPanel::control(const AlarmControlPanelCall &call) { } } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 80ce34b8ae..bdd3747372 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -14,8 +14,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -namespace esphome { -namespace template_ { +namespace esphome::template_ { #ifdef USE_BINARY_SENSOR enum BinarySensorFlags : uint16_t { @@ -169,5 +168,4 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void arm_(optional code, alarm_control_panel::AlarmControlPanelState state, uint32_t delay); }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.cpp b/esphome/components/template/binary_sensor/template_binary_sensor.cpp index 806aed49b1..b63121d2db 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.cpp +++ b/esphome/components/template/binary_sensor/template_binary_sensor.cpp @@ -1,8 +1,7 @@ #include "template_binary_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.binary_sensor"; @@ -23,5 +22,4 @@ void TemplateBinarySensor::loop() { void TemplateBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Template Binary Sensor", this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index 0af709b097..c78a95e0e3 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: @@ -21,5 +20,4 @@ class TemplateBinarySensor final : public Component, public binary_sensor::Binar TemplateLambda f_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index 5bda82c58f..f64a85eef0 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -2,8 +2,7 @@ #include "esphome/components/button/button.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateButton final : public button::Button { public: @@ -11,5 +10,4 @@ class TemplateButton final : public button::Button { void press_action() override{}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index a87f28ccec..9c8a8fc9bc 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -1,8 +1,7 @@ #include "template_cover.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::cover; @@ -133,5 +132,4 @@ void TemplateCover::stop_prev_trigger_() { } } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 125c67bb86..9c4a787283 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { enum TemplateCoverRestoreMode { COVER_NO_RESTORE, @@ -63,5 +62,4 @@ class TemplateCover final : public cover::Cover, public Component { bool has_tilt_{false}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index 3f6626e847..303d5ae2b0 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.date"; @@ -104,7 +103,6 @@ void TemplateDate::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATE diff --git a/esphome/components/template/datetime/template_date.h b/esphome/components/template/datetime/template_date.h index fe64b0ba14..0379a9bc67 100644 --- a/esphome/components/template/datetime/template_date.h +++ b/esphome/components/template/datetime/template_date.h @@ -11,8 +11,7 @@ #include "esphome/core/time.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateDate final : public datetime::DateEntity, public PollingComponent { public: @@ -41,7 +40,6 @@ class TemplateDate final : public datetime::DateEntity, public PollingComponent ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATE diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 62f842a7ad..81a823f53e 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.datetime"; @@ -143,7 +142,6 @@ void TemplateDateTime::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATETIME diff --git a/esphome/components/template/datetime/template_datetime.h b/esphome/components/template/datetime/template_datetime.h index c44bd85265..b7eb490933 100644 --- a/esphome/components/template/datetime/template_datetime.h +++ b/esphome/components/template/datetime/template_datetime.h @@ -11,8 +11,7 @@ #include "esphome/core/time.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateDateTime final : public datetime::DateTimeEntity, public PollingComponent { public: @@ -41,7 +40,6 @@ class TemplateDateTime final : public datetime::DateTimeEntity, public PollingCo ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATETIME diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index dab28d01cc..21f843dcc7 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.time"; @@ -104,7 +103,6 @@ void TemplateTime::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_TIME diff --git a/esphome/components/template/datetime/template_time.h b/esphome/components/template/datetime/template_time.h index 0c95330d27..cb83b1b3e5 100644 --- a/esphome/components/template/datetime/template_time.h +++ b/esphome/components/template/datetime/template_time.h @@ -11,8 +11,7 @@ #include "esphome/core/time.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateTime final : public datetime::TimeEntity, public PollingComponent { public: @@ -41,7 +40,6 @@ class TemplateTime final : public datetime::TimeEntity, public PollingComponent ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_TIME diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index 5467a64141..fe83dc9f34 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -3,10 +3,8 @@ #include "esphome/core/component.h" #include "esphome/components/event/event.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateEvent final : public Component, public event::Event {}; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index eba4c673b5..384e6b0ca1 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -1,8 +1,7 @@ #include "template_fan.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.fan"; @@ -34,5 +33,4 @@ void TemplateFan::control(const fan::FanCall &call) { this->publish_state(); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index 052b385b93..b7e1d4ab5a 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateFan final : public Component, public fan::Fan { public: @@ -27,5 +26,4 @@ class TemplateFan final : public Component, public fan::Fan { std::vector preset_modes_{}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h index bd110b7b0c..42a2a826e2 100644 --- a/esphome/components/template/lock/automation.h +++ b/esphome/components/template/lock/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { template class TemplateLockPublishAction : public Action, public Parented { public: @@ -14,5 +13,4 @@ template class TemplateLockPublishAction : public Action, void play(const Ts &...x) override { this->parent_->publish_state(this->state_.value(x...)); } }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 8ed87b9736..de8f9b762c 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -1,8 +1,7 @@ #include "template_lock.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::lock; @@ -56,5 +55,4 @@ void TemplateLock::dump_config() { ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index ac10794e4d..f4396c2c5d 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/lock/lock.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateLock final : public lock::Lock, public Component { public: @@ -36,5 +35,4 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *prev_trigger_{nullptr}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index 145a89a2f7..76fef82225 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -1,8 +1,7 @@ #include "template_number.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.number"; @@ -51,5 +50,4 @@ void TemplateNumber::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h index 876ec96b3b..42c27fc3ca 100644 --- a/esphome/components/template/number/template_number.h +++ b/esphome/components/template/number/template_number.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateNumber final : public number::Number, public PollingComponent { public: @@ -34,5 +33,4 @@ class TemplateNumber final : public number::Number, public PollingComponent { ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/output/template_output.h b/esphome/components/template/output/template_output.h index 9ecfc446b9..e536660b02 100644 --- a/esphome/components/template/output/template_output.h +++ b/esphome/components/template/output/template_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateBinaryOutput final : public output::BinaryOutput { public: @@ -27,5 +26,4 @@ class TemplateFloatOutput final : public output::FloatOutput { Trigger *trigger_ = new Trigger(); }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 112f24e919..9d2df0956b 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -1,8 +1,7 @@ #include "template_select.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.select"; @@ -63,5 +62,4 @@ void TemplateSelect::dump_config() { YESNO(this->optimistic_), this->option_at(this->initial_option_index_), YESNO(this->restore_value_)); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index cb5b546976..2757c51405 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateSelect final : public select::Select, public PollingComponent { public: @@ -34,5 +33,4 @@ class TemplateSelect final : public select::Select, public PollingComponent { ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/sensor/template_sensor.cpp b/esphome/components/template/sensor/template_sensor.cpp index 1558ea9b15..313a163e38 100644 --- a/esphome/components/template/sensor/template_sensor.cpp +++ b/esphome/components/template/sensor/template_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.sensor"; @@ -24,5 +23,4 @@ void TemplateSensor::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 3ca965dde3..825a2b4ffa 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: @@ -21,5 +20,4 @@ class TemplateSensor final : public sensor::Sensor, public PollingComponent { TemplateLambda f_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 95e8692da5..cfa8798e75 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -1,8 +1,7 @@ #include "template_switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.switch"; @@ -57,5 +56,4 @@ void TemplateSwitch::dump_config() { } void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 35c18af448..91b7b396f6 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateSwitch final : public switch_::Switch, public Component { public: @@ -37,5 +36,4 @@ class TemplateSwitch final : public switch_::Switch, public Component { Trigger<> *prev_trigger_{nullptr}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index a917c72a14..556abbbd8b 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -1,8 +1,7 @@ #include "template_text.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.text"; @@ -51,5 +50,4 @@ void TemplateText::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 1a0a66ed5b..178b410ed2 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { // We keep this separate so we don't have to template and duplicate // the text input for each different size flash allocation. @@ -84,5 +83,4 @@ class TemplateText final : public text::Text, public PollingComponent { TemplateTextSaverBase *pref_ = nullptr; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text_sensor/template_text_sensor.cpp b/esphome/components/template/text_sensor/template_text_sensor.cpp index 024d0093a2..89a15b6081 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.cpp +++ b/esphome/components/template/text_sensor/template_text_sensor.cpp @@ -1,8 +1,7 @@ #include "template_text_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.text_sensor"; @@ -20,5 +19,4 @@ float TemplateTextSensor::get_setup_priority() const { return setup_priority::HA void TemplateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Template Sensor", this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index da5c518c7f..0538a7ec21 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: @@ -22,5 +21,4 @@ class TemplateTextSensor final : public text_sensor::TextSensor, public PollingC TemplateLambda f_{}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h index e3f394ac7c..a27e98b25c 100644 --- a/esphome/components/template/valve/automation.h +++ b/esphome/components/template/valve/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { template class TemplateValvePublishAction : public Action, public Parented { TEMPLATABLE_VALUE(float, position) @@ -20,5 +19,4 @@ template class TemplateValvePublishAction : public Action } }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index b91b32473e..4e772f9253 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -1,8 +1,7 @@ #include "template_valve.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::valve; @@ -127,5 +126,4 @@ void TemplateValve::stop_prev_trigger_() { } } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index c452648193..4205682a2a 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/valve/valve.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { enum TemplateValveRestoreMode { VALVE_NO_RESTORE, @@ -57,5 +56,4 @@ class TemplateValve final : public valve::Valve, public Component { bool has_position_{false}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ From 68a7634228883a951f25e12a816796a59ca66499 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 15:33:15 -0600 Subject: [PATCH 277/320] [text] Store pattern as const char* to reduce memory usage (#12335) --- esphome/components/template/text/template_text.cpp | 2 +- esphome/components/text/text_traits.h | 10 +++++----- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/web_server/web_server_v1.cpp | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index 556abbbd8b..32ed8f047b 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -15,7 +15,7 @@ void TemplateText::setup() { 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; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; this->pref_->setup(key, value); } if (!value.empty()) diff --git a/esphome/components/text/text_traits.h b/esphome/components/text/text_traits.h index ceaba2dead..473daafb8e 100644 --- a/esphome/components/text/text_traits.h +++ b/esphome/components/text/text_traits.h @@ -1,8 +1,7 @@ #pragma once -#include +#include -#include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" namespace esphome { @@ -22,8 +21,9 @@ class TextTraits { int get_max_length() const { return this->max_length_; } // Set/get the pattern. - void set_pattern(std::string pattern) { this->pattern_ = std::move(pattern); } - std::string get_pattern() const { return this->pattern_; } + void set_pattern(const char *pattern) { this->pattern_ = pattern; } + std::string get_pattern() const { return std::string(this->pattern_); } + const char *get_pattern_c_str() const { return this->pattern_; } StringRef get_pattern_ref() const { return StringRef(this->pattern_); } // Set/get the frontend mode. @@ -33,7 +33,7 @@ class TextTraits { protected: int min_length_; int max_length_; - std::string pattern_; + const char *pattern_{""}; TextMode mode_{TEXT_MODE_TEXT}; }; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1f3605a082..ca3aa21a95 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1211,7 +1211,7 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json set_json_icon_state_value(root, obj, "text", state, value, start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); - root[ESPHOME_F("pattern")] = obj->traits.get_pattern(); + root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); diff --git a/esphome/components/web_server/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 870a338620..486c38a2ab 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -142,7 +142,7 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { stream.print(R"(" maxlength=")"); stream.print(text->traits.get_max_length()); stream.print(R"(" pattern=")"); - stream.print(text->traits.get_pattern().c_str()); + stream.print(text->traits.get_pattern_c_str()); stream.print(R"(" value=")"); stream.print(text->state.c_str()); stream.print(R"("/>)"); From 1134251c32db10aa56b6f74fa4d57046be5822bf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 00:55:36 +0100 Subject: [PATCH 278/320] [micronova] Set update_interval on entities instead on hub (#12226) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 2 +- esphome/components/micronova/__init__.py | 42 ++++--- .../components/micronova/button/__init__.py | 8 +- .../micronova/button/micronova_button.cpp | 2 +- .../micronova/button/micronova_button.h | 5 +- esphome/components/micronova/micronova.cpp | 24 ++-- esphome/components/micronova/micronova.h | 34 ++--- .../components/micronova/number/__init__.py | 47 ++++--- .../micronova/number/micronova_number.cpp | 2 +- .../micronova/number/micronova_number.h | 9 +- .../components/micronova/sensor/__init__.py | 117 +++++++----------- .../micronova/sensor/micronova_sensor.h | 9 +- .../components/micronova/switch/__init__.py | 8 +- .../micronova/switch/micronova_switch.cpp | 2 +- .../micronova/switch/micronova_switch.h | 5 +- .../micronova/text_sensor/__init__.py | 21 ++-- .../text_sensor/micronova_text_sensor.h | 9 +- tests/components/micronova/common.yaml | 7 ++ 18 files changed, 185 insertions(+), 168 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4f9fb7ef55..1fb6e111b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -307,7 +307,7 @@ esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz esphome/components/micro_wake_word/* @jesserockz @kahrendt -esphome/components/micronova/* @jorre05 +esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 31abc11abf..9b01ae97e3 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -4,7 +4,7 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID -CODEOWNERS = ["@jorre05"] +CODEOWNERS = ["@jorre05", "@edenhaus"] DEPENDENCIES = ["uart"] @@ -12,6 +12,7 @@ CONF_MICRONOVA_ID = "micronova_id" CONF_ENABLE_RX_PIN = "enable_rx_pin" CONF_MEMORY_LOCATION = "memory_location" CONF_MEMORY_ADDRESS = "memory_address" +DEFAULT_POLLING_INTERVAL = "60s" micronova_ns = cg.esphome_ns.namespace("micronova") @@ -31,22 +32,24 @@ MICRONOVA_FUNCTIONS_ENUM = { "STOVE_FUNCTION_CUSTOM": MicroNovaFunctions.STOVE_FUNCTION_CUSTOM, } -MicroNova = micronova_ns.class_("MicroNova", cg.PollingComponent, uart.UARTDevice) +MicroNova = micronova_ns.class_("MicroNova", cg.Component, uart.UARTDevice) +MicroNovaListener = micronova_ns.class_("MicroNovaListener", cg.PollingComponent) -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(MicroNova), - cv.Required(CONF_ENABLE_RX_PIN): pins.gpio_output_pin_schema, - } - ) - .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.polling_component_schema("60s")) -) +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MicroNova), + cv.Required(CONF_ENABLE_RX_PIN): pins.gpio_output_pin_schema, + } +).extend(uart.UART_DEVICE_SCHEMA) -def MICRONOVA_LISTENER_SCHEMA(default_memory_location, default_memory_address): - return cv.Schema( +def MICRONOVA_ADDRESS_SCHEMA( + *, + default_memory_location: int, + default_memory_address: int, + is_polling_component: bool, +): + schema = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), cv.Optional( @@ -57,6 +60,17 @@ def MICRONOVA_LISTENER_SCHEMA(default_memory_location, default_memory_address): ): cv.hex_int_range(), } ) + if is_polling_component: + schema = schema.extend(cv.polling_component_schema(DEFAULT_POLLING_INTERVAL)) + return schema + + +async def to_code_micronova_listener(mv, var, config, micronova_function): + await cg.register_component(var, config) + cg.add(mv.register_micronova_listener(var)) + cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) + cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) + cg.add(var.set_function(micronova_function)) async def to_code(config): diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 813d24efef..dd57c9ec4f 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -6,7 +6,7 @@ from .. import ( CONF_MEMORY_ADDRESS, CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, micronova_ns, @@ -24,8 +24,10 @@ CONFIG_SCHEMA = cv.Schema( MicroNovaButton, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0xA0, default_memory_address=0x7D + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0xA0, + default_memory_address=0x7D, + is_polling_component=False, ) ) .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range()}), diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 147fef37bd..c78b4024f9 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -10,7 +10,7 @@ void MicroNovaButton::press_action() { default: break; } - this->micronova_->update(); + this->micronova_->request_update_listeners(); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 5c1d7d8455..951ae8bba3 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -9,7 +9,10 @@ namespace esphome::micronova { class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { public: MicroNovaButton(MicroNova *m) : MicroNovaButtonListener(m) {} - void dump_config() override { LOG_BUTTON("", "Micronova button", this); } + void dump_config() override { + LOG_BUTTON("", "Micronova button", this); + this->dump_base_config(); + } void set_memory_data(uint8_t f) { this->memory_data_ = f; } uint8_t get_memory_data() { return this->memory_data_; } diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 52b719bff2..7343bc90ba 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,6 +3,18 @@ namespace esphome::micronova { +void MicroNovaBaseListener::dump_base_config() { + ESP_LOGCONFIG(TAG, + " Memory Location: %02X\n" + " Memory Address: %02X", + this->memory_location_, this->memory_address_); +} + +void MicroNovaListener::dump_base_config() { + MicroNovaBaseListener::dump_base_config(); + LOG_UPDATE_INTERVAL(this); +} + void MicroNova::setup() { if (this->enable_rx_pin_ != nullptr) { this->enable_rx_pin_->setup(); @@ -21,16 +33,10 @@ void MicroNova::dump_config() { if (this->enable_rx_pin_ != nullptr) { LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); } - - for (auto &mv_sensor : this->micronova_listeners_) { - mv_sensor->dump_config(); - ESP_LOGCONFIG(TAG, " sensor location:%02X, address:%02X", mv_sensor->get_memory_location(), - mv_sensor->get_memory_address()); - } } -void MicroNova::update() { - ESP_LOGD(TAG, "Schedule sensor update"); +void MicroNova::request_update_listeners() { + ESP_LOGD(TAG, "Schedule listener update"); for (auto &mv_listener : this->micronova_listeners_) { mv_listener->set_needs_update(true); } @@ -61,7 +67,7 @@ void MicroNova::loop() { } } -void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaSensorListener *listener) { +void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaListener *listener) { uint8_t write_data[2] = {0, 0}; uint8_t trash_rx; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index acb85fad3c..7992bff2d3 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -49,7 +49,6 @@ class MicroNovaBaseListener { public: MicroNovaBaseListener() {} MicroNovaBaseListener(MicroNova *m) { this->micronova_ = m; } - virtual void dump_config(); void set_micronova_object(MicroNova *m) { this->micronova_ = m; } @@ -62,6 +61,8 @@ class MicroNovaBaseListener { void set_memory_address(uint8_t a) { this->memory_address_ = a; } uint8_t get_memory_address() { return this->memory_address_; } + void dump_base_config(); + protected: MicroNova *micronova_{nullptr}; MicroNovaFunctions function_ = MicroNovaFunctions::STOVE_FUNCTION_VOID; @@ -69,28 +70,19 @@ class MicroNovaBaseListener { uint8_t memory_address_ = 0; }; -class MicroNovaSensorListener : public MicroNovaBaseListener { +class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent { public: - MicroNovaSensorListener() {} - MicroNovaSensorListener(MicroNova *m) : MicroNovaBaseListener(m) {} + MicroNovaListener() {} + MicroNovaListener(MicroNova *m) : MicroNovaBaseListener(m) {} virtual void request_value_from_stove() = 0; virtual void process_value_from_stove(int value_from_stove) = 0; void set_needs_update(bool u) { this->needs_update_ = u; } bool get_needs_update() { return this->needs_update_; } - protected: - bool needs_update_ = false; -}; + void update() override { this->set_needs_update(true); } -class MicroNovaNumberListener : public MicroNovaBaseListener { - public: - MicroNovaNumberListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void request_value_from_stove() = 0; - virtual void process_value_from_stove(int value_from_stove) = 0; - - void set_needs_update(bool u) { this->needs_update_ = u; } - bool get_needs_update() { return this->needs_update_; } + void dump_base_config(); protected: bool needs_update_ = false; @@ -117,17 +109,17 @@ class MicroNovaButtonListener : public MicroNovaBaseListener { ///////////////////////////////////////////////////////////////////// // Main component class -class MicroNova : public PollingComponent, public uart::UARTDevice { +class MicroNova : public Component, public uart::UARTDevice { public: MicroNova() {} void setup() override; void loop() override; - void update() override; void dump_config() override; - void register_micronova_listener(MicroNovaSensorListener *l) { this->micronova_listeners_.push_back(l); } + void register_micronova_listener(MicroNovaListener *l) { this->micronova_listeners_.push_back(l); } + void request_update_listeners(); - void request_address(uint8_t location, uint8_t address, MicroNovaSensorListener *listener); + void request_address(uint8_t location, uint8_t address, MicroNovaListener *listener); void write_address(uint8_t location, uint8_t address, uint8_t data); int read_stove_reply(); @@ -149,13 +141,13 @@ class MicroNova : public PollingComponent, public uart::UARTDevice { uint8_t memory_location; uint8_t memory_address; bool reply_pending; - MicroNovaSensorListener *initiating_listener; + MicroNovaListener *initiating_listener; }; Mutex reply_pending_mutex_; MicroNovaSerialTransmission current_transmission_; - std::vector micronova_listeners_{}; + std::vector micronova_listeners_{}; MicroNovaSwitchListener *stove_switch_{nullptr}; }; diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index b0eeaf8dd1..ec994a5aa5 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -4,13 +4,13 @@ import esphome.config_validation as cv from esphome.const import CONF_STEP, DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) ICON_FLASH = "mdi:flash" @@ -19,7 +19,9 @@ CONF_THERMOSTAT_TEMPERATURE = "thermostat_temperature" CONF_POWER_LEVEL = "power_level" CONF_MEMORY_WRITE_LOCATION = "memory_write_location" -MicroNovaNumber = micronova_ns.class_("MicroNovaNumber", number.Number, cg.Component) +MicroNovaNumber = micronova_ns.class_( + "MicroNovaNumber", number.Number, MicroNovaListener +) CONFIG_SCHEMA = cv.Schema( { @@ -30,8 +32,10 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_TEMPERATURE, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x20, default_memory_address=0x7D + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x20, + default_memory_address=0x7D, + is_polling_component=True, ) ) .extend( @@ -47,8 +51,10 @@ CONFIG_SCHEMA = cv.Schema( icon=ICON_FLASH, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x20, default_memory_address=0x7F + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x20, + default_memory_address=0x7F, + is_polling_component=True, ) ) .extend( @@ -68,24 +74,18 @@ async def to_code(config): max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) + await to_code_micronova_listener( + mv, + numb, + thermostat_temperature_config, + MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, + ) cg.add(numb.set_micronova_object(mv)) - cg.add(mv.register_micronova_listener(numb)) - cg.add( - numb.set_memory_location( - thermostat_temperature_config[CONF_MEMORY_LOCATION] - ) - ) - cg.add( - numb.set_memory_address(thermostat_temperature_config[CONF_MEMORY_ADDRESS]) - ) cg.add( numb.set_memory_write_location( thermostat_temperature_config.get(CONF_MEMORY_WRITE_LOCATION) ) ) - cg.add( - numb.set_function(MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE) - ) if power_level_config := config.get(CONF_POWER_LEVEL): numb = await number.new_number( @@ -94,13 +94,12 @@ async def to_code(config): max_value=5, step=1, ) + await to_code_micronova_listener( + mv, numb, power_level_config, MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL + ) cg.add(numb.set_micronova_object(mv)) - cg.add(mv.register_micronova_listener(numb)) - cg.add(numb.set_memory_location(power_level_config[CONF_MEMORY_LOCATION])) - cg.add(numb.set_memory_address(power_level_config[CONF_MEMORY_ADDRESS])) cg.add( numb.set_memory_write_location( power_level_config.get(CONF_MEMORY_WRITE_LOCATION) ) ) - cg.add(numb.set_function(MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index b311c85b99..66e81e98b7 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -37,7 +37,7 @@ void MicroNovaNumber::control(float value) { break; } this->micronova_->write_address(this->memory_write_location_, this->memory_address_, new_number); - this->micronova_->update(); + this->micronova_->request_update_listeners(); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 79e59dbc28..e1545bb35a 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -5,11 +5,14 @@ namespace esphome::micronova { -class MicroNovaNumber : public number::Number, public MicroNovaSensorListener { +class MicroNovaNumber : public number::Number, public MicroNovaListener { public: MicroNovaNumber() {} - MicroNovaNumber(MicroNova *m) : MicroNovaSensorListener(m) {} - void dump_config() override { LOG_NUMBER("", "Micronova number", this); } + MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_NUMBER("", "Micronova number", this); + this->dump_base_config(); + } void control(float value) override; void request_value_from_stove() override { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index ceb4a9ef77..77bdacd5da 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -10,18 +10,20 @@ from esphome.const import ( ) from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) UNIT_BAR = "bar" -MicroNovaSensor = micronova_ns.class_("MicroNovaSensor", sensor.Sensor, cg.Component) +MicroNovaSensor = micronova_ns.class_( + "MicroNovaSensor", sensor.Sensor, MicroNovaListener +) CONF_ROOM_TEMPERATURE = "room_temperature" CONF_FUMES_TEMPERATURE = "fumes_temperature" @@ -42,8 +44,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x01 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x01, + is_polling_component=True, ) ), cv.Optional(CONF_FUMES_TEMPERATURE): sensor.sensor_schema( @@ -53,8 +57,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x5A + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x5A, + is_polling_component=True, ) ), cv.Optional(CONF_STOVE_POWER): sensor.sensor_schema( @@ -62,8 +68,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=0, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x34 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x34, + is_polling_component=True, ) ), cv.Optional(CONF_FAN_SPEED): sensor.sensor_schema( @@ -72,8 +80,10 @@ CONFIG_SCHEMA = cv.Schema( unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x37 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x37, + is_polling_component=True, ) ) .extend( @@ -86,8 +96,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x3B + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x3B, + is_polling_component=True, ) ), cv.Optional(CONF_WATER_PRESSURE): sensor.sensor_schema( @@ -97,15 +109,19 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x3C + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x3C, + is_polling_component=True, ) ), cv.Optional(CONF_MEMORY_ADDRESS_SENSOR): sensor.sensor_schema( MicroNovaSensor, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x00 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x00, + is_polling_component=True, ) ), } @@ -115,58 +131,21 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) - if room_temperature_config := config.get(CONF_ROOM_TEMPERATURE): - sens = await sensor.new_sensor(room_temperature_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(room_temperature_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(room_temperature_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE)) - - if fumes_temperature_config := config.get(CONF_FUMES_TEMPERATURE): - sens = await sensor.new_sensor(fumes_temperature_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(fumes_temperature_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(fumes_temperature_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE)) - - if stove_power_config := config.get(CONF_STOVE_POWER): - sens = await sensor.new_sensor(stove_power_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(stove_power_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(stove_power_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER)) + for key, fn in { + CONF_ROOM_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE, + CONF_FUMES_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE, + CONF_STOVE_POWER: MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER, + CONF_MEMORY_ADDRESS_SENSOR: MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR, + CONF_WATER_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE, + CONF_WATER_PRESSURE: MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE, + }.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config, mv) + await to_code_micronova_listener(mv, sens, sensor_config, fn) if fan_speed_config := config.get(CONF_FAN_SPEED): sens = await sensor.new_sensor(fan_speed_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(fan_speed_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(fan_speed_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED)) + await to_code_micronova_listener( + mv, sens, fan_speed_config, MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED + ) cg.add(sens.set_fan_speed_offset(fan_speed_config[CONF_FAN_RPM_OFFSET])) - - if memory_address_sensor_config := config.get(CONF_MEMORY_ADDRESS_SENSOR): - sens = await sensor.new_sensor(memory_address_sensor_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add( - sens.set_memory_location(memory_address_sensor_config[CONF_MEMORY_LOCATION]) - ) - cg.add( - sens.set_memory_address(memory_address_sensor_config[CONF_MEMORY_ADDRESS]) - ) - cg.add( - sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR) - ) - - if water_temperature_config := config.get(CONF_WATER_TEMPERATURE): - sens = await sensor.new_sensor(water_temperature_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(water_temperature_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(water_temperature_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE)) - - if water_pressure_config := config.get(CONF_WATER_PRESSURE): - sens = await sensor.new_sensor(water_pressure_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(water_pressure_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(water_pressure_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE)) diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index 081e68b09d..119e5eb155 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -5,10 +5,13 @@ namespace esphome::micronova { -class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener { +class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { public: - MicroNovaSensor(MicroNova *m) : MicroNovaSensorListener(m) {} - void dump_config() override { LOG_SENSOR("", "Micronova sensor", this); } + MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_SENSOR("", "Micronova sensor", this); + this->dump_base_config(); + } void request_value_from_stove() override { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index 43e5c9d844..006ada92aa 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -7,7 +7,7 @@ from .. import ( CONF_MEMORY_ADDRESS, CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, micronova_ns, @@ -27,8 +27,10 @@ CONFIG_SCHEMA = cv.Schema( icon=ICON_POWER, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x80, default_memory_address=0x21 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x80, + default_memory_address=0x21, + is_polling_component=False, ) ) .extend( diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 81d36adccb..3777b6029d 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -22,7 +22,7 @@ void MicroNovaSwitch::write_state(bool state) { ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", micronova_->get_current_stove_state()); } } - this->micronova_->update(); + this->micronova_->request_update_listeners(); break; default: diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index 7019084355..ab83973ef7 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -9,7 +9,10 @@ namespace esphome::micronova { class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaSwitchListener(m) {} - void dump_config() override { LOG_SWITCH("", "Micronova switch", this); } + void dump_config() override { + LOG_SWITCH("", "Micronova switch", this); + this->dump_base_config(); + } void set_stove_state(bool v) override { this->publish_state(v); } bool get_stove_state() override { return this->state; } diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 474c30e13b..e54b9e280a 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -3,19 +3,19 @@ from esphome.components import text_sensor import esphome.config_validation as cv from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) CONF_STOVE_STATE = "stove_state" MicroNovaTextSensor = micronova_ns.class_( - "MicroNovaTextSensor", text_sensor.TextSensor, cg.Component + "MicroNovaTextSensor", text_sensor.TextSensor, MicroNovaListener ) CONFIG_SCHEMA = cv.Schema( @@ -24,8 +24,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_STOVE_STATE): text_sensor.text_sensor_schema( MicroNovaTextSensor ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x21 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x21, + is_polling_component=True, ) ), } @@ -37,7 +39,6 @@ async def to_code(config): if stove_state_config := config.get(CONF_STOVE_STATE): sens = await text_sensor.new_text_sensor(stove_state_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(stove_state_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(stove_state_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE)) + await to_code_micronova_listener( + mv, sens, stove_state_config, MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE + ) diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 352f049654..7992bdc243 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -5,10 +5,13 @@ namespace esphome::micronova { -class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSensorListener { +class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { public: - MicroNovaTextSensor(MicroNova *m) : MicroNovaSensorListener(m) {} - void dump_config() override { LOG_TEXT_SENSOR("", "Micronova text sensor", this); } + MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_TEXT_SENSOR("", "Micronova text sensor", this); + this->dump_base_config(); + } void request_value_from_stove() override { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } diff --git a/tests/components/micronova/common.yaml b/tests/components/micronova/common.yaml index 3cf8e36fb6..73456aa199 100644 --- a/tests/components/micronova/common.yaml +++ b/tests/components/micronova/common.yaml @@ -16,6 +16,7 @@ number: step: 1 power_level: name: Micronova Power level + update_interval: 10s sensor: - platform: micronova @@ -41,3 +42,9 @@ switch: - platform: micronova stove: name: Stove on/off + +text_sensor: + - platform: micronova + stove_state: + name: Stove status + update_interval: 5s From e36e6fbc3fdb7f6ed8474893503b67b1aa4eb9d2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 01:08:41 +0100 Subject: [PATCH 279/320] [micronova] Move STOVE_STATES to text sensor file as it's used only there (#12349) --- esphome/components/micronova/micronova.h | 12 ------------ .../micronova/text_sensor/micronova_text_sensor.h | 12 ++++++++++++ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 7992bff2d3..c5a5ba4f4e 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -13,18 +13,6 @@ namespace esphome::micronova { static const char *const TAG = "micronova"; static const int STOVE_REPLY_DELAY = 60; -static const std::string STOVE_STATES[11] = {"Off", - "Start", - "Pellets loading", - "Ignition", - "Working", - "Brazier Cleaning", - "Final Cleaning", - "Standby", - "No pellets alarm", - "No ignition alarm", - "Undefined alarm"}; - enum class MicroNovaFunctions { STOVE_FUNCTION_VOID = 0, STOVE_FUNCTION_SWITCH = 1, diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 7992bdc243..290f0ca45a 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -5,6 +5,18 @@ namespace esphome::micronova { +static const char *const STOVE_STATES[11] = {"Off", + "Start", + "Pellets loading", + "Ignition", + "Working", + "Brazier Cleaning", + "Final Cleaning", + "Standby", + "No pellets alarm", + "No ignition alarm", + "Undefined alarm"}; + class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} From c5cc91f6f004120703e8ef3dfe570ce0157db3bc Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 03:02:05 +0100 Subject: [PATCH 280/320] [micronova] Add FINAL_VALIDATE_SCHEMA to validate uart (#12350) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/micronova/__init__.py | 15 +++++++++++++-- tests/components/micronova/test.esp32-idf.yaml | 2 +- tests/components/micronova/test.esp8266-ard.yaml | 2 +- tests/components/micronova/test.rp2040-ard.yaml | 2 +- .../uart_1200_none_2stopbits/esp32-ard.yaml | 13 +++++++++++++ .../uart_1200_none_2stopbits/esp32-c3-ard.yaml | 13 +++++++++++++ .../uart_1200_none_2stopbits/esp32-c3-idf.yaml | 13 +++++++++++++ .../uart_1200_none_2stopbits/esp32-idf.yaml | 13 +++++++++++++ .../uart_1200_none_2stopbits/esp8266-ard.yaml | 13 +++++++++++++ .../uart_1200_none_2stopbits/rp2040-ard.yaml | 13 +++++++++++++ 10 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml create mode 100644 tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml create mode 100644 tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml create mode 100644 tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml create mode 100644 tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 9b01ae97e3..11213e82c5 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -8,13 +8,14 @@ CODEOWNERS = ["@jorre05", "@edenhaus"] DEPENDENCIES = ["uart"] -CONF_MICRONOVA_ID = "micronova_id" +DOMAIN = "micronova" +CONF_MICRONOVA_ID = f"{DOMAIN}_id" CONF_ENABLE_RX_PIN = "enable_rx_pin" CONF_MEMORY_LOCATION = "memory_location" CONF_MEMORY_ADDRESS = "memory_address" DEFAULT_POLLING_INTERVAL = "60s" -micronova_ns = cg.esphome_ns.namespace("micronova") +micronova_ns = cg.esphome_ns.namespace(DOMAIN) MicroNovaFunctions = micronova_ns.enum("MicroNovaFunctions", is_class=True) MICRONOVA_FUNCTIONS_ENUM = { @@ -42,6 +43,16 @@ CONFIG_SCHEMA = cv.Schema( } ).extend(uart.UART_DEVICE_SCHEMA) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=1200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=2, +) + def MICRONOVA_ADDRESS_SCHEMA( *, diff --git a/tests/components/micronova/test.esp32-idf.yaml b/tests/components/micronova/test.esp32-idf.yaml index 5cc3a234ca..b3e4714bc3 100644 --- a/tests/components/micronova/test.esp32-idf.yaml +++ b/tests/components/micronova/test.esp32-idf.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO13 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.esp8266-ard.yaml b/tests/components/micronova/test.esp8266-ard.yaml index ffe1e0a063..04030801e3 100644 --- a/tests/components/micronova/test.esp8266-ard.yaml +++ b/tests/components/micronova/test.esp8266-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO15 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.rp2040-ard.yaml b/tests/components/micronova/test.rp2040-ard.yaml index 6dc030e6b6..67110f25b0 100644 --- a/tests/components/micronova/test.rp2040-ard.yaml +++ b/tests/components/micronova/test.rp2040-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml new file mode 100644 index 0000000000..41ded5a763 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml new file mode 100644 index 0000000000..1eb5d6d5f9 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32-C3 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml new file mode 100644 index 0000000000..5181995a40 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32-C3 IDF tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml new file mode 100644 index 0000000000..122f05aced --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32 IDF tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml new file mode 100644 index 0000000000..3bffabf82d --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP8266 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml new file mode 100644 index 0000000000..fb94939090 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for RP2040 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 From ffb3e2eb0afaf5efe6b6b6d74eff413f80fd7207 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 22:00:04 -0600 Subject: [PATCH 281/320] [wifi] Fix scan timeout loop when scan returns zero networks (#12354) --- esphome/components/wifi/wifi_component.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 317507f242..ff33a81fcf 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1264,8 +1264,8 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } case WiFiRetryPhase::SCAN_CONNECTING: - // If scan found no matching networks, skip to hidden network mode - if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) { + // If scan found no networks or no matching networks, skip to hidden network mode + if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) { return WiFiRetryPhase::RETRY_HIDDEN; } From 159194587b7a7f033d5ad361ed9d89b19a79623e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 22:08:21 -0600 Subject: [PATCH 282/320] [core] Move Color::gradient to cpp to avoid duplicate code (#12348) --- esphome/core/color.cpp | 14 ++++++++++++++ esphome/core/color.h | 14 +++----------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index 7e390b2354..14c41c2b0d 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,4 +6,18 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); +Color Color::gradient(const Color &to_color, uint8_t amnt) { + Color new_color; + float amnt_f = float(amnt) / 255.0f; + new_color.r = amnt_f * (to_color.r - this->r) + this->r; + new_color.g = amnt_f * (to_color.g - this->g) + this->g; + new_color.b = amnt_f * (to_color.b - this->b) + this->b; + new_color.w = amnt_f * (to_color.w - this->w) + this->w; + return new_color; +} + +Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } + +Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } + } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 4b0ae5b57a..32d63b1856 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,17 +174,9 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt) { - Color new_color; - float amnt_f = float(amnt) / 255.0f; - new_color.r = amnt_f * (to_color.r - (*this).r) + (*this).r; - new_color.g = amnt_f * (to_color.g - (*this).g) + (*this).g; - new_color.b = amnt_f * (to_color.b - (*this).b) + (*this).b; - new_color.w = amnt_f * (to_color.w - (*this).w) + (*this).w; - return new_color; - } - Color fade_to_white(uint8_t amnt) { return (*this).gradient(Color::WHITE, amnt); } - Color fade_to_black(uint8_t amnt) { return (*this).gradient(Color::BLACK, amnt); } + Color gradient(const Color &to_color, uint8_t amnt); + Color fade_to_white(uint8_t amnt); + Color fade_to_black(uint8_t amnt); Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } From 93a85d7979f7d0a23c93aa520a7af8c1996da561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 22:08:46 -0600 Subject: [PATCH 283/320] [wifi_signal] Update signal strength immediately on WiFi connect/disconnect (#12347) --- esphome/components/wifi_signal/sensor.py | 3 ++- .../components/wifi_signal/wifi_signal_sensor.cpp | 6 ++---- esphome/components/wifi_signal/wifi_signal_sensor.h | 12 +++++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/wifi_signal/sensor.py b/esphome/components/wifi_signal/sensor.py index 99b51adea0..82cb90c745 100644 --- a/esphome/components/wifi_signal/sensor.py +++ b/esphome/components/wifi_signal/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import sensor +from esphome.components import sensor, wifi import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, @@ -25,5 +25,6 @@ CONFIG_SCHEMA = sensor.sensor_schema( async def to_code(config): + wifi.request_wifi_listeners() var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.cpp b/esphome/components/wifi_signal/wifi_signal_sensor.cpp index 4347295421..11d816a909 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.cpp +++ b/esphome/components/wifi_signal/wifi_signal_sensor.cpp @@ -2,13 +2,11 @@ #ifdef USE_WIFI #include "esphome/core/log.h" -namespace esphome { -namespace wifi_signal { +namespace esphome::wifi_signal { static const char *const TAG = "wifi_signal.sensor"; void WiFiSignalSensor::dump_config() { LOG_SENSOR("", "WiFi Signal", this); } -} // namespace wifi_signal -} // namespace esphome +} // namespace esphome::wifi_signal #endif diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 5cfd19b523..cc951e8dd7 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -5,17 +5,19 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI -namespace esphome { -namespace wifi_signal { +namespace esphome::wifi_signal { -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { public: + void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + // WiFiConnectStateListener interface - update RSSI immediately on connect + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override { this->update(); } }; -} // namespace wifi_signal -} // namespace esphome +} // namespace esphome::wifi_signal #endif From 53ddd1a1cd8a5fc5f73550eadf14c2f5ac6c9c86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 13:43:48 +0100 Subject: [PATCH 284/320] [wifi_signal] Add ifdef guards for clang-tidy compatibility (#12362) --- esphome/components/wifi_signal/wifi_signal_sensor.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index cc951e8dd7..5d7f4b4562 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -7,16 +7,24 @@ #ifdef USE_WIFI namespace esphome::wifi_signal { +#ifdef USE_WIFI_LISTENERS class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +#else +class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +#endif public: +#ifdef USE_WIFI_LISTENERS void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } +#endif void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } +#ifdef USE_WIFI_LISTENERS // WiFiConnectStateListener interface - update RSSI immediately on connect void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override { this->update(); } +#endif }; } // namespace esphome::wifi_signal From 2515f1c0806d1641d8b04b3688f771c145cc4f48 Mon Sep 17 00:00:00 2001 From: Berik Visschers Date: Mon, 8 Dec 2025 14:37:59 +0100 Subject: [PATCH 285/320] Add seeed_xiao_esp32c6 board definition (#12307) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/boards.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 7107874a5b..514d674b55 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1218,6 +1218,28 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + "seeed_xiao_esp32c6": { + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 21, + "D4": 22, + "D5": 23, + "D6": 16, + "D7": 17, + "D8": 19, + "D9": 20, + "D10": 18, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "LED": 8, + "LED_BUILTIN": 8, + "RF_SWITCH_EN": 3, + "RF_ANT_SELECT": 14, + }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, "tinypico": {}, From 95efb3704524cc1e9f9aefefc2b4c189c3f0aca2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 14:39:43 +0100 Subject: [PATCH 286/320] [micronova] Set the write bit automatically (#12318) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/micronova/__init__.py | 6 ++++-- .../components/micronova/button/__init__.py | 2 +- esphome/components/micronova/micronova.cpp | 8 ++++++-- esphome/components/micronova/micronova.h | 1 - .../components/micronova/number/__init__.py | 20 +------------------ .../micronova/number/micronova_number.cpp | 2 +- .../micronova/number/micronova_number.h | 6 ------ .../components/micronova/switch/__init__.py | 2 +- tests/components/micronova/common.yaml | 2 +- 9 files changed, 15 insertions(+), 34 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 11213e82c5..637d0eb168 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -63,12 +63,14 @@ def MICRONOVA_ADDRESS_SCHEMA( schema = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), + # On write requests the write bit (0x80) is added automatically to the location + # Therefore no locations >= 0x80 are allowed cv.Optional( CONF_MEMORY_LOCATION, default=default_memory_location - ): cv.hex_int_range(), + ): cv.hex_int_range(min=0x00, max=0x79), cv.Optional( CONF_MEMORY_ADDRESS, default=default_memory_address - ): cv.hex_int_range(), + ): cv.hex_int_range(min=0x00, max=0xFF), } ) if is_polling_component: diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index dd57c9ec4f..38fee2f561 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -25,7 +25,7 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0xA0, + default_memory_location=0x20, default_memory_address=0x7D, is_polling_component=False, ) diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 7343bc90ba..22daef4fe6 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,6 +3,9 @@ namespace esphome::micronova { +static const int STOVE_REPLY_DELAY = 60; +static const uint8_t WRITE_BIT = 1 << 7; // 0x80 + void MicroNovaBaseListener::dump_base_config() { ESP_LOGCONFIG(TAG, " Memory Location: %02X\n" @@ -125,7 +128,8 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { uint16_t checksum = 0; if (this->reply_pending_mutex_.try_lock()) { - write_data[0] = location; + uint8_t write_location = location | WRITE_BIT; + write_data[0] = write_location; write_data[1] = address; write_data[2] = data; @@ -140,7 +144,7 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { this->enable_rx_pin_->digital_write(false); this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = location; + this->current_transmission_.memory_location = write_location; this->current_transmission_.memory_address = address; this->current_transmission_.reply_pending = true; this->current_transmission_.initiating_listener = nullptr; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index c5a5ba4f4e..a2eee81be8 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -11,7 +11,6 @@ namespace esphome::micronova { static const char *const TAG = "micronova"; -static const int STOVE_REPLY_DELAY = 60; enum class MicroNovaFunctions { STOVE_FUNCTION_VOID = 0, diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index ec994a5aa5..07023e618c 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -17,7 +17,6 @@ ICON_FLASH = "mdi:flash" CONF_THERMOSTAT_TEMPERATURE = "thermostat_temperature" CONF_POWER_LEVEL = "power_level" -CONF_MEMORY_WRITE_LOCATION = "memory_write_location" MicroNovaNumber = micronova_ns.class_( "MicroNovaNumber", number.Number, MicroNovaListener @@ -40,25 +39,18 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( { - cv.Optional( - CONF_MEMORY_WRITE_LOCATION, default=0xA0 - ): cv.hex_int_range(), cv.Optional(CONF_STEP, default=1.0): cv.float_range(min=0.1, max=10.0), } ), cv.Optional(CONF_POWER_LEVEL): number.number_schema( MicroNovaNumber, icon=ICON_FLASH, - ) - .extend( + ).extend( MICRONOVA_ADDRESS_SCHEMA( default_memory_location=0x20, default_memory_address=0x7F, is_polling_component=True, ) - ) - .extend( - {cv.Optional(CONF_MEMORY_WRITE_LOCATION, default=0xA0): cv.hex_int_range()} ), } ) @@ -81,11 +73,6 @@ async def to_code(config): MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, ) cg.add(numb.set_micronova_object(mv)) - cg.add( - numb.set_memory_write_location( - thermostat_temperature_config.get(CONF_MEMORY_WRITE_LOCATION) - ) - ) if power_level_config := config.get(CONF_POWER_LEVEL): numb = await number.new_number( @@ -98,8 +85,3 @@ async def to_code(config): mv, numb, power_level_config, MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL ) cg.add(numb.set_micronova_object(mv)) - cg.add( - numb.set_memory_write_location( - power_level_config.get(CONF_MEMORY_WRITE_LOCATION) - ) - ) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 66e81e98b7..c71d819ad6 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -36,7 +36,7 @@ void MicroNovaNumber::control(float value) { default: break; } - this->micronova_->write_address(this->memory_write_location_, this->memory_address_, new_number); + this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); this->micronova_->request_update_listeners(); } diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index e1545bb35a..391765b730 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -18,12 +18,6 @@ class MicroNovaNumber : public number::Number, public MicroNovaListener { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } void process_value_from_stove(int value_from_stove) override; - - void set_memory_write_location(uint8_t l) { this->memory_write_location_ = l; } - uint8_t get_memory_write_location() { return this->memory_write_location_; } - - protected: - uint8_t memory_write_location_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index 006ada92aa..c6897d8e5c 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0x80, + default_memory_location=0x00, default_memory_address=0x21, is_polling_component=False, ) diff --git a/tests/components/micronova/common.yaml b/tests/components/micronova/common.yaml index 73456aa199..660970350a 100644 --- a/tests/components/micronova/common.yaml +++ b/tests/components/micronova/common.yaml @@ -5,7 +5,7 @@ button: - platform: micronova custom_button: name: Custom Micronova Button - memory_location: 0xA0 + memory_location: 0x20 memory_address: 0x7D memory_data: 0x0F From c7382fc494796dc89f19967c50c61d3c9319e27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 15:07:10 +0100 Subject: [PATCH 287/320] [hlw8032] Single-phase metering IC (#7241) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/hlw8032/__init__.py | 1 + esphome/components/hlw8032/hlw8032.cpp | 194 ++++++++++++++++++ esphome/components/hlw8032/hlw8032.h | 44 ++++ esphome/components/hlw8032/sensor.py | 93 +++++++++ tests/components/hlw8032/common.yaml | 17 ++ tests/components/hlw8032/test.esp32-idf.yaml | 4 + .../components/hlw8032/test.esp8266-ard.yaml | 4 + tests/components/hlw8032/test.rp2040-ard.yaml | 4 + 9 files changed, 362 insertions(+) create mode 100644 esphome/components/hlw8032/__init__.py create mode 100644 esphome/components/hlw8032/hlw8032.cpp create mode 100644 esphome/components/hlw8032/hlw8032.h create mode 100644 esphome/components/hlw8032/sensor.py create mode 100644 tests/components/hlw8032/common.yaml create mode 100644 tests/components/hlw8032/test.esp32-idf.yaml create mode 100644 tests/components/hlw8032/test.esp8266-ard.yaml create mode 100644 tests/components/hlw8032/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 1fb6e111b7..2cd1453e12 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal esphome/components/hlk_fm22x/* @OnFreund +esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter diff --git a/esphome/components/hlw8032/__init__.py b/esphome/components/hlw8032/__init__.py new file mode 100644 index 0000000000..4908e10037 --- /dev/null +++ b/esphome/components/hlw8032/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@rici4kubicek"] diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp new file mode 100644 index 0000000000..55e6664a8b --- /dev/null +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -0,0 +1,194 @@ +#include "hlw8032.h" +#include "esphome/core/log.h" +#include + +namespace esphome::hlw8032 { + +static const char *const TAG = "hlw8032"; + +static constexpr uint8_t STATE_REG_OFFSET = 0; +static constexpr uint8_t VOLTAGE_PARAM_OFFSET = 2; +static constexpr uint8_t VOLTAGE_REG_OFFSET = 5; +static constexpr uint8_t CURRENT_PARAM_OFFSET = 8; +static constexpr uint8_t CURRENT_REG_OFFSET = 11; +static constexpr uint8_t POWER_PARAM_OFFSET = 14; +static constexpr uint8_t POWER_REG_OFFSET = 17; +static constexpr uint8_t DATA_UPDATE_REG_OFFSET = 20; +static constexpr uint8_t CHECKSUM_REG_OFFSET = 23; +static constexpr uint8_t PARAM_REG_USABLE_BIT = (1 << 0); +static constexpr uint8_t POWER_OVERFLOW_BIT = (1 << 1); +static constexpr uint8_t CURRENT_OVERFLOW_BIT = (1 << 2); +static constexpr uint8_t VOLTAGE_OVERFLOW_BIT = (1 << 3); +static constexpr uint8_t HAVE_POWER_BIT = (1 << 4); +static constexpr uint8_t HAVE_CURRENT_BIT = (1 << 5); +static constexpr uint8_t HAVE_VOLTAGE_BIT = (1 << 6); +static constexpr uint8_t CHECK_REG = 0x5A; +static constexpr uint8_t STATE_REG_CORRECTION_FUNC_NORMAL = 0x55; +static constexpr uint8_t STATE_REG_CORRECTION_FUNC_FAIL = 0xAA; +static constexpr uint8_t STATE_REG_CORRECTION_MASK = 0xF0; +static constexpr uint8_t STATE_REG_OVERFLOW_MASK = 0xF; +static constexpr uint8_t PACKET_LENGTH = 24; + +void HLW8032Component::loop() { + while (this->available()) { + uint8_t data = this->read(); + if (!this->header_found_) { + if ((data == STATE_REG_CORRECTION_FUNC_NORMAL) || (data == STATE_REG_CORRECTION_FUNC_FAIL) || + (data & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { + this->header_found_ = true; + this->raw_data_[0] = data; + } + } else if (data == CHECK_REG) { + this->raw_data_[1] = data; + this->raw_data_index_ = 2; + this->check_ = 0; + } else if (this->raw_data_index_ >= 2 && this->raw_data_index_ < PACKET_LENGTH) { + this->raw_data_[this->raw_data_index_++] = data; + if (this->raw_data_index_ < PACKET_LENGTH) { + this->check_ += data; + } else if (this->raw_data_index_ == PACKET_LENGTH) { + if (this->check_ == this->raw_data_[CHECKSUM_REG_OFFSET]) { + this->parse_data_(); + } else { + ESP_LOGW(TAG, "Invalid checksum: 0x%02X != 0x%02X", this->check_, this->raw_data_[CHECKSUM_REG_OFFSET]); + } + this->raw_data_index_ = 0; + this->header_found_ = false; + memset(this->raw_data_, 0, PACKET_LENGTH); + } + } + } +} + +uint32_t HLW8032Component::read_uint24_(uint8_t offset) { + return encode_uint24(this->raw_data_[offset], this->raw_data_[offset + 1], this->raw_data_[offset + 2]); +} + +void HLW8032Component::parse_data_() { + // Parse header + uint8_t state_reg = this->raw_data_[STATE_REG_OFFSET]; + + if (state_reg == STATE_REG_CORRECTION_FUNC_FAIL) { + ESP_LOGE(TAG, "The chip's function of error correction fails."); + return; + } + + // Parse data frame + uint32_t voltage_parameter = this->read_uint24_(VOLTAGE_PARAM_OFFSET); + uint32_t voltage_reg = this->read_uint24_(VOLTAGE_REG_OFFSET); + uint32_t current_parameter = this->read_uint24_(CURRENT_PARAM_OFFSET); + uint32_t current_reg = this->read_uint24_(CURRENT_REG_OFFSET); + uint32_t power_parameter = this->read_uint24_(POWER_PARAM_OFFSET); + uint32_t power_reg = this->read_uint24_(POWER_REG_OFFSET); + uint8_t data_update_register = this->raw_data_[DATA_UPDATE_REG_OFFSET]; + + bool have_power = data_update_register & HAVE_POWER_BIT; + bool have_current = data_update_register & HAVE_CURRENT_BIT; + bool have_voltage = data_update_register & HAVE_VOLTAGE_BIT; + + bool power_cycle_exceeds_range = false; + bool parameter_regs_usable = true; + + if ((state_reg & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { + if (state_reg & STATE_REG_OVERFLOW_MASK) { + if (state_reg & VOLTAGE_OVERFLOW_BIT) { + have_voltage = false; + } + if (state_reg & CURRENT_OVERFLOW_BIT) { + have_current = false; + } + if (state_reg & POWER_OVERFLOW_BIT) { + have_power = false; + } + if (state_reg & PARAM_REG_USABLE_BIT) { + parameter_regs_usable = false; + } + + ESP_LOGW(TAG, + "Reports: (0x%02X)\n" + " Voltage REG overflows: %s\n" + " Current REG overflows: %s\n" + " Power REG overflows: %s\n" + " Voltage/Current/Power Parameter REGs not usable: %s\n", + state_reg, YESNO(!have_voltage), YESNO(!have_current), YESNO(!have_power), + YESNO(!parameter_regs_usable)); + + if (!parameter_regs_usable) { + return; + } + } + power_cycle_exceeds_range = have_power; + } + + ESP_LOGVV(TAG, + "Parsed data:\n" + " Voltage: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Current: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Power: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Data Update: REG 0x%02" PRIX8 "\n", + voltage_parameter, voltage_reg, current_parameter, current_reg, power_parameter, power_reg, + data_update_register); + + const float current_multiplier = 1 / (this->current_resistor_ * 1000); + + float voltage = 0.0f; + if (have_voltage && voltage_reg) { + voltage = float(voltage_parameter) * this->voltage_divider_ / float(voltage_reg); + } + if (this->voltage_sensor_ != nullptr) { + this->voltage_sensor_->publish_state(voltage); + } + + float power = 0.0f; + if (have_power && power_reg && !power_cycle_exceeds_range) { + power = (float(power_parameter) / float(power_reg)) * this->voltage_divider_ * current_multiplier; + } + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + + float current = 0.0f; + if (have_current && current_reg) { + current = float(current_parameter) * current_multiplier / float(current_reg); + } + if (this->current_sensor_ != nullptr) { + this->current_sensor_->publish_state(current); + } + + float pf = NAN; + const float apparent_power = voltage * current; + if (have_voltage && have_current) { + if (have_power || power_cycle_exceeds_range) { + if (apparent_power > 0) { + pf = power / apparent_power; + if (pf < 0 || pf > 1) { + ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf); + pf = NAN; + } + } else if (apparent_power == 0 && power == 0) { + // No load, report ideal power factor + pf = 1.0f; + } + } + } + if (this->apparent_power_sensor_ != nullptr) { + this->apparent_power_sensor_->publish_state(apparent_power); + } + if (this->power_factor_sensor_ != nullptr) { + this->power_factor_sensor_->publish_state(pf); + } +} + +void HLW8032Component::dump_config() { + ESP_LOGCONFIG(TAG, + "Configuration:\n" + " Current resistor: %.1f mΩ\n" + " Voltage Divider: %.3f", + this->current_resistor_ * 1000.0f, this->voltage_divider_); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Power", this->power_sensor_); + LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_); +} +} // namespace esphome::hlw8032 diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h new file mode 100644 index 0000000000..d4c7dbd26c --- /dev/null +++ b/esphome/components/hlw8032/hlw8032.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::hlw8032 { + +class HLW8032Component : public Component, public uart::UARTDevice { + public: + void loop() override; + void dump_config() override; + + void set_current_resistor(float current_resistor) { this->current_resistor_ = current_resistor; } + void set_voltage_divider(float voltage_divider) { this->voltage_divider_ = voltage_divider; } + void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } + void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } + void set_power_sensor(sensor::Sensor *power_sensor) { this->power_sensor_ = power_sensor; } + void set_apparent_power_sensor(sensor::Sensor *apparent_power_sensor) { + this->apparent_power_sensor_ = apparent_power_sensor; + } + void set_power_factor_sensor(sensor::Sensor *power_factor_sensor) { + this->power_factor_sensor_ = power_factor_sensor; + } + + protected: + void parse_data_(); + uint32_t read_uint24_(uint8_t offset); + + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + + float current_resistor_{0.001f}; + float voltage_divider_{1.720f}; + uint8_t raw_data_[24]{}; + uint8_t check_{0}; + uint8_t raw_data_index_{0}; + bool header_found_{false}; +}; + +} // namespace esphome::hlw8032 diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py new file mode 100644 index 0000000000..96800e46f4 --- /dev/null +++ b/esphome/components/hlw8032/sensor.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_APPARENT_POWER, + CONF_CURRENT, + CONF_CURRENT_RESISTOR, + CONF_ID, + CONF_POWER, + CONF_POWER_FACTOR, + CONF_VOLTAGE, + CONF_VOLTAGE_DIVIDER, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) + +DEPENDENCIES = ["uart"] + +hlw8032_ns = cg.esphome_ns.namespace("hlw8032") +HLW8032Component = hlw8032_ns.class_("HLW8032Component", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(HLW8032Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT_RESISTOR, default=0.001): cv.resistance, + cv.Optional(CONF_VOLTAGE_DIVIDER, default=1.720): cv.positive_float, + } +).extend(uart.UART_DEVICE_SCHEMA) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hlw8032", baud_rate=4800, require_rx=True, data_bits=8, parity="EVEN" +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if voltage_config := config.get(CONF_VOLTAGE): + sens = await sensor.new_sensor(voltage_config) + cg.add(var.set_voltage_sensor(sens)) + if current_config := config.get(CONF_CURRENT): + sens = await sensor.new_sensor(current_config) + cg.add(var.set_current_sensor(sens)) + if power_config := config.get(CONF_POWER): + sens = await sensor.new_sensor(power_config) + cg.add(var.set_power_sensor(sens)) + if apparent_power_config := config.get(CONF_APPARENT_POWER): + sens = await sensor.new_sensor(apparent_power_config) + cg.add(var.set_apparent_power_sensor(sens)) + if power_factor_config := config.get(CONF_POWER_FACTOR): + sens = await sensor.new_sensor(power_factor_config) + cg.add(var.set_power_factor_sensor(sens)) + cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) + cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) diff --git a/tests/components/hlw8032/common.yaml b/tests/components/hlw8032/common.yaml new file mode 100644 index 0000000000..1b4e537576 --- /dev/null +++ b/tests/components/hlw8032/common.yaml @@ -0,0 +1,17 @@ +sensor: + - platform: hlw8032 + voltage: + name: HLW8032 Voltage + id: hlw8032_voltage + current: + name: HLW8032 Current + id: hlw8032_current + power: + name: HLW8032 Power + id: hlw8032_power + apparent_power: + name: HLW8032 Apparent Power + id: hlw8032_apparent_power + power_factor: + name: HLW8032 Power Factor + id: hlw8032_power_factor diff --git a/tests/components/hlw8032/test.esp32-idf.yaml b/tests/components/hlw8032/test.esp32-idf.yaml new file mode 100644 index 0000000000..911b867708 --- /dev/null +++ b/tests/components/hlw8032/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hlw8032/test.esp8266-ard.yaml b/tests/components/hlw8032/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9c1c11c6a1 --- /dev/null +++ b/tests/components/hlw8032/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hlw8032/test.rp2040-ard.yaml b/tests/components/hlw8032/test.rp2040-ard.yaml new file mode 100644 index 0000000000..40b6e81bb2 --- /dev/null +++ b/tests/components/hlw8032/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/rp2040-ard.yaml + +<<: !include common.yaml From 4466c4c69fdec95113e8449b4420938373b636be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 15:09:04 +0100 Subject: [PATCH 288/320] [libretiny] Fix WiFi scan timeout loop when scan fails (#12356) --- esphome/components/wifi/wifi_component_libretiny.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 1a6f037a87..d6bc8e53da 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -445,6 +445,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } void WiFiComponent::wifi_scan_done_callback_() { this->scan_result_.clear(); + this->scan_done_ = true; int16_t num = WiFi.scanComplete(); if (num < 0) @@ -463,7 +464,6 @@ void WiFiComponent::wifi_scan_done_callback_() { ssid.length() == 0); } WiFi.scanDelete(); - this->scan_done_ = true; #ifdef USE_WIFI_LISTENERS for (auto *listener : this->scan_results_listeners_) { listener->on_wifi_scan_results(this->scan_result_); From 5144154f911f6ac06a7017f0b47b26d024d9e2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Blanchet?= <120399978+arno1801@users.noreply.github.com> Date: Mon, 8 Dec 2025 09:31:05 -0500 Subject: [PATCH 289/320] [hub75] fix id conflict (#12365) --- tests/components/hub75/test.esp32-idf.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/hub75/test.esp32-idf.yaml b/tests/components/hub75/test.esp32-idf.yaml index c275d24187..9f6bd57292 100644 --- a/tests/components/hub75/test.esp32-idf.yaml +++ b/tests/components/hub75/test.esp32-idf.yaml @@ -25,15 +25,15 @@ display: oe_pin: GPIO15 clk_pin: GPIO16 pages: - - id: page1 + - id: page1_hub75 lambda: |- it.rectangle(0, 0, it.get_width(), it.get_height()); - - id: page2 + - id: page2_hub75 lambda: |- it.rectangle(0, 0, it.get_width(), it.get_height()); on_page_change: - from: page1 - to: page2 + from: page1_hub75 + to: page2_hub75 then: lambda: |- ESP_LOGD("display", "1 -> 2"); From eda743ee481a75424933e35ed591dc1d933198de Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 8 Dec 2025 08:50:23 -0600 Subject: [PATCH 290/320] [usb_cdc_acm] New component (#11687) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/usb_cdc_acm/__init__.py | 76 +++ .../components/usb_cdc_acm/usb_cdc_acm.cpp | 495 ++++++++++++++++++ esphome/components/usb_cdc_acm/usb_cdc_acm.h | 135 +++++ .../usb_cdc_acm/test.esp32-p4-idf.yaml | 5 + .../usb_cdc_acm/test.esp32-s2-idf.yaml | 5 + .../usb_cdc_acm/test.esp32-s3-idf.yaml | 6 + .../usb_cdc_acm/tinyusb_common.yaml | 8 + 8 files changed, 731 insertions(+) create mode 100644 esphome/components/usb_cdc_acm/__init__.py create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm.cpp create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm.h create mode 100644 tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml create mode 100644 tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml create mode 100644 tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml create mode 100644 tests/components/usb_cdc_acm/tinyusb_common.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2cd1453e12..af926d2d61 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -524,6 +524,7 @@ esphome/components/ufire_ise/* @pvizeli esphome/components/ultrasonic/* @OttoWinter esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_host/* @clydebarrow esphome/components/usb_uart/* @clydebarrow esphome/components/valve/* @esphome/core diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py new file mode 100644 index 0000000000..6693d8e75e --- /dev/null +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -0,0 +1,76 @@ +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.esp32 import ( + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + add_idf_sdkconfig_option, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_RX_BUFFER_SIZE, CONF_TX_BUFFER_SIZE +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +AUTO_LOAD = ["uart"] +DEPENDENCIES = ["tinyusb"] + +CONF_INTERFACES = "interfaces" + +usb_cdc_acm_ns = cg.esphome_ns.namespace("usb_cdc_acm") +USBCDCACMComponent = usb_cdc_acm_ns.class_("USBCDCACMComponent", cg.Component) +USBCDCACMInstance = usb_cdc_acm_ns.class_( + "USBCDCACMInstance", uart.UARTComponent, cg.Parented.template(USBCDCACMComponent) +) + + +# Schema for individual CDC ACM interface instances +INTERFACE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + } +) + +# Main component schema +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBCDCACMComponent), + cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.All( + cv.validate_bytes, cv.uint16_t + ), + cv.Optional(CONF_TX_BUFFER_SIZE, default=256): cv.All( + cv.validate_bytes, cv.uint16_t + ), + cv.Optional(CONF_INTERFACES, default=[{}]): cv.All( + cv.ensure_list(INTERFACE_SCHEMA), + cv.Length(min=1, max=2), # At least 1, at most 2 interfaces + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # Create and register interface instances + for interface_index, interface_conf in enumerate(config[CONF_INTERFACES]): + interface = cg.new_Pvariable(interface_conf[CONF_ID]) + await cg.register_parented(interface, var) + cg.add(interface.set_interface_number(interface_index)) + cg.add(var.add_interface(interface)) + + # Configure TinyUSB with the correct number of CDC interfaces + num_interfaces = len(config[CONF_INTERFACES]) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] + ) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] + ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp new file mode 100644 index 0000000000..1cf614286f --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -0,0 +1,495 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_cdc_acm.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/ringbuf.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "tusb.h" +#include "tusb_cdc_acm.h" + +namespace esphome::usb_cdc_acm { + +static const char *TAG = "usb_cdc_acm"; + +static constexpr size_t USB_TX_TASK_STACK_SIZE = 4096; +static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; + +// Global component instance for managing USB device +USBCDCACMComponent *global_usb_cdc_component = nullptr; + +static USBCDCACMInstance *get_instance_by_itf(int itf) { + if (global_usb_cdc_component == nullptr) { + return nullptr; + } + return global_usb_cdc_component->get_interface_by_number(itf); +} + +static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "RX callback: invalid interface %d", itf); + return; + } + + size_t rx_size = 0; + static uint8_t rx_buf[CONFIG_TINYUSB_CDC_RX_BUFSIZE] = {0}; + + // read from USB + esp_err_t ret = + tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); + ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); + ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty(rx_buf, rx_size).c_str()); + + if (ret == ESP_OK && rx_size > 0) { + RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); + if (rx_ringbuf != nullptr) { + BaseType_t send_res = xRingbufferSend(rx_ringbuf, rx_buf, rx_size, 0); + if (send_res != pdTRUE) { + ESP_LOGE(TAG, "USB RX itf=%d: buffer full, %u bytes lost", itf, rx_size); + } else { + ESP_LOGV(TAG, "USB RX itf=%d: queued %u bytes", itf, rx_size); + } + } + } +} + +static void tinyusb_cdc_line_state_changed_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "Line state callback: invalid interface %d", itf); + return; + } + + int dtr = event->line_state_changed_data.dtr; + int rts = event->line_state_changed_data.rts; + ESP_LOGV(TAG, "Line state itf=%d: DTR=%d, RTS=%d", itf, dtr, rts); + + // Queue event for processing in main loop + instance->queue_line_state_event(dtr != 0, rts != 0); +} + +static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "Line coding callback: invalid interface %d", itf); + return; + } + + uint32_t bit_rate = event->line_coding_changed_data.p_line_coding->bit_rate; + uint8_t stop_bits = event->line_coding_changed_data.p_line_coding->stop_bits; + uint8_t parity = event->line_coding_changed_data.p_line_coding->parity; + uint8_t data_bits = event->line_coding_changed_data.p_line_coding->data_bits; + ESP_LOGV(TAG, "Line coding itf=%d: bit_rate=%" PRIu32 " stop_bits=%u parity=%u data_bits=%u", itf, bit_rate, + stop_bits, parity, data_bits); + + // Queue event for processing in main loop + instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); +} + +static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t xTicksToWait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, xTicksToWait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + + return ESP_OK; +} + +//============================================================================== +// USBCDCACMInstance Implementation +//============================================================================== + +void USBCDCACMInstance::setup() { + this->usb_tx_ringbuf_ = xRingbufferCreate(CONFIG_TINYUSB_CDC_TX_BUFSIZE, RINGBUF_TYPE_BYTEBUF); + if (this->usb_tx_ringbuf_ == nullptr) { + ESP_LOGE(TAG, "USB TX buffer creation error for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } + + this->usb_rx_ringbuf_ = xRingbufferCreate(CONFIG_TINYUSB_CDC_RX_BUFSIZE, RINGBUF_TYPE_BYTEBUF); + if (this->usb_rx_ringbuf_ == nullptr) { + ESP_LOGE(TAG, "USB RX buffer creation error for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } + + // Configure this CDC interface + const tinyusb_config_cdcacm_t acm_cfg = { + .usb_dev = TINYUSB_USBDEV_0, + .cdc_port = this->itf_, + .callback_rx = &tinyusb_cdc_rx_callback, + .callback_rx_wanted_char = NULL, + .callback_line_state_changed = &tinyusb_cdc_line_state_changed_callback, + .callback_line_coding_changed = &tinyusb_cdc_line_coding_changed_callback, + }; + + esp_err_t result = tusb_cdc_acm_init(&acm_cfg); + if (result != ESP_OK) { + ESP_LOGE(TAG, "tusb_cdc_acm_init failed: %d", result); + this->parent_->mark_failed(); + return; + } + + // Use a larger stack size for (very) verbose logging + const size_t stack_size = esp_log_level_get(TAG) > ESP_LOG_DEBUG ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE; + + // Create a simple, unique task name per interface + char task_name[] = "usb_tx_0"; + task_name[sizeof(task_name) - 1] = format_hex_char(static_cast(this->itf_)); + xTaskCreate(usb_tx_task_fn, task_name, stack_size, this, 4, &this->usb_tx_task_handle_); + + if (this->usb_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create USB TX task for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } +} + +void USBCDCACMInstance::loop() { + // Process events from the lock-free queue + this->process_events_(); +} + +void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { + // Allocate event from pool + CDCEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + ESP_LOGW(TAG, "Event pool exhausted, line state event dropped (itf=%d)", this->itf_); + return; + } + + event->type = CDC_EVENT_LINE_STATE_CHANGED; + event->data.line_state.dtr = dtr; + event->data.line_state.rts = rts; + + if (!this->event_queue_.push(event)) { + ESP_LOGW(TAG, "Event queue full, line state event dropped (itf=%d)", this->itf_); + // Return event to pool since we couldn't queue it + this->event_pool_.release(event); + } else { + // Wake main loop immediately to process event +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } +} + +void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, + uint8_t data_bits) { + // Allocate event from pool + CDCEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + ESP_LOGW(TAG, "Event pool exhausted, line coding event dropped (itf=%d)", this->itf_); + return; + } + + event->type = CDC_EVENT_LINE_CODING_CHANGED; + event->data.line_coding.bit_rate = bit_rate; + event->data.line_coding.stop_bits = stop_bits; + event->data.line_coding.parity = parity; + event->data.line_coding.data_bits = data_bits; + + if (!this->event_queue_.push(event)) { + ESP_LOGW(TAG, "Event queue full, line coding event dropped (itf=%d)", this->itf_); + // Return event to pool since we couldn't queue it + this->event_pool_.release(event); + } else { + // Wake main loop immediately to process event +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } +} + +void USBCDCACMInstance::process_events_() { + // Process all pending events from the queue + CDCEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + switch (event->type) { + case CDC_EVENT_LINE_STATE_CHANGED: { + bool dtr = event->data.line_state.dtr; + bool rts = event->data.line_state.rts; + + // Invoke user callback in main loop context + if (this->line_state_callback_ != nullptr) { + this->line_state_callback_(dtr, rts); + } + break; + } + case CDC_EVENT_LINE_CODING_CHANGED: { + uint32_t bit_rate = event->data.line_coding.bit_rate; + uint8_t stop_bits = event->data.line_coding.stop_bits; + uint8_t parity = event->data.line_coding.parity; + uint8_t data_bits = event->data.line_coding.data_bits; + + // Update UART configuration based on CDC line coding + this->baud_rate_ = bit_rate; + this->data_bits_ = data_bits; + + // Convert CDC stop bits to UART stop bits format + // CDC: 0=1 stop bit, 1=1.5 stop bits, 2=2 stop bits + this->stop_bits_ = (stop_bits == 0) ? 1 : (stop_bits == 1) ? 1 : 2; + + // Convert CDC parity to UART parity format + // CDC: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + switch (parity) { + case 0: + this->parity_ = uart::UART_CONFIG_PARITY_NONE; + break; + case 1: + this->parity_ = uart::UART_CONFIG_PARITY_ODD; + break; + case 2: + this->parity_ = uart::UART_CONFIG_PARITY_EVEN; + break; + default: + // Mark and Space parity are not commonly supported, default to None + this->parity_ = uart::UART_CONFIG_PARITY_NONE; + break; + } + + // Invoke user callback in main loop context + if (this->line_coding_callback_ != nullptr) { + this->line_coding_callback_(bit_rate, stop_bits, parity, data_bits); + } + break; + } + } + // Return event to pool for reuse + this->event_pool_.release(event); + } +} + +void USBCDCACMInstance::usb_tx_task_fn(void *arg) { + auto *instance = static_cast(arg); + instance->usb_tx_task(); +} + +void USBCDCACMInstance::usb_tx_task() { + uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + size_t tx_data_size = 0; + + while (1) { + // Wait for a notification from the bridge component + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + // When we do wake up, we can be sure there is data in the ring buffer + esp_err_t ret = ringbuf_read_bytes(this->usb_tx_ringbuf_, data, CONFIG_TINYUSB_CDC_TX_BUFSIZE, &tx_data_size, 0); + + if (ret != ESP_OK) { + ESP_LOGE(TAG, "USB TX itf=%d: RingBuf read failed", this->itf_); + continue; + } else if (tx_data_size == 0) { + ESP_LOGD(TAG, "USB TX itf=%d: RingBuf empty, skipping", this->itf_); + continue; + } + + ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); + ESP_LOGVV(TAG, "data = %s", format_hex_pretty(data, tx_data_size).c_str()); + + // Serial data will be split up into 64 byte chunks to be sent over USB so this + // usually will take multiple iterations + uint8_t *data_head = &data[0]; + + while (tx_data_size > 0) { + size_t queued = tinyusb_cdcacm_write_queue(this->itf_, data_head, tx_data_size); + ESP_LOGV(TAG, "USB TX itf=%d: enqueued: size=%d, queued=%u", this->itf_, tx_data_size, queued); + + tx_data_size -= queued; + data_head += queued; + + ESP_LOGV(TAG, "USB TX itf=%d: waiting 10ms for flush", this->itf_); + esp_err_t flush_ret = tinyusb_cdcacm_write_flush(this->itf_, pdMS_TO_TICKS(10)); + + if (flush_ret != ESP_OK) { + ESP_LOGE(TAG, "USB TX itf=%d: flush failed", this->itf_); + tud_cdc_n_write_clear(this->itf_); + break; + } + } + } +} + +//============================================================================== +// UARTComponent Interface Implementation +//============================================================================== + +void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { + if (len == 0) { + return; + } + + // Write data to TX ring buffer + BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); + if (send_res != pdTRUE) { + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %u bytes dropped", this->itf_, len); + return; + } + + // Notify TX task that data is available + if (this->usb_tx_task_handle_ != nullptr) { + xTaskNotifyGive(this->usb_tx_task_handle_); + } +} + +bool USBCDCACMInstance::peek_byte(uint8_t *data) { + if (this->has_peek_) { + *data = this->peek_buffer_; + return true; + } + + if (this->read_byte(&this->peek_buffer_)) { + *data = this->peek_buffer_; + this->has_peek_ = true; + return true; + } + + return false; +} + +bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return true; + } + + size_t original_len = len; + size_t bytes_read = 0; + + // First, use the peek buffer if available + if (this->has_peek_) { + data[0] = this->peek_buffer_; + this->has_peek_ = false; + bytes_read = 1; + data++; + if (--len == 0) { // Decrement len first, then check it... + return true; // No more to read + } + } + + // Read remaining bytes from RX ring buffer + size_t rx_size = 0; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); + if (buf == nullptr) { + return false; + } + + memcpy(data, buf, rx_size); + vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + bytes_read += rx_size; + data += rx_size; + len -= rx_size; + if (len == 0) { + return true; // No more to read + } + + // Buffer's data may wrap around, in which case we should perform another read + buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); + if (buf == nullptr) { + return false; + } + + memcpy(data, buf, rx_size); + vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + bytes_read += rx_size; + + return bytes_read == original_len; +} + +int USBCDCACMInstance::available() { + UBaseType_t waiting = 0; + if (this->usb_rx_ringbuf_ != nullptr) { + vRingbufferGetInfo(this->usb_rx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + } + return static_cast(waiting) + (this->has_peek_ ? 1 : 0); +} + +void USBCDCACMInstance::flush() { + // Wait for TX ring buffer to be empty + if (this->usb_tx_ringbuf_ == nullptr) { + return; + } + + UBaseType_t waiting = 1; + while (waiting > 0) { + vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + if (waiting > 0) { + vTaskDelay(pdMS_TO_TICKS(1)); + } + } + + // Also wait for USB to finish transmitting + tinyusb_cdcacm_write_flush(this->itf_, pdMS_TO_TICKS(100)); +} + +//============================================================================== +// USBCDCACMComponent Implementation +//============================================================================== + +USBCDCACMComponent::USBCDCACMComponent() { global_usb_cdc_component = this; } + +void USBCDCACMComponent::setup() { + // Setup all registered interfaces + for (auto interface : this->interfaces_) { + if (interface != nullptr) { + interface->setup(); + } + } +} + +void USBCDCACMComponent::loop() { + // Call loop() on all registered interfaces to process events + for (auto interface : this->interfaces_) { + if (interface != nullptr) { + interface->loop(); + } + } +} + +void USBCDCACMComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "USB CDC-ACM:\n" + " Number of Interfaces: %d", + this->interfaces_[MAX_USB_CDC_INSTANCES - 1] != nullptr ? MAX_USB_CDC_INSTANCES : 1); +} + +void USBCDCACMComponent::add_interface(USBCDCACMInstance *interface) { + uint8_t itf_num = static_cast(interface->get_itf()); + if (itf_num < MAX_USB_CDC_INSTANCES) { + this->interfaces_[itf_num] = interface; + } else { + ESP_LOGE(TAG, "Interface number must be less than %u", MAX_USB_CDC_INSTANCES); + } +} + +USBCDCACMInstance *USBCDCACMComponent::get_interface_by_number(uint8_t itf) { + for (auto interface : this->interfaces_) { + if ((interface != nullptr) && (interface->get_itf() == static_cast(itf))) { + return interface; + } + } + return nullptr; +} + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h new file mode 100644 index 0000000000..8c00f5d52f --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -0,0 +1,135 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "esphome/components/uart/uart_component.h" + +#include +#include "freertos/ringbuf.h" +#include "tusb_cdc_acm.h" + +namespace esphome::usb_cdc_acm { + +static const uint8_t EVENT_QUEUE_SIZE = 12; +static const uint8_t MAX_USB_CDC_INSTANCES = 2; + +// Callback types for line coding and line state changes +using LineCodingCallback = std::function; +using LineStateCallback = std::function; + +// Event types +enum CDCEventType : uint8_t { + CDC_EVENT_LINE_STATE_CHANGED, + CDC_EVENT_LINE_CODING_CHANGED, +}; + +// Event structure for the queue +struct CDCEvent { + CDCEventType type; + union { + struct { + bool dtr; + bool rts; + } line_state; + struct { + uint32_t bit_rate; + uint8_t stop_bits; + uint8_t parity; + uint8_t data_bits; + } line_coding; + } data; + + // Required by EventPool - called before returning to pool + void release() { + // No dynamic memory to clean up, data is stored inline + } +}; + +// Forward declaration +class USBCDCACMComponent; + +/// Represents a single CDC ACM interface instance +class USBCDCACMInstance : public uart::UARTComponent, public Parented { + public: + void set_interface_number(uint8_t itf) { this->itf_ = static_cast(itf); } + + void setup(); + void loop(); + + // Get the CDC port number for this instance + tinyusb_cdcacm_itf_t get_itf() const { return this->itf_; } + + // Ring buffer accessors for bridge components + RingbufHandle_t get_tx_ringbuf() const { return this->usb_tx_ringbuf_; } + RingbufHandle_t get_rx_ringbuf() const { return this->usb_rx_ringbuf_; } + + // Task handle accessor for notifying TX task + TaskHandle_t get_tx_task_handle() const { return this->usb_tx_task_handle_; } + + // Callback registration for line coding and line state changes + void set_line_coding_callback(LineCodingCallback callback) { this->line_coding_callback_ = std::move(callback); } + void set_line_state_callback(LineStateCallback callback) { this->line_state_callback_ = std::move(callback); } + + // Called from TinyUSB task context (SPSC producer) - queues event for processing in main loop + void queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, uint8_t data_bits); + void queue_line_state_event(bool dtr, bool rts); + + static void usb_tx_task_fn(void *arg); + void usb_tx_task(); + + // UARTComponent interface implementation + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + int available() override; + void flush() override; + + protected: + void check_logger_conflict() override {} + + // Process queued events and invoke callbacks (called from main loop) + void process_events_(); + + TaskHandle_t usb_tx_task_handle_{nullptr}; + tinyusb_cdcacm_itf_t itf_{TINYUSB_CDC_ACM_0}; + + RingbufHandle_t usb_tx_ringbuf_{nullptr}; + RingbufHandle_t usb_rx_ringbuf_{nullptr}; + + // User-registered callbacks (called from main loop) + LineCodingCallback line_coding_callback_{nullptr}; + LineStateCallback line_state_callback_{nullptr}; + + // Lock-free queue and event pool for cross-task event passing + EventPool event_pool_; + LockFreeQueue event_queue_; + + // RX buffer for peek functionality + uint8_t peek_buffer_{0}; + bool has_peek_{false}; +}; + +/// Main USB CDC ACM component that manages the USB device and all CDC interfaces +class USBCDCACMComponent : public Component { + public: + USBCDCACMComponent(); + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + // Interface management + void add_interface(USBCDCACMInstance *interface); + USBCDCACMInstance *get_interface_by_number(uint8_t itf); + + protected: + std::array interfaces_{nullptr, nullptr}; +}; + +extern USBCDCACMComponent *global_usb_cdc_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..4786c96bcc --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml @@ -0,0 +1,5 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..f159b38ff6 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..6913fe21d5 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml @@ -0,0 +1,6 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + - id: usb_cdc_acm2 diff --git a/tests/components/usb_cdc_acm/tinyusb_common.yaml b/tests/components/usb_cdc_acm/tinyusb_common.yaml new file mode 100644 index 0000000000..cb3f48836a --- /dev/null +++ b/tests/components/usb_cdc_acm/tinyusb_common.yaml @@ -0,0 +1,8 @@ +tinyusb: + id: tinyusb_test + usb_lang_id: 0x0123 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_serial_str: ESPHomeTestSerialNumber + usb_vendor_id: 0x2345 From 7e486b1c259b5a1304160ebb716cbdc626fe7b10 Mon Sep 17 00:00:00 2001 From: Johannes Nau Date: Mon, 8 Dec 2025 16:34:26 +0100 Subject: [PATCH 291/320] [pca9685] Allow to disable the phase balancer for PCA9685 (#9792) --- esphome/components/pca9685/__init__.py | 17 ++++++++++++++++- esphome/components/pca9685/pca9685_output.cpp | 13 ++++++++++++- esphome/components/pca9685/pca9685_output.h | 7 +++++++ tests/components/pca9685/common.yaml | 1 + 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 50f58cdfb9..56101c2d62 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_EXTERNAL_CLOCK_INPUT, CONF_FREQUENCY, CONF_ID +from esphome.const import ( + CONF_EXTERNAL_CLOCK_INPUT, + CONF_FREQUENCY, + CONF_ID, + CONF_PHASE_BALANCER, +) DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -9,6 +14,12 @@ MULTI_CONF = True pca9685_ns = cg.esphome_ns.namespace("pca9685") PCA9685Output = pca9685_ns.class_("PCA9685Output", cg.Component, i2c.I2CDevice) +phase_balancer = pca9685_ns.enum("PhaseBalancer", is_class=True) +PHASE_BALANCERS = { + "none": phase_balancer.NONE, + "linear": phase_balancer.LINEAR, +} + def validate_frequency(config): if config[CONF_EXTERNAL_CLOCK_INPUT]: @@ -30,6 +41,9 @@ CONFIG_SCHEMA = cv.All( cv.frequency, cv.Range(min=23.84, max=1525.88) ), cv.Optional(CONF_EXTERNAL_CLOCK_INPUT, default=False): cv.boolean, + cv.Optional(CONF_PHASE_BALANCER, default="linear"): cv.enum( + PHASE_BALANCERS + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -43,5 +57,6 @@ async def to_code(config): if CONF_FREQUENCY in config: cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add(var.set_extclk(config[CONF_EXTERNAL_CLOCK_INPUT])) + cg.add(var.set_phase_balancer(config[CONF_PHASE_BALANCER])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index 6df708ac84..77e3d5a6c6 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -105,7 +105,18 @@ void PCA9685Output::loop() { const uint16_t num_channels = this->max_channel_ - this->min_channel_ + 1; const uint16_t phase_delta_begin = 4096 / num_channels; for (uint8_t channel = this->min_channel_; channel <= this->max_channel_; channel++) { - uint16_t phase_begin = (channel - this->min_channel_) * phase_delta_begin; + uint16_t phase_begin; + switch (this->balancer_) { + case PhaseBalancer::NONE: + phase_begin = 0; + break; + case PhaseBalancer::LINEAR: + phase_begin = (channel - this->min_channel_) * phase_delta_begin; + break; + default: + ESP_LOGE(TAG, "Unknown phase balancer %d", static_cast(this->balancer_)); + return; + } uint16_t phase_end; uint16_t amount = this->pwm_amounts_[channel]; if (amount == 0) { diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 8e547d0032..288c923d4c 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -7,6 +7,11 @@ namespace esphome { namespace pca9685 { +enum class PhaseBalancer { + NONE = 0x00, + LINEAR = 0x01, +}; + /// Inverts polarity of channel output signal extern const uint8_t PCA9685_MODE_INVERTED; /// Channel update happens upon ACK (post-set) rather than on STOP (endTransmission) @@ -47,6 +52,7 @@ class PCA9685Output : public Component, public i2c::I2CDevice { void loop() override; void set_extclk(bool extclk) { this->extclk_ = extclk; } void set_frequency(float frequency) { this->frequency_ = frequency; } + void set_phase_balancer(PhaseBalancer balancer) { this->balancer_ = balancer; } protected: friend PCA9685Channel; @@ -60,6 +66,7 @@ class PCA9685Output : public Component, public i2c::I2CDevice { float frequency_; uint8_t mode_; bool extclk_ = false; + PhaseBalancer balancer_ = PhaseBalancer::LINEAR; uint8_t min_channel_{0xFF}; uint8_t max_channel_{0x00}; diff --git a/tests/components/pca9685/common.yaml b/tests/components/pca9685/common.yaml index 2e238b481c..9e2de6257a 100644 --- a/tests/components/pca9685/common.yaml +++ b/tests/components/pca9685/common.yaml @@ -2,6 +2,7 @@ pca9685: i2c_id: i2c_bus frequency: 500 address: 0x0 + phase_balancer: linear output: - platform: pca9685 From d635892ecf672c9077aa872f6acf03bf61b9aa26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 16:36:13 +0100 Subject: [PATCH 292/320] [core] Use StringRef for get_comment and get_compilation_time to avoid allocations (#12219) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/components/sen5x/sen5x.cpp | 2 +- esphome/components/sgp30/sgp30.cpp | 2 +- esphome/components/sgp4x/sgp4x.cpp | 2 +- esphome/components/version/version_text_sensor.cpp | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/core/application.h | 2 ++ esphome/core/string_ref.h | 11 +++++++++++ 9 files changed, 20 insertions(+), 7 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 1cd818964e..5d2bedae79 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,7 +154,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time() + ")"; + device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time_ref() + ")"; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 3298a5b8db..ffb9e2bc02 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -157,7 +157,7 @@ void SEN5XComponent::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(combined_serial)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(combined_serial)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 9e8d6b332c..fa548ce94e 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -75,7 +75,7 @@ void SGP30Component::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(this->serial_number_)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->store_baseline_ && this->pref_.load(&this->baselines_storage_)) { diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 99d88006f7..a0c957d608 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -59,7 +59,7 @@ void SGP4xComponent::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(this->serial_number_)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 65dbfd27cf..78d0fb501b 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -13,7 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time().c_str())); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time_ref().c_str())); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ca3aa21a95..0c22c2f08d 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -287,7 +287,7 @@ std::string WebServer::get_config_json() { JsonObject root = builder.root(); root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root[ESPHOME_F("comment")] = App.get_comment(); + root[ESPHOME_F("comment")] = App.get_comment_ref(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ff33a81fcf..d46916bfd9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -360,7 +360,7 @@ void WiFiComponent::start() { get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); - uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL; + uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time_ref().c_str()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 14e800342e..8e2035b7c5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -256,6 +256,8 @@ class Application { /// Get the comment of this Application set by pre_setup(). std::string get_comment() const { return this->comment_; } + /// Get the comment as StringRef (avoids allocation) + StringRef get_comment_ref() const { return StringRef(this->comment_); } bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index efaa17181d..505fdd906a 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -128,6 +128,17 @@ inline std::string operator+(const StringRef &lhs, const char *rhs) { return str; } +inline std::string operator+(const StringRef &lhs, const std::string &rhs) { + auto str = lhs.str(); + str.append(rhs); + return str; +} + +inline std::string operator+(const std::string &lhs, const StringRef &rhs) { + std::string str(lhs); + str.append(rhs.c_str(), rhs.size()); + return str; +} #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } From 801d1135ab750ae6b88425786da598170d845d10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 16:37:51 +0100 Subject: [PATCH 293/320] [select] Add zero-copy support for API select commands (#12329) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 7 +++++-- esphome/components/api/api_pb2.h | 5 +++-- esphome/components/api/api_pb2_dump.cpp | 4 +++- esphome/components/select/select.cpp | 6 ++---- esphome/components/select/select.h | 5 +++-- esphome/components/select/select_call.cpp | 10 +++------- esphome/components/select/select_call.h | 10 ++++++---- 9 files changed, 27 insertions(+), 24 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3fc2e1fed8..2534ad0b1f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1195,7 +1195,7 @@ message SelectCommandRequest { option (base_class) = "CommandProtoMessage"; fixed32 key = 1; - string state = 2; + string state = 2 [(pointer_to_buffer) = true]; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f0428546de..18d80c46df 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -902,7 +902,7 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * } void APIConnection::select_command(const SelectCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(select::Select, select, select) - call.set_option(msg.state); + call.set_option(reinterpret_cast(msg.state), msg.state_len); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a3da6591f4..128f82fe7f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1569,9 +1569,12 @@ bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->state = value.as_string(); + case 2: { + // Use raw data directly to avoid allocation + this->state = value.data(); + this->state_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e41cd8a22..49f1ea3c52 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1604,11 +1604,12 @@ class SelectStateResponse final : public StateResponseProtoMessage { class SelectCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 54; - static constexpr uint8_t ESTIMATED_SIZE = 18; + static constexpr uint8_t ESTIMATED_SIZE = 28; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "select_command_request"; } #endif - std::string state{}; + const uint8_t *state{nullptr}; + uint16_t state_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 59fc1367fe..ca69d1ff00 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1453,7 +1453,9 @@ void SelectStateResponse::dump_to(std::string &out) const { void SelectCommandRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "SelectCommandRequest"); dump_field(out, "key", this->key); - dump_field(out, "state", this->state); + out.append(" state: "); + out.append(format_hex_pretty(this->state, this->state_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 3ec413f167..4fc4d79b08 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -56,12 +56,10 @@ size_t Select::size() const { return options.size(); } -optional Select::index_of(const std::string &option) const { return this->index_of(option.c_str()); } - -optional Select::index_of(const char *option) const { +optional Select::index_of(const char *option, size_t len) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { - if (strcmp(options[i], option) == 0) { + if (strncmp(options[i], option, len) == 0 && options[i][len] == '\0') { return i; } } diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index c4d7412d50..63707f6bd6 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -62,8 +62,9 @@ class Select : public EntityBase { size_t size() const; /// Find the (optional) index offset of the provided option value. - optional index_of(const std::string &option) const; - optional index_of(const char *option) const; + optional index_of(const char *option, size_t len) const; + optional index_of(const std::string &option) const { return this->index_of(option.data(), option.size()); } + optional index_of(const char *option) const { return this->index_of(option, strlen(option)); } /// Return the (optional) index offset of the currently active option. optional active_index() const; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index aecfed0d64..2ff99c961d 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -6,9 +6,7 @@ namespace esphome::select { static const char *const TAG = "select"; -SelectCall &SelectCall::set_option(const std::string &option) { return this->with_option(option); } - -SelectCall &SelectCall::set_option(const char *option) { return this->with_option(option); } +SelectCall &SelectCall::set_option(const char *option, size_t len) { return this->with_option(option, len); } SelectCall &SelectCall::set_index(size_t index) { return this->with_index(index); } @@ -32,12 +30,10 @@ SelectCall &SelectCall::with_cycle(bool cycle) { return *this; } -SelectCall &SelectCall::with_option(const std::string &option) { return this->with_option(option.c_str()); } - -SelectCall &SelectCall::with_option(const char *option) { +SelectCall &SelectCall::with_option(const char *option, size_t len) { this->operation_ = SELECT_OP_SET; // Find the option index - this validates the option exists - this->index_ = this->parent_->index_of(option); + this->index_ = this->parent_->index_of(option, len); return *this; } diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index b31d890ef6..c9abbc69a0 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -20,8 +20,9 @@ class SelectCall { explicit SelectCall(Select *parent) : parent_(parent) {} void perform(); - SelectCall &set_option(const std::string &option); - SelectCall &set_option(const char *option); + SelectCall &set_option(const char *option, size_t len); + SelectCall &set_option(const std::string &option) { return this->set_option(option.data(), option.size()); } + SelectCall &set_option(const char *option) { return this->set_option(option, strlen(option)); } SelectCall &set_index(size_t index); SelectCall &select_next(bool cycle); @@ -31,8 +32,9 @@ class SelectCall { SelectCall &with_operation(SelectOperation operation); SelectCall &with_cycle(bool cycle); - SelectCall &with_option(const std::string &option); - SelectCall &with_option(const char *option); + SelectCall &with_option(const char *option, size_t len); + SelectCall &with_option(const std::string &option) { return this->with_option(option.data(), option.size()); } + SelectCall &with_option(const char *option) { return this->with_option(option, strlen(option)); } SelectCall &with_index(size_t index); protected: From 9f60aed9b0b7a921c6f637142c8b36c5146b36ea Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 17:18:44 +0100 Subject: [PATCH 294/320] [micronova] Make stove switch entity independent (#12355) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/micronova/micronova.h | 20 ------------ .../components/micronova/switch/__init__.py | 17 +++++----- .../micronova/switch/micronova_switch.cpp | 31 +++++++++++++++---- .../micronova/switch/micronova_switch.h | 17 +++++----- .../text_sensor/micronova_text_sensor.cpp | 9 ------ 5 files changed, 44 insertions(+), 50 deletions(-) diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index a2eee81be8..1b2c06f07f 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -75,17 +75,6 @@ class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent bool needs_update_ = false; }; -class MicroNovaSwitchListener : public MicroNovaBaseListener { - public: - MicroNovaSwitchListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void set_stove_state(bool v) = 0; - virtual bool get_stove_state() = 0; - - protected: - uint8_t memory_data_on_ = 0; - uint8_t memory_data_off_ = 0; -}; - class MicroNovaButtonListener : public MicroNovaBaseListener { public: MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {} @@ -112,15 +101,7 @@ class MicroNova : public Component, public uart::UARTDevice { void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; } - void set_current_stove_state(uint8_t s) { this->current_stove_state_ = s; } - uint8_t get_current_stove_state() { return this->current_stove_state_; } - - void set_stove(MicroNovaSwitchListener *s) { this->stove_switch_ = s; } - MicroNovaSwitchListener *get_stove_switch() { return this->stove_switch_; } - protected: - uint8_t current_stove_state_ = 0; - GPIOPin *enable_rx_pin_{nullptr}; struct MicroNovaSerialTransmission { @@ -135,7 +116,6 @@ class MicroNova : public Component, public uart::UARTDevice { MicroNovaSerialTransmission current_transmission_; std::vector micronova_listeners_{}; - MicroNovaSwitchListener *stove_switch_{nullptr}; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index c6897d8e5c..62a8a0f008 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -4,20 +4,22 @@ import esphome.config_validation as cv from esphome.const import ICON_POWER from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) CONF_STOVE = "stove" CONF_MEMORY_DATA_ON = "memory_data_on" CONF_MEMORY_DATA_OFF = "memory_data_off" -MicroNovaSwitch = micronova_ns.class_("MicroNovaSwitch", switch.Switch, cg.Component) +MicroNovaSwitch = micronova_ns.class_( + "MicroNovaSwitch", switch.Switch, MicroNovaListener +) CONFIG_SCHEMA = cv.Schema( { @@ -30,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema( MICRONOVA_ADDRESS_SCHEMA( default_memory_location=0x00, default_memory_address=0x21, - is_polling_component=False, + is_polling_component=True, ) ) .extend( @@ -48,9 +50,8 @@ async def to_code(config): if stove_config := config.get(CONF_STOVE): sw = await switch.new_switch(stove_config, mv) - cg.add(mv.set_stove(sw)) - cg.add(sw.set_memory_location(stove_config[CONF_MEMORY_LOCATION])) - cg.add(sw.set_memory_address(stove_config[CONF_MEMORY_ADDRESS])) + await to_code_micronova_listener( + mv, sw, stove_config, MicroNovaFunctions.STOVE_FUNCTION_SWITCH + ) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) cg.add(sw.set_memory_data_off(stove_config[CONF_MEMORY_DATA_OFF])) - cg.add(sw.set_function(MicroNovaFunctions.STOVE_FUNCTION_SWITCH)) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 3777b6029d..76ef04da8a 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -4,27 +4,46 @@ namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: + case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { if (state) { // Only send power-on when current state is Off - if (this->micronova_->get_current_stove_state() == 0) { + if (this->raw_state_ == 0) { this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); this->publish_state(true); } else { - ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", micronova_->get_current_stove_state()); + ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); } } else { // don't send power-off when status is Off or Final cleaning - if (this->micronova_->get_current_stove_state() != 0 && micronova_->get_current_stove_state() != 6) { + if (this->raw_state_ != 0 && this->raw_state_ != 6) { this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); this->publish_state(false); } else { - ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", micronova_->get_current_stove_state()); + ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } } - this->micronova_->request_update_listeners(); + this->set_needs_update(true); break; + } + default: + break; + } +} +void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { + this->raw_state_ = value_from_stove; + if (value_from_stove == -1) { + ESP_LOGE(TAG, "Error reading stove state"); + return; + } + + switch (this->get_function()) { + case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { + // set the stove switch to on for any value but 0 + bool state = value_from_stove != 0; + this->publish_state(state); + break; + } default: break; } diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index ab83973ef7..96c2c14e9e 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,25 +6,28 @@ namespace esphome::micronova { -class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener { +class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { public: - MicroNovaSwitch(MicroNova *m) : MicroNovaSwitchListener(m) {} + MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override { LOG_SWITCH("", "Micronova switch", this); this->dump_base_config(); } - - void set_stove_state(bool v) override { this->publish_state(v); } - bool get_stove_state() override { return this->state; } + void request_value_from_stove() override { + this->micronova_->request_address(this->memory_location_, this->memory_address_, this); + } + void process_value_from_stove(int value_from_stove) override; void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; } - uint8_t get_memory_data_on() { return this->memory_data_on_; } void set_memory_data_off(uint8_t f) { this->memory_data_off_ = f; } - uint8_t get_memory_data_off() { return this->memory_data_off_; } protected: void write_state(bool state) override; + + uint8_t memory_data_on_ = 0; + uint8_t memory_data_off_ = 0; + uint8_t raw_state_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index b62fb1afce..d1c03f66c3 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -10,16 +10,7 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { switch (this->get_function()) { case MicroNovaFunctions::STOVE_FUNCTION_STOVE_STATE: - this->micronova_->set_current_stove_state(value_from_stove); this->publish_state(STOVE_STATES[value_from_stove]); - // set the stove switch to on for any value but 0 - if (value_from_stove != 0 && this->micronova_->get_stove_switch() != nullptr && - !this->micronova_->get_stove_switch()->get_stove_state()) { - this->micronova_->get_stove_switch()->set_stove_state(true); - } else if (value_from_stove == 0 && this->micronova_->get_stove_switch() != nullptr && - this->micronova_->get_stove_switch()->get_stove_state()) { - this->micronova_->get_stove_switch()->set_stove_state(false); - } break; default: break; From 7a20c85eec2a25a40e4e146e320234cc6510592f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Blanchet?= <120399978+arno1801@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:12:15 -0500 Subject: [PATCH 295/320] [i2c] Fix port logic with ESP-IDF (#12063) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/esp32/gpio_esp32_c5.py | 15 ++++- esphome/components/esp32/gpio_esp32_c6.py | 15 ++++- esphome/components/esp32/gpio_esp32_p4.py | 16 ++++- esphome/components/i2c/__init__.py | 71 ++++++++++++++++++++++ esphome/components/i2c/i2c_bus_esp_idf.cpp | 41 ++++++++----- esphome/components/i2c/i2c_bus_esp_idf.h | 6 ++ esphome/const.py | 1 + 7 files changed, 146 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index ada426771c..fa2ce1a689 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c5/include/hal/i2c_ll.h +_ESP32C5_I2C_LP_PINS = {"SDA": 2, "SCL": 3} + _ESP32C5_SPI_PSRAM_PINS = { 16: "SPICS0", 17: "SPIQ", @@ -43,3 +46,13 @@ def esp32_c5_validate_supports(value): check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) return value + + +def esp32_c5_validate_lp_i2c(value): + lp_sda_pin = _ESP32C5_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32C5_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-C5" + ) + return value diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index d466adb994..5d679dede2 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c6/include/hal/i2c_ll.h +_ESP32C6_I2C_LP_PINS = {"SDA": 6, "SCL": 7} + _ESP32C6_SPI_PSRAM_PINS = { 24: "SPICS0", 25: "SPIQ", @@ -43,3 +46,13 @@ def esp32_c6_validate_supports(value): check_strapping_pin(value, _ESP32C6_STRAPPING_PINS, _LOGGER) return value + + +def esp32_c6_validate_lp_i2c(value): + lp_sda_pin = _ESP32C6_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32C6_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-C6" + ) + return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 34d1b3139d..b98b567da2 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://documentation.espressif.com/esp32-p4-chip-revision-v1.3_datasheet_en.pdf +_ESP32P4_LP_PINS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + _ESP32P4_USB_JTAG_PINS = {24, 25} _ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38} @@ -36,3 +39,14 @@ def esp32_p4_validate_supports(value): pass check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value + + +def esp32_p4_validate_lp_i2c(value): + if ( + int(value[CONF_SDA]) not in _ESP32P4_LP_PINS + or int(value[CONF_SCL]) not in _ESP32P4_LP_PINS + ): + raise cv.Invalid( + f"Low power i2c interface for ESP32-P4 is only supported on low power interface GPIO{min(_ESP32P4_LP_PINS)} - GPIO{max(_ESP32P4_LP_PINS)}" + ) + return value diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 738568cd3c..9e7c9d702c 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -2,6 +2,23 @@ import logging from esphome import pins import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + get_esp32_variant, +) +from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -16,6 +33,7 @@ from esphome.const import ( CONF_I2C, CONF_I2C_ID, CONF_ID, + CONF_LOW_POWER_MODE, CONF_SCAN, CONF_SCL, CONF_SDA, @@ -40,6 +58,25 @@ IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") +ESP32_I2C_CAPABILITIES = { + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/soc_caps.h + VARIANT_ESP32: {"NUM": 2, "HP": 2}, + VARIANT_ESP32C2: {"NUM": 1, "HP": 1}, + VARIANT_ESP32C3: {"NUM": 1, "HP": 1}, + VARIANT_ESP32C5: {"NUM": 2, "HP": 1, "LP": 1}, + VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, + VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, + VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, + VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, +} +VALIDATE_LP_I2C = { + VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, + VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, + VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, +} +LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) CONF_SDA_PULLUP_ENABLED = "sda_pullup_enabled" CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" @@ -91,6 +128,13 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period, ), cv.Optional(CONF_SCAN, default=True): cv.boolean, + cv.Optional(CONF_LOW_POWER_MODE): cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + supported=LP_I2C_VARIANT, msg_prefix="Low power i2c" + ), + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), @@ -102,6 +146,31 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") + if CORE.using_esp_idf and get_esp32_variant() in ESP32_I2C_CAPABILITIES: + variant = get_esp32_variant() + max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"] + if len(full_config) > max_num: + raise cv.Invalid( + f"The maximum number of i2c interfaces for {variant} is {max_num}" + ) + if variant in LP_I2C_VARIANT: + max_lp_num = ESP32_I2C_CAPABILITIES[variant]["LP"] + max_hp_num = ESP32_I2C_CAPABILITIES[variant]["HP"] + lp_num = sum( + CONF_LOW_POWER_MODE in conf and conf[CONF_LOW_POWER_MODE] + for conf in full_config + ) + hp_num = len(full_config) - lp_num + if CONF_LOW_POWER_MODE in config and config[CONF_LOW_POWER_MODE]: + VALIDATE_LP_I2C[variant](config) + if lp_num > max_lp_num: + raise cv.Invalid( + f"The maximum number of low power i2c interfaces for {variant} is {max_lp_num}" + ) + if hp_num > max_hp_num: + raise cv.Invalid( + f"The maximum number of high power i2c interfaces for {variant} is {max_hp_num}" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -155,6 +224,8 @@ async def to_code(config): cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) if CORE.using_arduino and not CORE.is_esp32: cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) def i2c_device_schema(default_address): diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index c22db51c68..486dc0b7d8 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -16,13 +16,10 @@ namespace i2c { static const char *const TAG = "i2c.idf"; void IDFI2CBus::setup() { - static i2c_port_t next_port = I2C_NUM_0; - this->port_ = next_port; - if (this->port_ == I2C_NUM_MAX) { - ESP_LOGE(TAG, "No more than %u buses supported", I2C_NUM_MAX); - this->mark_failed(); - return; - } + static i2c_port_t next_hp_port = I2C_NUM_0; +#if SOC_LP_I2C_SUPPORTED + static i2c_port_t next_lp_port = LP_I2C_NUM_0; +#endif if (this->timeout_ > 13000) { ESP_LOGW(TAG, "Using max allowed timeout: 13 ms"); @@ -31,23 +28,35 @@ void IDFI2CBus::setup() { this->recover_(); - next_port = (i2c_port_t) (next_port + 1); - i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); bus_conf.sda_io_num = gpio_num_t(sda_pin_); bus_conf.scl_io_num = gpio_num_t(scl_pin_); - bus_conf.i2c_port = this->port_; bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED - if (this->port_ < SOC_HP_I2C_NUM) { - bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; - } else { + if (this->lp_mode_) { + if ((next_lp_port - LP_I2C_NUM_0) == SOC_LP_I2C_NUM) { + ESP_LOGE(TAG, "No more than %u LP buses supported", SOC_LP_I2C_NUM); + this->mark_failed(); + return; + } + this->port_ = next_lp_port; + next_lp_port = (i2c_port_t) (next_lp_port + 1); bus_conf.lp_source_clk = LP_I2C_SCLK_DEFAULT; - } -#else - bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; + } else { #endif + if (next_hp_port == SOC_HP_I2C_NUM) { + ESP_LOGE(TAG, "No more than %u HP buses supported", SOC_HP_I2C_NUM); + this->mark_failed(); + return; + } + this->port_ = next_hp_port; + next_hp_port = (i2c_port_t) (next_hp_port + 1); + bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; +#if SOC_LP_I2C_SUPPORTED + } +#endif + bus_conf.i2c_port = this->port_; bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 63fe8b701c..84f4616967 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -30,6 +30,9 @@ class IDFI2CBus : public InternalI2CBus, public Component { void set_scl_pullup_enabled(bool scl_pullup_enabled) { this->scl_pullup_enabled_ = scl_pullup_enabled; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } +#if SOC_LP_I2C_SUPPORTED + void set_lp_mode(bool lp_mode) { this->lp_mode_ = lp_mode; } +#endif int get_port() const override { return this->port_; } @@ -48,6 +51,9 @@ class IDFI2CBus : public InternalI2CBus, public Component { uint32_t frequency_{}; uint32_t timeout_ = 0; bool initialized_ = false; +#if SOC_LP_I2C_SUPPORTED + bool lp_mode_ = false; +#endif }; } // namespace i2c diff --git a/esphome/const.py b/esphome/const.py index 59bf0e8b8a..8fa2d8da16 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -559,6 +559,7 @@ CONF_LOGS = "logs" CONF_LONGITUDE = "longitude" CONF_LOOP_TIME = "loop_time" CONF_LOW = "low" +CONF_LOW_POWER_MODE = "low_power_mode" CONF_LOW_VOLTAGE_REFERENCE = "low_voltage_reference" CONF_MAC_ADDRESS = "mac_address" CONF_MAGNITUDE = "magnitude" From 4c31961ae9c9a1654255db226c5e330f93bac378 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 8 Dec 2025 20:37:45 +0100 Subject: [PATCH 296/320] Update URLs (#12369) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- esphome/components/absolute_humidity/absolute_humidity.cpp | 2 +- esphome/components/api/__init__.py | 2 +- esphome/components/bedjet/climate/__init__.py | 2 +- esphome/components/esp32/__init__.py | 2 +- esphome/components/kalman_combinator/sensor.py | 2 +- esphome/components/pn532/__init__.py | 2 +- esphome/components/sgp40/sensor.py | 2 +- esphome/components/web_server/web_server.h | 2 +- esphome/components/web_server/web_server_v1.cpp | 5 ++--- esphome/core/config.py | 2 +- esphome/espota2.py | 2 +- esphome/pins.py | 2 +- esphome/util.py | 2 +- esphome/wizard.py | 6 ++---- tests/components/qr_code/common.yaml | 2 +- 16 files changed, 18 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303b548310..66ad3ed599 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ We welcome contributions to the ESPHome suite of code and documentation! -Please read our [contributing guide](https://esphome.io/guides/contributing.html) if you wish to contribute to the +Please read our [contributing guide](https://developers.esphome.io/contributing/code/) if you wish to contribute to the project and be sure to join us on [Discord](https://discord.gg/KhAMKrd). **See also:** diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index d16a024d86..74d675b80b 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -163,7 +163,7 @@ float AbsoluteHumidityComponent::es_wobus(float t) { } // From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/ -// H/T to https://esphome.io/cookbook/bme280_environment.html +// H/T to https://esphome.io/cookbook/bme280_environment/ // H/T to https://carnotcycle.wordpress.com/2012/08/04/how-to-convert-relative-humidity-to-absolute-humidity/ float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) { // es = saturated vapor pressure (kPa) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index d349cf3867..88618acef4 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -246,7 +246,7 @@ def _validate_api_config(config: ConfigType) -> ConfigType: _LOGGER.warning( "API 'password' authentication has been deprecated since May 2022 and will be removed in version 2026.1.0. " "Please migrate to the 'encryption' configuration. " - "See https://esphome.io/components/api.html#configuration-variables" + "See https://esphome.io/components/api/#configuration-variables" ) return config diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index e9c5510256..0da2107d43 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -44,7 +44,7 @@ CONFIG_SCHEMA = ( cv.Optional(ble_client.CONF_BLE_CLIENT_ID): cv.invalid( "The 'ble_client_id' option has been removed. Please migrate " "to the new `bedjet_id` option in the `bedjet` component.\n" - "See https://esphome.io/components/climate/bedjet.html" + "See https://esphome.io/components/climate/bedjet/" ), cv.Optional(CONF_TIME_ID): cv.invalid( "The 'time_id' option has been moved to the `bedjet` component." diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 94280308bd..3dc5e4bbaa 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -764,7 +764,7 @@ def _show_framework_migration_message(name: str, variant: str) -> None: + "Need help? Check out the migration guide:\n" + color( AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf.html", + "https://esphome.io/guides/esp32_arduino_to_idf/", ) ) _LOGGER.warning(message) diff --git a/esphome/components/kalman_combinator/sensor.py b/esphome/components/kalman_combinator/sensor.py index c19a17462d..d30a41d6bf 100644 --- a/esphome/components/kalman_combinator/sensor.py +++ b/esphome/components/kalman_combinator/sensor.py @@ -2,5 +2,5 @@ import esphome.config_validation as cv CONFIG_SCHEMA = cv.invalid( "The kalman_combinator sensor has moved.\nPlease use the combination platform instead with type: kalman.\n" - "See https://esphome.io/components/sensor/combination.html" + "See https://esphome.io/components/sensor/combination/" ) diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 3f04e8e1cc..6f679ed10a 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -55,7 +55,7 @@ def CONFIG_SCHEMA(conf): if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " - "instructions. https://esphome.io/components/binary_sensor/pn532.html" + "instructions. https://esphome.io/components/binary_sensor/pn532/" ) diff --git a/esphome/components/sgp40/sensor.py b/esphome/components/sgp40/sensor.py index ad9de6fe24..b16151ec1f 100644 --- a/esphome/components/sgp40/sensor.py +++ b/esphome/components/sgp40/sensor.py @@ -4,5 +4,5 @@ CODEOWNERS = ["@SenexCrenshaw"] CONFIG_SCHEMA = cv.invalid( "SGP40 is deprecated.\nPlease use the SGP4x platform instead.\nSGP4x supports both SPG40 and SGP41.\n" - " See https://esphome.io/components/sensor/sgp4x.html" + " See https://esphome.io/components/sensor/sgp4x/" ) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 52cf0bedea..bb69d57872 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -171,7 +171,7 @@ class DeferredUpdateEventSourceList : public std::listprint( - ESPHOME_F("

See ESPHome Web API for " - "REST API documentation.

")); + stream->print(ESPHOME_F("

See ESPHome Web API for " + "REST API documentation.

")); #if defined(USE_WEBSERVER_OTA) && !defined(USE_WEBSERVER_OTA_DISABLED) // Show OTA form only if web_server OTA is not explicitly disabled // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal diff --git a/esphome/core/config.py b/esphome/core/config.py index 0a239c5f5e..3adaf7eb9e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -87,7 +87,7 @@ def validate_hostname(config): _LOGGER.warning( "'%s': Using the '_' (underscore) character in the hostname is discouraged " "as it can cause problems with some DHCP and local name services. " - "For more information, see https://esphome.io/guides/faq.html#why-shouldn-t-i-use-underscores-in-my-device-name", + "For more information, see https://esphome.io/guides/faq/#why-shouldnt-i-use-underscores-in-my-device-name", config[CONF_NAME], ) return config diff --git a/esphome/espota2.py b/esphome/espota2.py index 2b1b9a8328..c29506224c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -402,7 +402,7 @@ def run_ota_impl_( ) _LOGGER.error( "(If this error persists, please set a static IP address: " - "https://esphome.io/components/wifi.html#manual-ips)" + "https://esphome.io/components/wifi/#manual-ips)" ) raise OTAError(err) from err diff --git a/esphome/pins.py b/esphome/pins.py index 601c05880a..bdaa0e28ab 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -274,7 +274,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): logger.warning( f"GPIO{num} is a strapping PIN and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", + "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", ) # mitigate undisciplined use of strapping: if num not in strapping_pin_list and conf.get(CONF_IGNORE_STRAPPING_WARNING): diff --git a/esphome/util.py b/esphome/util.py index d41800dc20..7b896de27e 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -375,6 +375,6 @@ def get_esp32_arduino_flash_error_help() -> str | None: + "For detailed migration instructions, see:\n" + color( AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf.html\n\n", + "https://esphome.io/guides/esp32_arduino_to_idf/\n\n", ) ) diff --git a/esphome/wizard.py b/esphome/wizard.py index 97343eea99..d77450b04d 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -411,9 +411,7 @@ def wizard(path: Path) -> int: "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) elif platform == "RP2040": - board_link = ( - "https://www.raspberrypi.com/documentation/microcontrollers/rp2040.html" - ) + board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" else: @@ -555,7 +553,7 @@ def wizard(path: Path) -> int: safe_print("Next steps:") safe_print(" > Follow the rest of the getting started guide:") safe_print( - " > https://esphome.io/guides/getting_started_command_line.html#adding-some-features" + " > https://esphome.io/guides/getting_started_command_line/#adding-some-features" ) safe_print(" > to learn how to customize ESPHome and install it to your device.") return 0 diff --git a/tests/components/qr_code/common.yaml b/tests/components/qr_code/common.yaml index 5fec26c1cc..15b4e387c6 100644 --- a/tests/components/qr_code/common.yaml +++ b/tests/components/qr_code/common.yaml @@ -16,4 +16,4 @@ display: qr_code: - id: qr_code_homepage_qr - value: https://esphome.io/index.html + value: https://esphome.io/ From 3eaa9f164b90a79bc5f7763f10b18c0630cdb6ba Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 20:38:13 +0100 Subject: [PATCH 297/320] [micronova] Remove MicroNovaFunctions (#12363) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/micronova/__init__.py | 19 +------ .../components/micronova/button/__init__.py | 2 - .../micronova/button/micronova_button.cpp | 8 +-- esphome/components/micronova/micronova.h | 20 ------- .../components/micronova/number/__init__.py | 13 ++--- .../micronova/number/micronova_number.cpp | 32 ++++-------- .../micronova/number/micronova_number.h | 5 ++ .../components/micronova/sensor/__init__.py | 23 ++++---- .../micronova/sensor/micronova_sensor.cpp | 26 +++------- .../micronova/sensor/micronova_sensor.h | 11 ++-- .../components/micronova/switch/__init__.py | 5 +- .../micronova/switch/micronova_switch.cpp | 52 +++++++------------ .../micronova/text_sensor/__init__.py | 5 +- .../text_sensor/micronova_text_sensor.cpp | 8 +-- 14 files changed, 68 insertions(+), 161 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 637d0eb168..52fbae2da2 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -17,22 +17,6 @@ DEFAULT_POLLING_INTERVAL = "60s" micronova_ns = cg.esphome_ns.namespace(DOMAIN) -MicroNovaFunctions = micronova_ns.enum("MicroNovaFunctions", is_class=True) -MICRONOVA_FUNCTIONS_ENUM = { - "STOVE_FUNCTION_SWITCH": MicroNovaFunctions.STOVE_FUNCTION_SWITCH, - "STOVE_FUNCTION_ROOM_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE, - "STOVE_FUNCTION_THERMOSTAT_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, - "STOVE_FUNCTION_FUMES_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE, - "STOVE_FUNCTION_STOVE_POWER": MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER, - "STOVE_FUNCTION_FAN_SPEED": MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED, - "STOVE_FUNCTION_STOVE_STATE": MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE, - "STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR": MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR, - "STOVE_FUNCTION_WATER_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE, - "STOVE_FUNCTION_WATER_PRESSURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE, - "STOVE_FUNCTION_POWER_LEVEL": MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL, - "STOVE_FUNCTION_CUSTOM": MicroNovaFunctions.STOVE_FUNCTION_CUSTOM, -} - MicroNova = micronova_ns.class_("MicroNova", cg.Component, uart.UARTDevice) MicroNovaListener = micronova_ns.class_("MicroNovaListener", cg.PollingComponent) @@ -78,12 +62,11 @@ def MICRONOVA_ADDRESS_SCHEMA( return schema -async def to_code_micronova_listener(mv, var, config, micronova_function): +async def to_code_micronova_listener(mv, var, config): await cg.register_component(var, config) cg.add(mv.register_micronova_listener(var)) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) - cg.add(var.set_function(micronova_function)) async def to_code(config): diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 38fee2f561..2eda887443 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -8,7 +8,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, micronova_ns, ) @@ -43,4 +42,3 @@ async def to_code(config): cg.add(bt.set_memory_location(custom_button_config.get(CONF_MEMORY_LOCATION))) cg.add(bt.set_memory_address(custom_button_config.get(CONF_MEMORY_ADDRESS))) cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA])) - cg.add(bt.set_function(MicroNovaFunctions.STOVE_FUNCTION_CUSTOM)) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index c78b4024f9..3f49d4b5b3 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -3,13 +3,7 @@ namespace esphome::micronova { void MicroNovaButton::press_action() { - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_CUSTOM: - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); - break; - default: - break; - } + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); this->micronova_->request_update_listeners(); } diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 1b2c06f07f..a70f355ead 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -12,22 +12,6 @@ namespace esphome::micronova { static const char *const TAG = "micronova"; -enum class MicroNovaFunctions { - STOVE_FUNCTION_VOID = 0, - STOVE_FUNCTION_SWITCH = 1, - STOVE_FUNCTION_ROOM_TEMPERATURE = 2, - STOVE_FUNCTION_THERMOSTAT_TEMPERATURE = 3, - STOVE_FUNCTION_FUMES_TEMPERATURE = 4, - STOVE_FUNCTION_STOVE_POWER = 5, - STOVE_FUNCTION_FAN_SPEED = 6, - STOVE_FUNCTION_STOVE_STATE = 7, - STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR = 8, - STOVE_FUNCTION_WATER_TEMPERATURE = 9, - STOVE_FUNCTION_WATER_PRESSURE = 10, - STOVE_FUNCTION_POWER_LEVEL = 11, - STOVE_FUNCTION_CUSTOM = 12 -}; - class MicroNova; ////////////////////////////////////////////////////////////////////// @@ -39,9 +23,6 @@ class MicroNovaBaseListener { void set_micronova_object(MicroNova *m) { this->micronova_ = m; } - void set_function(MicroNovaFunctions f) { this->function_ = f; } - MicroNovaFunctions get_function() { return this->function_; } - void set_memory_location(uint8_t l) { this->memory_location_ = l; } uint8_t get_memory_location() { return this->memory_location_; } @@ -52,7 +33,6 @@ class MicroNovaBaseListener { protected: MicroNova *micronova_{nullptr}; - MicroNovaFunctions function_ = MicroNovaFunctions::STOVE_FUNCTION_VOID; uint8_t memory_location_ = 0; uint8_t memory_address_ = 0; }; diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index 07023e618c..ef6cc0f7d7 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -7,7 +7,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -66,13 +65,9 @@ async def to_code(config): max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) - await to_code_micronova_listener( - mv, - numb, - thermostat_temperature_config, - MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, - ) + await to_code_micronova_listener(mv, numb, thermostat_temperature_config) cg.add(numb.set_micronova_object(mv)) + cg.add(numb.set_use_step_scaling(True)) if power_level_config := config.get(CONF_POWER_LEVEL): numb = await number.new_number( @@ -81,7 +76,5 @@ async def to_code(config): max_value=5, step=1, ) - await to_code_micronova_listener( - mv, numb, power_level_config, MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL - ) + await to_code_micronova_listener(mv, numb, power_level_config) cg.add(numb.set_micronova_object(mv)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index c71d819ad6..8027947468 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -3,38 +3,24 @@ namespace esphome::micronova { void MicroNovaNumber::process_value_from_stove(int value_from_stove) { - float new_sensor_value = 0; - if (value_from_stove == -1) { this->publish_state(NAN); return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - new_sensor_value = ((float) value_from_stove) * this->traits.get_step(); - break; - case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL: - new_sensor_value = (float) value_from_stove; - break; - default: - break; + float new_value = static_cast(value_from_stove); + if (this->use_step_scaling_) { + new_value *= this->traits.get_step(); } - this->publish_state(new_sensor_value); + this->publish_state(new_value); } void MicroNovaNumber::control(float value) { - uint8_t new_number = 0; - - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - new_number = (uint8_t) (value / this->traits.get_step()); - break; - case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL: - new_number = (uint8_t) value; - break; - default: - break; + uint8_t new_number; + if (this->use_step_scaling_) { + new_number = static_cast(value / this->traits.get_step()); + } else { + new_number = static_cast(value); } this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); this->micronova_->request_update_listeners(); diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 391765b730..3fc5838a4f 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -18,6 +18,11 @@ class MicroNovaNumber : public number::Number, public MicroNovaListener { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } void process_value_from_stove(int value_from_stove) override; + + void set_use_step_scaling(bool v) { this->use_step_scaling_ = v; } + + protected: + bool use_step_scaling_ = false; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 77bdacd5da..55318a7fff 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -13,7 +13,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -131,21 +130,21 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) - for key, fn in { - CONF_ROOM_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE, - CONF_FUMES_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE, - CONF_STOVE_POWER: MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER, - CONF_MEMORY_ADDRESS_SENSOR: MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR, - CONF_WATER_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE, - CONF_WATER_PRESSURE: MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE, + for key, divisor in { + CONF_ROOM_TEMPERATURE: 2, + CONF_FUMES_TEMPERATURE: None, + CONF_STOVE_POWER: None, + CONF_MEMORY_ADDRESS_SENSOR: None, + CONF_WATER_TEMPERATURE: 2, + CONF_WATER_PRESSURE: 10, }.items(): if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config, mv) - await to_code_micronova_listener(mv, sens, sensor_config, fn) + await to_code_micronova_listener(mv, sens, sensor_config) + if divisor: + cg.add(sens.set_divisor(divisor)) if fan_speed_config := config.get(CONF_FAN_SPEED): sens = await sensor.new_sensor(fan_speed_config, mv) - await to_code_micronova_listener( - mv, sens, fan_speed_config, MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED - ) + await to_code_micronova_listener(mv, sens, fan_speed_config) cg.add(sens.set_fan_speed_offset(fan_speed_config[CONF_FAN_RPM_OFFSET])) diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 9fd8832f29..d845e0ab3c 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -8,25 +8,15 @@ void MicroNovaSensor::process_value_from_stove(int value_from_stove) { return; } - float new_sensor_value = (float) value_from_stove; - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_ROOM_TEMPERATURE: - new_sensor_value = new_sensor_value / 2; - break; - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - break; - case MicroNovaFunctions::STOVE_FUNCTION_FAN_SPEED: - new_sensor_value = new_sensor_value == 0 ? 0 : (new_sensor_value * 10) + this->fan_speed_offset_; - break; - case MicroNovaFunctions::STOVE_FUNCTION_WATER_TEMPERATURE: - new_sensor_value = new_sensor_value / 2; - break; - case MicroNovaFunctions::STOVE_FUNCTION_WATER_PRESSURE: - new_sensor_value = new_sensor_value / 10; - break; - default: - break; + float new_sensor_value = static_cast(value_from_stove); + + // Fan speed has special calculation: value * 10 + offset (when non-zero) + if (this->is_fan_speed_) { + new_sensor_value = value_from_stove == 0 ? 0.0f : (new_sensor_value * 10) + this->fan_speed_offset_; + } else if (this->divisor_ > 1) { + new_sensor_value = new_sensor_value / this->divisor_; } + this->publish_state(new_sensor_value); } diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index 119e5eb155..a2f232c7dc 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -18,11 +18,16 @@ class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { } void process_value_from_stove(int value_from_stove) override; - void set_fan_speed_offset(uint8_t f) { this->fan_speed_offset_ = f; } - uint8_t get_set_fan_speed_offset() { return this->fan_speed_offset_; } + void set_divisor(uint8_t d) { this->divisor_ = d; } + void set_fan_speed_offset(uint8_t offset) { + this->is_fan_speed_ = true; + this->fan_speed_offset_ = offset; + } protected: - int fan_speed_offset_ = 0; + uint8_t divisor_ = 1; + uint8_t fan_speed_offset_ = 0; + bool is_fan_speed_ = false; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index 62a8a0f008..c937a4cac9 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -7,7 +7,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -50,8 +49,6 @@ async def to_code(config): if stove_config := config.get(CONF_STOVE): sw = await switch.new_switch(stove_config, mv) - await to_code_micronova_listener( - mv, sw, stove_config, MicroNovaFunctions.STOVE_FUNCTION_SWITCH - ) + await to_code_micronova_listener(mv, sw, stove_config) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) cg.add(sw.set_memory_data_off(stove_config[CONF_MEMORY_DATA_OFF])) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 76ef04da8a..9b9ad61018 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -3,31 +3,24 @@ namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { - if (state) { - // Only send power-on when current state is Off - if (this->raw_state_ == 0) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); - this->publish_state(true); - } else { - ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); - } - } else { - // don't send power-off when status is Off or Final cleaning - if (this->raw_state_ != 0 && this->raw_state_ != 6) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); - this->publish_state(false); - } else { - ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); - } - } - this->set_needs_update(true); - break; + if (state) { + // Only send power-on when current state is Off + if (this->raw_state_ == 0) { + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); + this->publish_state(true); + } else { + ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); + } + } else { + // don't send power-off when status is Off or Final cleaning + if (this->raw_state_ != 0 && this->raw_state_ != 6) { + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); + this->publish_state(false); + } else { + ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } - default: - break; } + this->set_needs_update(true); } void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { @@ -37,16 +30,9 @@ void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { - // set the stove switch to on for any value but 0 - bool state = value_from_stove != 0; - this->publish_state(state); - break; - } - default: - break; - } + // set the stove switch to on for any value but 0 + bool state = value_from_stove != 0; + this->publish_state(state); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index e54b9e280a..33d0779eae 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -6,7 +6,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -39,6 +38,4 @@ async def to_code(config): if stove_state_config := config.get(CONF_STOVE_STATE): sens = await text_sensor.new_text_sensor(stove_state_config, mv) - await to_code_micronova_listener( - mv, sens, stove_state_config, MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE - ) + await to_code_micronova_listener(mv, sens, stove_state_config) diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index d1c03f66c3..2217ed6d6f 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -8,13 +8,7 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_STOVE_STATE: - this->publish_state(STOVE_STATES[value_from_stove]); - break; - default: - break; - } + this->publish_state(STOVE_STATES[value_from_stove]); } } // namespace esphome::micronova From fcae13836cdfb08ec239aaa1d89dc63a354e9040 Mon Sep 17 00:00:00 2001 From: Mirko Vogt Date: Tue, 9 Dec 2025 04:50:07 +0100 Subject: [PATCH 298/320] [sx1509] Change setup priority from HARDWARE to IO (#12373) Co-authored-by: Your Name --- esphome/components/sx1509/sx1509.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 2afd0d0e4e..f98fc0a44f 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -40,7 +40,7 @@ class SX1509Component : public Component, void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::HARDWARE; } + float get_setup_priority() const override { return setup_priority::IO; } void loop() override; uint16_t read_key_data(); From 6945b44af59107a183004c0880c525c1179bf7ce Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:38:16 +1100 Subject: [PATCH 299/320] [psram] Fix boot failure with 120MHz Octal flash (#12377) --- esphome/components/psram/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index fcbe9ed043..39afb407f1 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -197,7 +197,6 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) if config[CONF_MODE] == TYPE_OCTAL and speed == 120: add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - add_idf_sdkconfig_option("CONFIG_BOOTLOADER_FLASH_DC_AWARE", True) if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True From 750f4ea797703ed6301c3a99a3acb6ea648f251b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:40:58 +1100 Subject: [PATCH 300/320] [pio] Rationalise library definitions in platformio.ini (#12374) --- .clang-tidy.hash | 2 +- platformio.ini | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7dabee48f1..a3322ba731 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -c01eec15857a784dd603c0afd194ab3b29a632422fe6f6b0a806ad4d81b5efc0 +766420905c06eeb6c5f360f68fd965e5ddd9c4a5db6b823263d3ad3accb64a07 diff --git a/platformio.ini b/platformio.ini index 9095d27af8..d37c798c05 100644 --- a/platformio.ini +++ b/platformio.ini @@ -32,20 +32,24 @@ build_flags = ; This are common settings for all environments. [common] -lib_deps = - esphome/noise-c@0.1.10 ; api - improv/Improv@1.2.4 ; improv_serial / esp32_improv +; Base dependencies for all environments +lib_deps_base = bblanchon/ArduinoJson@7.4.2 ; json wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - kikuchan98/pngle@1.1.0 ; online_image https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps +; This is using the repository until a new release is published to PlatformIO + https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library + lvgl/lvgl@8.4.0 ; lvgl + +lib_deps = + ${common.lib_deps_base} + esphome/noise-c@0.1.10 ; api + improv/Improv@1.2.4 ; improv_serial / esp32_improv + kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image - ; This is using the repository until a new release is published to PlatformIO - https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -236,13 +240,7 @@ build_flags = -DUSE_ZEPHYR -DUSE_NRF52 lib_deps = - bblanchon/ArduinoJson@7.4.2 ; json - wjtje/qr-code-generator-library@1.7.0 ; qr_code - pavlodn/HaierProtocol@0.9.31 ; haier - functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 - https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps - https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl + ${common.lib_deps_base} ; All the actual environments are defined below. From 861ed8dd41c2966494ebcaaca5843a672e239072 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 15:27:12 +0100 Subject: [PATCH 301/320] [scheduler] Avoid std::string allocation in RetryArgs (#12311) --- esphome/core/component.cpp | 9 +++++++ esphome/core/component.h | 4 ++++ esphome/core/scheduler.cpp | 49 ++++++++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index b7c0cedb76..97ab2edb5a 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -138,10 +138,19 @@ void Component::set_retry(const std::string &name, uint32_t initial_wait_time, u App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); } +void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function &&f, float backoff_increase_factor) { // NOLINT + App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +} + bool Component::cancel_retry(const std::string &name) { // NOLINT return App.scheduler.cancel_retry(this, name); } +bool Component::cancel_retry(const char *name) { // NOLINT + return App.scheduler.cancel_retry(this, name); +} + void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 3d45a020c4..32f594d6f8 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -367,6 +367,9 @@ class Component { void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT + std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT float backoff_increase_factor = 1.0f); // NOLINT @@ -376,6 +379,7 @@ class Component { * @return Whether a retry function was deleted. */ bool cancel_retry(const std::string &name); // NOLINT + bool cancel_retry(const char *name); // NOLINT /** Set a timeout function with a unique name. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index f84495950c..8b713523b6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -204,13 +204,21 @@ bool HOT Scheduler::cancel_interval(Component *component, const char *name) { } struct RetryArgs { + // Ordered to minimize padding on 32-bit systems std::function func; - uint8_t retry_countdown; - uint32_t current_interval; Component *component; - std::string name; // Keep as std::string since retry uses it dynamically - float backoff_increase_factor; Scheduler *scheduler; + const char *name; // Points to static string or owned copy + uint32_t current_interval; + float backoff_increase_factor; + uint8_t retry_countdown; + bool name_is_dynamic; // True if name needs delete[] + + ~RetryArgs() { + if (this->name_is_dynamic && this->name) { + delete[] this->name; + } + } }; void retry_handler(const std::shared_ptr &args) { @@ -218,8 +226,10 @@ void retry_handler(const std::shared_ptr &args) { if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) return; // second execution of `func` happens after `initial_wait_time` + // Pass is_static_string=true because args->name is owned by the shared_ptr + // which is captured in the lambda and outlives the SchedulerItem args->scheduler->set_timer_common_( - args->component, Scheduler::SchedulerItem::TIMEOUT, false, &args->name, args->current_interval, + args->component, Scheduler::SchedulerItem::TIMEOUT, true, args->name, args->current_interval, [args]() { retry_handler(args); }, /* is_retry= */ true); // backoff_increase_factor applied to third & later executions args->current_interval *= args->backoff_increase_factor; @@ -246,16 +256,35 @@ void HOT Scheduler::set_retry_common_(Component *component, bool is_static_strin auto args = std::make_shared(); args->func = std::move(func); - args->retry_countdown = max_attempts; - args->current_interval = initial_wait_time; args->component = component; - args->name = name_cstr ? name_cstr : ""; // Convert to std::string for RetryArgs - args->backoff_increase_factor = backoff_increase_factor; args->scheduler = this; + args->current_interval = initial_wait_time; + args->backoff_increase_factor = backoff_increase_factor; + args->retry_countdown = max_attempts; + + // Store name - either as static pointer or owned copy + if (name_cstr == nullptr || name_cstr[0] == '\0') { + // Empty or null name - use empty string literal + args->name = ""; + args->name_is_dynamic = false; + } else if (is_static_string) { + // Static string - just store the pointer + args->name = name_cstr; + args->name_is_dynamic = false; + } else { + // Dynamic string - make a copy + size_t len = strlen(name_cstr); + char *copy = new char[len + 1]; + memcpy(copy, name_cstr, len + 1); + args->name = copy; + args->name_is_dynamic = true; + } // First execution of `func` immediately - use set_timer_common_ with is_retry=true + // Pass is_static_string=true because args->name is owned by the shared_ptr + // which is captured in the lambda and outlives the SchedulerItem this->set_timer_common_( - component, SchedulerItem::TIMEOUT, false, &args->name, 0, [args]() { retry_handler(args); }, + component, SchedulerItem::TIMEOUT, true, args->name, 0, [args]() { retry_handler(args); }, /* is_retry= */ true); } From f9aa48295c46e74968fd909c5348df18944cb697 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 15:33:23 +0100 Subject: [PATCH 302/320] [mdns] Reduce RAM usage by eliminating MAC address heap allocation (#12073) --- esphome/components/mdns/__init__.py | 10 +++--- esphome/components/mdns/mdns_component.cpp | 6 ++-- esphome/components/mdns/mdns_component.h | 42 ++++++++++++++++++++-- esphome/components/mdns/mdns_esp32.cpp | 14 +++----- esphome/components/mdns/mdns_esp8266.cpp | 12 ++----- esphome/components/mdns/mdns_host.cpp | 9 +++++ esphome/components/mdns/mdns_libretiny.cpp | 12 ++----- esphome/components/mdns/mdns_rp2040.cpp | 12 ++----- esphome/core/defines.h | 3 +- esphome/core/helpers.cpp | 6 ++-- esphome/core/helpers.h | 15 +++++--- 11 files changed, 86 insertions(+), 55 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 1daac93a2e..99b728b249 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -184,10 +184,8 @@ async def to_code(config): # Calculate compile-time dynamic TXT value count # Dynamic values are those that cannot be stored in flash at compile time + # Note: MAC address is now stored in a fixed char[13] buffer, not dynamic storage dynamic_txt_count = 0 - if "api" in CORE.config: - # Always: get_mac_address() - dynamic_txt_count += 1 # User-provided templatable TXT values (only lambdas, not static strings) dynamic_txt_count += sum( 1 @@ -196,8 +194,10 @@ async def to_code(config): if cg.is_template(txt_value) ) - # Ensure at least 1 to avoid zero-size array - cg.add_define("MDNS_DYNAMIC_TXT_COUNT", max(1, dynamic_txt_count)) + # Only add define if we actually need dynamic storage + if dynamic_txt_count > 0: + cg.add_define("USE_MDNS_DYNAMIC_TXT") + cg.add_define("MDNS_DYNAMIC_TXT_COUNT", dynamic_txt_count) # Enable storage if verbose logging is enabled (for dump_config) if get_logger_level() in ("VERBOSE", "VERY_VERBOSE"): diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4655907983..47db92610a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -35,7 +35,7 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_(StaticVector &services) { +void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. @@ -86,7 +86,9 @@ void MDNSComponent::compile_records_(StaticVectoradd_dynamic_txt_value(get_mac_address()))}); + + // MAC address: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES) + txt_records.push_back({MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}); #ifdef USE_ESP8266 MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266"); diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 691c45b7df..f696cfff1c 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -60,22 +60,58 @@ class MDNSComponent : public Component { void on_shutdown() override; +#ifdef USE_MDNS_DYNAMIC_TXT /// Add a dynamic TXT value and return pointer to it for use in MDNSTXTRecord const char *add_dynamic_txt_value(const std::string &value) { this->dynamic_txt_values_.push_back(value); return this->dynamic_txt_values_[this->dynamic_txt_values_.size() - 1].c_str(); } +#endif - /// Storage for runtime-generated TXT values (MAC address, user lambdas) + protected: + /// Helper to set up services and MAC buffers, then call platform-specific registration + using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); + + void setup_buffers_and_register_(PlatformRegisterFn platform_register) { +#ifdef USE_MDNS_STORE_SERVICES + auto &services = this->services_; +#else + StaticVector services_storage; + auto &services = services_storage; +#endif + +#ifdef USE_API +#ifdef USE_MDNS_STORE_SERVICES + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; +#else + char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + get_mac_address_into_buffer(mac_address); + char *mac_ptr = mac_address; +#endif +#else + char *mac_ptr = nullptr; +#endif + + this->compile_records_(services, mac_ptr); + platform_register(this, services); + } + +#ifdef USE_MDNS_DYNAMIC_TXT + /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. /// Static/compile-time values (version, board, etc.) are stored directly in flash and don't use this. StaticVector dynamic_txt_values_; +#endif - protected: +#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) + /// Fixed buffer for MAC address (only needed when services are stored) + char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; +#endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif - void compile_records_(StaticVector &services); + void compile_records_(StaticVector &services, char *mac_address_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 5547a2524b..e6b43e59cb 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -11,19 +11,11 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_esp32(MDNSComponent *comp, StaticVector &services) { esp_err_t err = mdns_init(); if (err != ESP_OK) { ESP_LOGW(TAG, "Init failed: %s", esp_err_to_name(err)); - this->mark_failed(); + comp->mark_failed(); return; } @@ -50,6 +42,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } + void MDNSComponent::on_shutdown() { mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 06f905884c..dcbe5ebd52 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_esp8266(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -44,6 +36,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); } + void MDNSComponent::loop() { MDNS.update(); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 64b8c8f54b..4d902319b8 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -9,6 +9,15 @@ namespace esphome::mdns { void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES +#ifdef USE_API + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; +#else + char *mac_ptr = nullptr; +#endif + this->compile_records_(this->services_, mac_ptr); +#endif // Host platform doesn't have actual mDNS implementation } diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a049fe2109..986099fa1f 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_libretiny(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -43,6 +35,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_libretiny); } + void MDNSComponent::on_shutdown() {} } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a102e0b6c3..e4a9b60cdb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_rp2040(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -43,6 +35,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_rp2040); } + void MDNSComponent::loop() { MDNS.update(); } void MDNSComponent::on_shutdown() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 021240cc40..a5170d73ff 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -90,7 +90,8 @@ #define USE_MDNS #define USE_MDNS_STORE_SERVICES #define MDNS_SERVICE_COUNT 3 -#define MDNS_DYNAMIC_TXT_COUNT 3 +#define USE_MDNS_DYNAMIC_TXT +#define MDNS_DYNAMIC_TXT_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_NEXTION_TFT_UPLOAD diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1f675563c7..77102c8db2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -642,17 +642,17 @@ std::string get_mac_address() { } std::string get_mac_address_pretty() { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; return std::string(get_mac_address_pretty_into_buffer(buf)); } -void get_mac_address_into_buffer(std::span buf) { +void get_mac_address_into_buffer(std::span buf) { uint8_t mac[6]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } -const char *get_mac_address_pretty_into_buffer(std::span buf) { +const char *get_mac_address_pretty_into_buffer(std::span buf) { uint8_t mac[6]; get_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 83a12b9bf0..6054f03353 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1056,6 +1056,12 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) +/// Buffer size for MAC address in lowercase hex notation (12 hex chars + null terminator) +constexpr size_t MAC_ADDRESS_BUFFER_SIZE = 13; + +/// Buffer size for MAC address in colon-separated uppercase hex notation (17 chars + null terminator) +constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = 18; + /// Get the device MAC address as a string, in lowercase hex notation. std::string get_mac_address(); @@ -1063,13 +1069,14 @@ std::string get_mac_address(); std::string get_mac_address_pretty(); /// Get the device MAC address into the given buffer, in lowercase hex notation. -/// Assumes buffer length is 13 (12 digits for hexadecimal representation followed by null terminator). -void get_mac_address_into_buffer(std::span buf); +/// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null +/// terminator). +void get_mac_address_into_buffer(std::span buf); /// Get the device MAC address into the given buffer, in colon-separated uppercase hex notation. -/// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator). +/// Buffer must be exactly MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator). /// Returns pointer to the buffer for convenience. -const char *get_mac_address_pretty_into_buffer(std::span buf); +const char *get_mac_address_pretty_into_buffer(std::span buf); #ifdef USE_ESP32 /// Set the MAC address to use from the provided byte array (6 bytes). From 74f509c754b9d7e92f867d789b4e11be4372e7bc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:42:06 -0500 Subject: [PATCH 303/320] [core] Add PR template instruction to AI instructions (#12375) Co-authored-by: Claude --- .ai/instructions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 9d48f467cb..994d517f75 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -276,12 +276,12 @@ This document provides essential context for AI models interacting with this pro ## 7. Specific Instructions for AI Collaboration * **Contribution Workflow (Pull Request Process):** - 1. **Fork & Branch:** Create a new branch in your fork. + 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch). 2. **Make Changes:** Adhere to all coding conventions and patterns. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. 4. **Lint:** Run `pre-commit` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. - 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made with the PULL_REQUEST_TEMPLATE.md template filled out correctly. + 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome-docs` repository. From 27e031c25785c3ff745941e6a3d41df339f8f22e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:43:47 -0500 Subject: [PATCH 304/320] [mqtt] Fix logger method case sensitivity error (#12379) Co-authored-by: Claude --- esphome/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 0d50edbc2c..042df12d67 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -192,7 +192,7 @@ def get_esphome_device_ip( data = json.loads(payload) if "name" not in data or data["name"] != dev_name: - _LOGGER.Warn("Wrong device answer") + _LOGGER.warning("Wrong device answer") return dev_ip = [] From e1afd65fae3c951dfc19121962d9788906f8099d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 15:59:27 +0100 Subject: [PATCH 305/320] [api] Store device info strings in flash on ESP8266 (#12173) --- esphome/components/api/api_connection.cpp | 50 +++++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 18d80c46df..d63d6eb2c5 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -14,6 +14,9 @@ #include #include #include +#ifdef USE_ESP8266 +#include +#endif #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" @@ -1471,35 +1474,64 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_compilation_time(App.get_compilation_time_ref()); - // Compile-time StringRef constants for manufacturers + // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) - static constexpr auto MANUFACTURER = StringRef::from_lit("Espressif"); +#define ESPHOME_MANUFACTURER "Espressif" #elif defined(USE_RP2040) - static constexpr auto MANUFACTURER = StringRef::from_lit("Raspberry Pi"); +#define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) - static constexpr auto MANUFACTURER = StringRef::from_lit("Beken"); +#define ESPHOME_MANUFACTURER "Beken" #elif defined(USE_LN882X) - static constexpr auto MANUFACTURER = StringRef::from_lit("Lightning"); +#define ESPHOME_MANUFACTURER "Lightning" #elif defined(USE_NRF52) - static constexpr auto MANUFACTURER = StringRef::from_lit("Nordic Semiconductor"); +#define ESPHOME_MANUFACTURER "Nordic Semiconductor" #elif defined(USE_RTL87XX) - static constexpr auto MANUFACTURER = StringRef::from_lit("Realtek"); +#define ESPHOME_MANUFACTURER "Realtek" #elif defined(USE_HOST) - static constexpr auto MANUFACTURER = StringRef::from_lit("Host"); +#define ESPHOME_MANUFACTURER "Host" #endif - resp.set_manufacturer(MANUFACTURER); +#ifdef USE_ESP8266 + // ESP8266 requires PROGMEM for flash storage, copy to stack for memcpy compatibility + static const char MANUFACTURER_PROGMEM[] PROGMEM = ESPHOME_MANUFACTURER; + char manufacturer_buf[sizeof(MANUFACTURER_PROGMEM)]; + memcpy_P(manufacturer_buf, MANUFACTURER_PROGMEM, sizeof(MANUFACTURER_PROGMEM)); + resp.set_manufacturer(StringRef(manufacturer_buf, sizeof(MANUFACTURER_PROGMEM) - 1)); +#else + static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); + resp.set_manufacturer(MANUFACTURER); +#endif +#undef ESPHOME_MANUFACTURER + +#ifdef USE_ESP8266 + static const char MODEL_PROGMEM[] PROGMEM = ESPHOME_BOARD; + char model_buf[sizeof(MODEL_PROGMEM)]; + memcpy_P(model_buf, MODEL_PROGMEM, sizeof(MODEL_PROGMEM)); + resp.set_model(StringRef(model_buf, sizeof(MODEL_PROGMEM) - 1)); +#else static constexpr auto MODEL = StringRef::from_lit(ESPHOME_BOARD); resp.set_model(MODEL); +#endif #ifdef USE_DEEP_SLEEP resp.has_deep_sleep = deep_sleep::global_has_deep_sleep; #endif #ifdef ESPHOME_PROJECT_NAME +#ifdef USE_ESP8266 + static const char PROJECT_NAME_PROGMEM[] PROGMEM = ESPHOME_PROJECT_NAME; + static const char PROJECT_VERSION_PROGMEM[] PROGMEM = ESPHOME_PROJECT_VERSION; + char project_name_buf[sizeof(PROJECT_NAME_PROGMEM)]; + char project_version_buf[sizeof(PROJECT_VERSION_PROGMEM)]; + memcpy_P(project_name_buf, PROJECT_NAME_PROGMEM, sizeof(PROJECT_NAME_PROGMEM)); + memcpy_P(project_version_buf, PROJECT_VERSION_PROGMEM, sizeof(PROJECT_VERSION_PROGMEM)); + resp.set_project_name(StringRef(project_name_buf, sizeof(PROJECT_NAME_PROGMEM) - 1)); + resp.set_project_version(StringRef(project_version_buf, sizeof(PROJECT_VERSION_PROGMEM) - 1)); +#else static constexpr auto PROJECT_NAME = StringRef::from_lit(ESPHOME_PROJECT_NAME); static constexpr auto PROJECT_VERSION = StringRef::from_lit(ESPHOME_PROJECT_VERSION); resp.set_project_name(PROJECT_NAME); resp.set_project_version(PROJECT_VERSION); #endif +#endif #ifdef USE_WEBSERVER resp.webserver_port = USE_WEBSERVER_PORT; #endif From 88a2e75989822faad0914cf6dce6dc7cec68d910 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Tue, 9 Dec 2025 16:04:10 +0100 Subject: [PATCH 306/320] [packages] Add more information and deprecation deadline for "single package" includes (#12280) --- esphome/components/packages/__init__.py | 11 ++++++++++- tests/component_tests/packages/test_packages.py | 5 +---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 67fd2770e9..15ab11d6b0 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -91,7 +91,16 @@ def validate_source_shorthand(value): def deprecate_single_package(config): _LOGGER.warning( - "Including a single package under `packages:` is deprecated. Use a list instead." + """ + Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. + This method for including packages will go away in 2026.7.0 + Please use a list instead: + + packages: + - !include mypackage.yaml + + See https://github.com/esphome/esphome/pull/12116 + """ ) return config diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index ac4e211fe6..34760587df 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -176,10 +176,7 @@ def test_single_package( assert actual == expected - assert ( - "Including a single package under `packages:` is deprecated. Use a list instead." - in caplog.text - ) + assert "This method for including packages will go away in 2026.7.0" in caplog.text def test_package_append(basic_wifi, basic_esphome): From 443f9c3f57ff93b223046043ae0162c17dfae15c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:10:43 +0100 Subject: [PATCH 307/320] [api] Use StringRef for ActionResponse error message to avoid copy (#12240) --- .../components/api/homeassistant_service.h | 16 +++++--- .../fixtures/api_homeassistant.yaml | 22 +++++++++++ tests/integration/test_api_homeassistant.py | 39 ++++++++++++++++++- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index d00e9e6257..397520fa2e 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -12,6 +12,7 @@ #endif #include "esphome/core/automation.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" namespace esphome::api { @@ -55,14 +56,16 @@ template class TemplatableKeyValuePair { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES // Represents the response data from a Home Assistant action +// Note: This class holds a StringRef to the error_message from the protobuf message. +// The protobuf message must outlive the ActionResponse (which is guaranteed since +// the callback is invoked synchronously while the message is on the stack). class ActionResponse { public: - ActionResponse(bool success, std::string error_message = "") - : success_(success), error_message_(std::move(error_message)) {} + ActionResponse(bool success, const std::string &error_message) : success_(success), error_message_(error_message) {} #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ActionResponse(bool success, std::string error_message, const uint8_t *data, size_t data_len) - : success_(success), error_message_(std::move(error_message)) { + ActionResponse(bool success, const std::string &error_message, const uint8_t *data, size_t data_len) + : success_(success), error_message_(error_message) { if (data == nullptr || data_len == 0) return; this->json_document_ = json::parse_json(data, data_len); @@ -70,7 +73,8 @@ class ActionResponse { #endif bool is_success() const { return this->success_; } - const std::string &get_error_message() const { return this->error_message_; } + // Returns reference to error message - can be implicitly converted to std::string if needed + const StringRef &get_error_message() const { return this->error_message_; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON // Get data as parsed JSON object (const version returns read-only view) @@ -79,7 +83,7 @@ class ActionResponse { protected: bool success_; - std::string error_message_; + StringRef error_message_; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON JsonDocument json_document_; #endif diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml index ce8628977a..8fe23b9a19 100644 --- a/tests/integration/fixtures/api_homeassistant.yaml +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -17,6 +17,7 @@ api: - button.press: test_all_empty_service - button.press: test_rapid_service_calls - button.press: test_read_ha_states + - button.press: test_action_response_error - number.set: id: ha_number value: 42.5 @@ -309,3 +310,24 @@ button: } else { ESP_LOGI("test", "HA Empty State has no value (expected)"); } + + # Test 9: Action response error handling (tests StringRef error message) + - platform: template + name: "Test Action Response Error" + id: test_action_response_error + on_press: + - logger.log: "Testing action response error handling" + - homeassistant.action: + action: nonexistent.action_for_error_test + data: + test_field: "test_value" + on_error: + - lambda: |- + // This tests that StringRef error message works correctly + // The error variable is std::string (converted from StringRef) + ESP_LOGI("test", "Action error received: %s", error.c_str()); + - logger.log: + format: "Action failed with error message length: %d" + args: ['error.size()'] + on_success: + - logger.log: "Action succeeded unexpectedly" diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index 98901fb3f9..1343691f5f 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -81,8 +81,15 @@ async def test_api_homeassistant( "input_number.set_value": loop.create_future(), # ha_number_service_call "switch.turn_on": loop.create_future(), # ha_switch_on_service_call "switch.turn_off": loop.create_future(), # ha_switch_off_service_call + "nonexistent.action_for_error_test": loop.create_future(), # error_test_call } + # Future for error message test + action_error_received_future = loop.create_future() + + # Store client reference for use in callback + client_ref: list = [] # Use list to allow modification in nested function + def on_service_call(service_call: HomeassistantServiceCall) -> None: """Capture HomeAssistant service calls.""" ha_service_calls.append(service_call) @@ -93,6 +100,17 @@ async def test_api_homeassistant( if not future.done(): future.set_result(service_call) + # Immediately respond to the error test call so the test can proceed + # This needs to happen synchronously so ESPHome receives the response + # before logging "=== All tests completed ===" + if service_call.service == "nonexistent.action_for_error_test" and client_ref: + test_error_message = "Test error: action not found" + client_ref[0].send_homeassistant_action_response( + call_id=service_call.call_id, + success=False, + error_message=test_error_message, + ) + def check_output(line: str) -> None: """Check log output for expected messages.""" log_lines.append(line) @@ -131,7 +149,12 @@ async def test_api_homeassistant( if match: ha_number_future.set_result(match.group(1)) - elif not tests_complete_future.done() and tests_complete_pattern.search(line): + # Check for action error message (tests StringRef -> std::string conversion) + # Use separate if (not elif) since this can come after tests_complete + if not action_error_received_future.done() and "Action error received:" in line: + action_error_received_future.set_result(line) + + if not tests_complete_future.done() and tests_complete_pattern.search(line): tests_complete_future.set_result(True) # Run with log monitoring @@ -144,6 +167,9 @@ async def test_api_homeassistant( assert device_info is not None assert device_info.name == "test-ha-api" + # Store client reference for use in service call callback + client_ref.append(client) + # Subscribe to HomeAssistant service calls client.subscribe_service_calls(on_service_call) @@ -292,6 +318,17 @@ async def test_api_homeassistant( assert switch_off_call.service == "switch.turn_off" assert switch_off_call.data["entity_id"] == "switch.test_switch" + # 9. Action response error test (tests StringRef error message) + # The error response is sent automatically in on_service_call callback + # Wait for the error to be logged (proves StringRef -> std::string works) + error_log_line = await asyncio.wait_for( + action_error_received_future, timeout=2.0 + ) + test_error_message = "Test error: action not found" + assert test_error_message in error_log_line, ( + f"Expected error message '{test_error_message}' not found in: {error_log_line}" + ) + except TimeoutError as e: # Show recent log lines for debugging recent_logs = "\n".join(log_lines[-20:]) From 72c74bc0b303d491829e1d12d2e2a0e338a0dd24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:26:11 +0100 Subject: [PATCH 308/320] [api] Store Home Assistant state subscriptions in flash instead of heap (#12008) --- esphome/components/api/api_connection.cpp | 11 +++- esphome/components/api/api_server.cpp | 57 ++++++++++++++----- esphome/components/api/api_server.h | 22 ++++++- .../homeassistant_binary_sensor.cpp | 13 ++--- .../homeassistant_binary_sensor.h | 8 +-- .../number/homeassistant_number.cpp | 23 ++++---- .../number/homeassistant_number.h | 4 +- .../sensor/homeassistant_sensor.cpp | 15 +++-- .../sensor/homeassistant_sensor.h | 8 +-- .../switch/homeassistant_switch.cpp | 6 +- .../switch/homeassistant_switch.h | 4 +- .../text_sensor/homeassistant_text_sensor.cpp | 13 ++--- .../text_sensor/homeassistant_text_sensor.h | 8 +-- .../fixtures/api_custom_services.yaml | 1 + .../custom_api_device_component.cpp | 9 +++ .../custom_api_device_component.h | 3 + tests/integration/test_api_custom_services.py | 11 ++++ 17 files changed, 144 insertions(+), 72 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d63d6eb2c5..4b10610281 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1580,7 +1580,12 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { for (auto &it : this->parent_->get_state_subs()) { - if (it.entity_id == msg.entity_id && it.attribute.value() == msg.attribute) { + // Compare entity_id and attribute with message fields + bool entity_match = (strcmp(it.entity_id, msg.entity_id.c_str()) == 0); + bool attribute_match = (it.attribute != nullptr && strcmp(it.attribute, msg.attribute.c_str()) == 0) || + (it.attribute == nullptr && msg.attribute.empty()); + + if (entity_match && attribute_match) { it.callback(msg.state); } } @@ -1959,8 +1964,8 @@ void APIConnection::process_state_subscriptions_() { SubscribeHomeAssistantStateResponse resp; resp.set_entity_id(StringRef(it.entity_id)); - // Avoid string copy by directly using the optional's value if it exists - resp.set_attribute(it.attribute.has_value() ? StringRef(it.attribute.value()) : StringRef("")); + // Avoid string copy by using the const char* pointer if it exists + resp.set_attribute(it.attribute != nullptr ? StringRef(it.attribute) : StringRef("")); resp.once = it.once; if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1921ca95d4..b1a5ee5d57 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -419,25 +419,56 @@ void APIServer::handle_action_response(uint32_t call_id, bool success, const std #endif // USE_API_HOMEASSISTANT_SERVICES #ifdef USE_API_HOMEASSISTANT_STATES +// Helper to add subscription (reduces duplication) +void APIServer::add_state_subscription_(const char *entity_id, const char *attribute, + std::function f, bool once) { + this->state_subs_.push_back(HomeAssistantStateSubscription{ + .entity_id = entity_id, .attribute = attribute, .callback = std::move(f), .once = once, + // entity_id_dynamic_storage and attribute_dynamic_storage remain nullptr (no heap allocation) + }); +} + +// Helper to add subscription with heap-allocated strings (reduces duplication) +void APIServer::add_state_subscription_(std::string entity_id, optional attribute, + std::function f, bool once) { + HomeAssistantStateSubscription sub; + // Allocate heap storage for the strings + sub.entity_id_dynamic_storage = std::make_unique(std::move(entity_id)); + sub.entity_id = sub.entity_id_dynamic_storage->c_str(); + + if (attribute.has_value()) { + sub.attribute_dynamic_storage = std::make_unique(std::move(attribute.value())); + sub.attribute = sub.attribute_dynamic_storage->c_str(); + } else { + sub.attribute = nullptr; + } + + sub.callback = std::move(f); + sub.once = once; + this->state_subs_.push_back(std::move(sub)); +} + +// New const char* overload (for internal components - zero allocation) +void APIServer::subscribe_home_assistant_state(const char *entity_id, const char *attribute, + std::function f) { + this->add_state_subscription_(entity_id, attribute, std::move(f), false); +} + +void APIServer::get_home_assistant_state(const char *entity_id, const char *attribute, + std::function f) { + this->add_state_subscription_(entity_id, attribute, std::move(f), true); +} + +// Existing std::string overload (for custom_api_device.h - heap allocation) void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f) { - this->state_subs_.push_back(HomeAssistantStateSubscription{ - .entity_id = std::move(entity_id), - .attribute = std::move(attribute), - .callback = std::move(f), - .once = false, - }); + this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), false); } void APIServer::get_home_assistant_state(std::string entity_id, optional attribute, std::function f) { - this->state_subs_.push_back(HomeAssistantStateSubscription{ - .entity_id = std::move(entity_id), - .attribute = std::move(attribute), - .callback = std::move(f), - .once = true, - }); -}; + this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), true); +} const std::vector &APIServer::get_state_subs() const { return this->state_subs_; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 2175d047eb..ad7d8bf63d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -190,16 +190,27 @@ class APIServer : public Component, #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { - std::string entity_id; - optional attribute; + const char *entity_id; // Pointer to flash (internal) or heap (external) + const char *attribute; // Pointer to flash or nullptr (nullptr means no attribute) std::function callback; bool once; + + // Dynamic storage for external components using std::string API (custom_api_device.h) + // These are only allocated when using the std::string overload (nullptr for const char* overload) + std::unique_ptr entity_id_dynamic_storage; + std::unique_ptr attribute_dynamic_storage; }; + // New const char* overload (for internal components - zero allocation) + void subscribe_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + void get_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + + // Existing std::string overload (for custom_api_device.h - heap allocation) void subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f); void get_home_assistant_state(std::string entity_id, optional attribute, std::function f); + const std::vector &get_state_subs() const; #endif #ifdef USE_API_USER_DEFINED_ACTIONS @@ -220,6 +231,13 @@ class APIServer : public Component, bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, const psk_t &active_psk, bool make_active); #endif // USE_API_NOISE +#ifdef USE_API_HOMEASSISTANT_STATES + // Helper methods to reduce code duplication + void add_state_subscription_(const char *entity_id, const char *attribute, std::function f, + bool once); + void add_state_subscription_(std::string entity_id, optional attribute, + std::function f, bool once); +#endif // USE_API_HOMEASSISTANT_STATES // Pointers and pointer-like types first (4 bytes each) std::unique_ptr socket_ = nullptr; #ifdef USE_API_CLIENT_CONNECTED_TRIGGER diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp index a36fcb204a..5652e7d603 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp @@ -19,11 +19,10 @@ void HomeassistantBinarySensor::setup() { case PARSE_ON: case PARSE_OFF: bool new_state = val == PARSE_ON; - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %s", this->entity_id_.c_str(), - this->attribute_.value().c_str(), ONOFF(new_state)); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state %s", this->entity_id_, this->attribute_, ONOFF(new_state)); } else { - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), ONOFF(new_state)); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, ONOFF(new_state)); } if (this->initial_) { this->publish_initial_state(new_state); @@ -37,9 +36,9 @@ void HomeassistantBinarySensor::setup() { } void HomeassistantBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Homeassistant Binary Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantBinarySensor::get_setup_priority() const { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 7026496295..9aec61a370 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; bool initial_{true}; }; diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 9963f3431d..1ca90180eb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -12,21 +12,21 @@ static const char *const TAG = "homeassistant.number"; void HomeassistantNumber::state_changed_(const std::string &state) { auto number_value = parse_number(state); if (!number_value.has_value()) { - ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_.c_str(), state.c_str()); + ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_, state.c_str()); this->publish_state(NAN); return; } if (this->state == number_value.value()) { return; } - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), state.c_str()); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str()); this->publish_state(number_value.value()); } void HomeassistantNumber::min_retrieved_(const std::string &min) { auto min_value = parse_number(min); if (!min_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_.c_str(), min.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); return; } ESP_LOGD(TAG, "'%s': Min retrieved: %s", get_name().c_str(), min.c_str()); @@ -36,7 +36,7 @@ void HomeassistantNumber::min_retrieved_(const std::string &min) { void HomeassistantNumber::max_retrieved_(const std::string &max) { auto max_value = parse_number(max); if (!max_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_.c_str(), max.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); return; } ESP_LOGD(TAG, "'%s': Max retrieved: %s", get_name().c_str(), max.c_str()); @@ -46,7 +46,7 @@ void HomeassistantNumber::max_retrieved_(const std::string &max) { void HomeassistantNumber::step_retrieved_(const std::string &step) { auto step_value = parse_number(step); if (!step_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_.c_str(), step.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_, step.c_str()); return; } ESP_LOGD(TAG, "'%s': Step Retrieved %s", get_name().c_str(), step.c_str()); @@ -55,22 +55,19 @@ void HomeassistantNumber::step_retrieved_(const std::string &step) { void HomeassistantNumber::setup() { api::global_api_server->subscribe_home_assistant_state( - this->entity_id_, nullopt, std::bind(&HomeassistantNumber::state_changed_, this, std::placeholders::_1)); + this->entity_id_, nullptr, std::bind(&HomeassistantNumber::state_changed_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("min"), - std::bind(&HomeassistantNumber::min_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "min", std::bind(&HomeassistantNumber::min_retrieved_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("max"), - std::bind(&HomeassistantNumber::max_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "max", std::bind(&HomeassistantNumber::max_retrieved_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("step"), - std::bind(&HomeassistantNumber::step_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "step", std::bind(&HomeassistantNumber::step_retrieved_, this, std::placeholders::_1)); } void HomeassistantNumber::dump_config() { LOG_NUMBER("", "Homeassistant Number", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); } float HomeassistantNumber::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index 0860b4e91c..0dffc108cb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -11,7 +11,7 @@ namespace homeassistant { class HomeassistantNumber : public number::Number, public Component { public: - void set_entity_id(const std::string &entity_id) { this->entity_id_ = entity_id; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; void dump_config() override; @@ -25,7 +25,7 @@ class HomeassistantNumber : public number::Number, public Component { void control(float value) override; - std::string entity_id_; + const char *entity_id_{nullptr}; }; } // namespace homeassistant } // namespace esphome diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 35e660f7c1..78da47f9a1 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -12,25 +12,24 @@ void HomeassistantSensor::setup() { this->entity_id_, this->attribute_, [this](const std::string &state) { auto val = parse_number(state); if (!val.has_value()) { - ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_.c_str(), state.c_str()); + ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_, state.c_str()); this->publish_state(NAN); return; } - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_.c_str(), - this->attribute_.value().c_str(), *val); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_.c_str(), *val); + ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); } void HomeassistantSensor::dump_config() { LOG_SENSOR("", "Homeassistant Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index 53b288d7d4..d89fc069ff 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantSensor : public sensor::Sensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 27d3705fc2..c4abf2295d 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "homeassistant.switch"; using namespace esphome::switch_; void HomeassistantSwitch::setup() { - api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullopt, [this](const std::string &state) { + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](const std::string &state) { auto val = parse_on_off(state.c_str()); switch (val) { case PARSE_NONE: @@ -20,7 +20,7 @@ void HomeassistantSwitch::setup() { case PARSE_ON: case PARSE_OFF: bool new_state = val == PARSE_ON; - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), ONOFF(new_state)); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, ONOFF(new_state)); this->publish_state(new_state); break; } @@ -29,7 +29,7 @@ void HomeassistantSwitch::setup() { void HomeassistantSwitch::dump_config() { LOG_SWITCH("", "Homeassistant Switch", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); } float HomeassistantSwitch::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index a4da257960..c180b7f98a 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -8,14 +8,14 @@ namespace homeassistant { class HomeassistantSwitch : public switch_::Switch, public Component { public: - void set_entity_id(const std::string &entity_id) { this->entity_id_ = entity_id; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: void write_state(bool state) override; - std::string entity_id_; + const char *entity_id_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp index 9b933fbbbe..6154330a4e 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp @@ -10,20 +10,19 @@ static const char *const TAG = "homeassistant.text_sensor"; void HomeassistantTextSensor::setup() { api::global_api_server->subscribe_home_assistant_state( this->entity_id_, this->attribute_, [this](const std::string &state) { - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state '%s'", this->entity_id_.c_str(), - this->attribute_.value().c_str(), state.c_str()); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state '%s'", this->entity_id_, this->attribute_, state.c_str()); } else { - ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_.c_str(), state.c_str()); + ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str()); } this->publish_state(state); }); } void HomeassistantTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Homeassistant Text Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index ce6b2c2c3f..4d66c65a17 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; }; } // namespace homeassistant diff --git a/tests/integration/fixtures/api_custom_services.yaml b/tests/integration/fixtures/api_custom_services.yaml index a597c74126..827bee93a6 100644 --- a/tests/integration/fixtures/api_custom_services.yaml +++ b/tests/integration/fixtures/api_custom_services.yaml @@ -5,6 +5,7 @@ host: # This is required for CustomAPIDevice to work api: custom_services: true + homeassistant_states: true # Also test that YAML services still work actions: - action: test_yaml_service diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp index c8581b3d2f..01bc7dcd98 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp @@ -17,6 +17,10 @@ void CustomAPIDeviceComponent::setup() { // Test array types register_service(&CustomAPIDeviceComponent::on_service_with_arrays, "custom_service_with_arrays", {"bool_array", "int_array", "float_array", "string_array"}); + + // Test Home Assistant state subscription using std::string API (custom_api_device.h) + // This tests the backward compatibility of the std::string overloads + subscribe_homeassistant_state(&CustomAPIDeviceComponent::on_ha_state_changed, std::string("sensor.custom_test")); } void CustomAPIDeviceComponent::on_test_service() { ESP_LOGI(TAG, "Custom test service called!"); } @@ -48,6 +52,11 @@ void CustomAPIDeviceComponent::on_service_with_arrays(std::vector bool_arr } } +void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::string state) { + ESP_LOGI(TAG, "Home Assistant state changed for %s: %s", entity_id.c_str(), state.c_str()); + ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility"); +} + } // namespace custom_api_device_component } // namespace esphome #endif // USE_API diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h index 92960746d9..0720b9e7de 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h @@ -22,6 +22,9 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice { void on_service_with_arrays(std::vector bool_array, std::vector int_array, std::vector float_array, std::vector string_array); + + // Test Home Assistant state subscription with std::string API + void on_ha_state_changed(std::string entity_id, std::string state); }; } // namespace custom_api_device_component diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index cd33b5a1fc..acf69bf092 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -38,6 +38,7 @@ async def test_api_custom_services( custom_service_future = loop.create_future() custom_args_future = loop.create_future() custom_arrays_future = loop.create_future() + ha_state_future = loop.create_future() # Patterns to match in logs yaml_service_pattern = re.compile(r"YAML service called") @@ -50,6 +51,9 @@ async def test_api_custom_services( custom_arrays_pattern = re.compile( r"Array service called with 2 bools, 3 ints, 2 floats, 2 strings" ) + ha_state_pattern = re.compile( + r"This subscription uses std::string API for backward compatibility" + ) def check_output(line: str) -> None: """Check log output for expected messages.""" @@ -65,6 +69,8 @@ async def test_api_custom_services( custom_args_future.set_result(True) elif not custom_arrays_future.done() and custom_arrays_pattern.search(line): custom_arrays_future.set_result(True) + elif not ha_state_future.done() and ha_state_pattern.search(line): + ha_state_future.set_result(True) # Run with log monitoring async with ( @@ -198,3 +204,8 @@ async def test_api_custom_services( }, ) await asyncio.wait_for(custom_arrays_future, timeout=5.0) + + # Test Home Assistant state subscription (std::string API backward compatibility) + # This verifies that custom_api_device.h can still use std::string overloads + client.send_home_assistant_state("sensor.custom_test", "", "42.5") + await asyncio.wait_for(ha_state_future, timeout=5.0) From e96c37965c6118a679da8198e57955b9c0450854 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:26:27 +0100 Subject: [PATCH 309/320] [wifi] Fix LibreTiny spurious disconnect events aborting connections (#12357) --- .../wifi/wifi_component_libretiny.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index d6bc8e53da..4fd64bdfa3 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -312,6 +312,23 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ char buf[33]; memcpy(buf, it.ssid, it.ssid_len); buf[it.ssid_len] = '\0'; + + // LibreTiny can send spurious disconnect events with empty ssid/bssid during connection. + // These are typically "Association Leave" events that don't indicate actual failures: + // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' + // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' + // [V][wifi_lt]: Connected ssid='WIFI' bssid=... channel=3, authmode=WPA2 PSK + // Without this check, the spurious events set s_sta_connecting=false, causing + // wifi_sta_connect_status_() to return IDLE. The main loop then sees + // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) + // and calls retry_connect(), aborting a connection that may succeed moments later. + // Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout. + if (it.ssid_len == 0 && s_sta_connecting) { + ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", + get_disconnect_reason_str(it.reason)); + break; + } + if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); } else { From 2c0f4d8f806ca17069112be5098dedf56805be5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:35:14 +0100 Subject: [PATCH 310/320] [api] Reduce heap usage for Home Assistant service call string storage (#12151) --- .../components/api/homeassistant_service.h | 32 ++++++++++------ esphome/core/automation.h | 37 ++++++++++++++----- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 397520fa2e..2da6e15362 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -17,6 +17,12 @@ namespace esphome::api { template class TemplatableStringValue : public TemplatableValue { + // Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation) + // rather than being wrapped in a lambda. The base class constructor for const char* is more + // specialized than the templated constructor here, so it should be selected. + static_assert(std::is_constructible_v, const char *>, + "Base class must have const char* constructor for STATIC_STRING optimization"); + private: // Helper to convert value to string - handles the case where value is already a string template static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } @@ -47,10 +53,10 @@ template class TemplatableKeyValuePair { // Keys are always string literals from YAML dictionary keys (e.g., "code", "event") // and never templatable values or lambdas. Only the value parameter can be a lambda/template. - // Using pass-by-value with std::move allows optimal performance for both lvalues and rvalues. - template TemplatableKeyValuePair(std::string key, T value) : key(std::move(key)), value(value) {} + // Using const char* avoids std::string heap allocation - keys remain in flash. + template TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {} - std::string key; + const char *key{nullptr}; TemplatableStringValue value; }; @@ -109,14 +115,15 @@ template class HomeAssistantServiceCallAction : public Action void add_data(K &&key, V &&value) { - this->add_kv_(this->data_, std::forward(key), std::forward(value)); + // Using const char* for keys avoids std::string heap allocation - keys remain in flash. + template void add_data(const char *key, V &&value) { + this->add_kv_(this->data_, key, std::forward(value)); } - template void add_data_template(K &&key, V &&value) { - this->add_kv_(this->data_template_, std::forward(key), std::forward(value)); + template void add_data_template(const char *key, V &&value) { + this->add_kv_(this->data_template_, key, std::forward(value)); } - template void add_variable(K &&key, V &&value) { - this->add_kv_(this->variables_, std::forward(key), std::forward(value)); + template void add_variable(const char *key, V &&value) { + this->add_kv_(this->variables_, key, std::forward(value)); } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -189,10 +196,11 @@ template class HomeAssistantServiceCallAction : public Action void add_kv_(FixedVector> &vec, K &&key, V &&value) { + // Helper to add key-value pairs to FixedVectors + // Keys are always string literals (const char*), values can be lambdas/templates + template void add_kv_(FixedVector> &vec, const char *key, V &&value) { auto &kv = vec.emplace_back(); - kv.key = std::forward(key); + kv.key = key; kv.value = std::forward(value); } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index dacadd35e8..61d2944acf 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -45,6 +45,12 @@ template class TemplatableValue { public: TemplatableValue() : type_(NONE) {} + // For const char* when T is std::string: store pointer directly, no heap allocation + // String remains in flash and is only converted to std::string when value() is called + TemplatableValue(const char *str) requires std::same_as : type_(STATIC_STRING) { + this->static_str_ = str; + } + template TemplatableValue(F value) requires(!std::invocable) : type_(VALUE) { new (&this->value_) T(std::move(value)); } @@ -64,24 +70,28 @@ template class TemplatableValue { // Copy constructor TemplatableValue(const TemplatableValue &other) : type_(other.type_) { - if (type_ == VALUE) { + if (this->type_ == VALUE) { new (&this->value_) T(other.value_); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { this->f_ = new std::function(*other.f_); - } else if (type_ == STATELESS_LAMBDA) { + } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; + } else if (this->type_ == STATIC_STRING) { + this->static_str_ = other.static_str_; } } // Move constructor TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) { - if (type_ == VALUE) { + if (this->type_ == VALUE) { new (&this->value_) T(std::move(other.value_)); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { this->f_ = other.f_; other.f_ = nullptr; - } else if (type_ == STATELESS_LAMBDA) { + } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; + } else if (this->type_ == STATIC_STRING) { + this->static_str_ = other.static_str_; } other.type_ = NONE; } @@ -104,12 +114,12 @@ template class TemplatableValue { } ~TemplatableValue() { - if (type_ == VALUE) { + if (this->type_ == VALUE) { this->value_.~T(); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { delete this->f_; } - // STATELESS_LAMBDA/NONE: no cleanup needed (function pointer or empty, not heap-allocated) + // STATELESS_LAMBDA/STATIC_STRING/NONE: no cleanup needed (pointers, not heap-allocated) } bool has_value() { return this->type_ != NONE; } @@ -122,6 +132,13 @@ template class TemplatableValue { return (*this->f_)(x...); // std::function call case VALUE: return this->value_; + case STATIC_STRING: + // if constexpr required: code must compile for all T, but STATIC_STRING + // can only be set when T is std::string (enforced by constructor constraint) + if constexpr (std::same_as) { + return std::string(this->static_str_); + } + __builtin_unreachable(); case NONE: default: return T{}; @@ -148,12 +165,14 @@ template class TemplatableValue { VALUE, LAMBDA, STATELESS_LAMBDA, + STATIC_STRING, // For const char* when T is std::string - avoids heap allocation } type_; union { T value_; std::function *f_; T (*stateless_f_)(X...); + const char *static_str_; // For STATIC_STRING type }; }; From 329b38fa296b88c1dca4cd25ced351048a0ea85e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 9 Dec 2025 20:30:55 +0100 Subject: [PATCH 311/320] [micronova] Require memory location and address for custom entities (#12371) --- esphome/components/micronova/__init__.py | 24 +++++++++++-------- .../components/micronova/button/__init__.py | 6 ++--- .../components/micronova/sensor/__init__.py | 2 -- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 52fbae2da2..d6ef93cf30 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -40,21 +40,25 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( def MICRONOVA_ADDRESS_SCHEMA( *, - default_memory_location: int, - default_memory_address: int, + default_memory_location: int | None = None, + default_memory_address: int | None = None, is_polling_component: bool, ): + location_key = ( + cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) + if default_memory_location is not None + else cv.Required(CONF_MEMORY_LOCATION) + ) + address_key = ( + cv.Optional(CONF_MEMORY_ADDRESS, default=default_memory_address) + if default_memory_address is not None + else cv.Required(CONF_MEMORY_ADDRESS) + ) schema = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), - # On write requests the write bit (0x80) is added automatically to the location - # Therefore no locations >= 0x80 are allowed - cv.Optional( - CONF_MEMORY_LOCATION, default=default_memory_location - ): cv.hex_int_range(min=0x00, max=0x79), - cv.Optional( - CONF_MEMORY_ADDRESS, default=default_memory_address - ): cv.hex_int_range(min=0x00, max=0xFF), + location_key: cv.hex_int_range(min=0x00, max=0x79), + address_key: cv.hex_int_range(min=0x00, max=0xFF), } ) if is_polling_component: diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 2eda887443..6adf8d96fe 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -24,8 +24,6 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0x20, - default_memory_address=0x7D, is_polling_component=False, ) ) @@ -39,6 +37,6 @@ async def to_code(config): if custom_button_config := config.get(CONF_CUSTOM_BUTTON): bt = await button.new_button(custom_button_config, mv) - cg.add(bt.set_memory_location(custom_button_config.get(CONF_MEMORY_LOCATION))) - cg.add(bt.set_memory_address(custom_button_config.get(CONF_MEMORY_ADDRESS))) + cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) + cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA])) diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 55318a7fff..e53c49aca5 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -118,8 +118,6 @@ CONFIG_SCHEMA = cv.Schema( MicroNovaSensor, ).extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0x00, - default_memory_address=0x00, is_polling_component=True, ) ), From 87142efbb43e1fd7619359f15df27cd7b05f0863 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 06:42:11 +1100 Subject: [PATCH 312/320] [epaper_spi] Set reasonable default update interval (#12331) --- esphome/components/epaper_spi/display.py | 15 +++++++++------ esphome/components/epaper_spi/epaper_spi.cpp | 10 +++++----- esphome/components/epaper_spi/epaper_spi.h | 2 +- .../components/epaper_spi/models/spectra_e6.py | 6 +++--- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index ff5693c206..b7e71a3cae 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -41,6 +41,7 @@ AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] CONF_INIT_SEQUENCE_ID = "init_sequence_id" +CONF_MINIMUM_UPDATE_INTERVAL = "minimum_update_interval" epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi") EPaperBase = epaper_spi_ns.class_( @@ -71,6 +72,9 @@ TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} def model_schema(config): model = MODELS[config[CONF_MODEL]] class_name = epaper_spi_ns.class_(model.class_name, EPaperBase) + minimum_update_interval = update_interval( + model.get_default(CONF_MINIMUM_UPDATE_INTERVAL, "1s") + ) cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required return ( display.FULL_DISPLAY_SCHEMA.extend( @@ -90,9 +94,9 @@ def model_schema(config): { cv.Optional(CONF_ROTATION, default=0): validate_rotation, cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), - cv.Optional( - CONF_UPDATE_INTERVAL, default=cv.UNDEFINED - ): update_interval, + cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): cv.All( + update_interval, cv.Range(min=minimum_update_interval) + ), cv.Optional(CONF_TRANSFORM): cv.Schema( { cv.Required(CONF_MIRROR_X): cv.boolean, @@ -153,9 +157,8 @@ def _final_validate(config): else: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - config[CONF_UPDATE_INTERVAL] = core.TimePeriod( - seconds=60 - ).total_milliseconds + elif CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("1min") return config diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index f6313d33ef..b2e58694c8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -286,7 +286,7 @@ void EPaperBase::initialise_() { * @param y * @return false if the coordinates are out of bounds */ -bool EPaperBase::rotate_coordinates_(int &x, int &y) const { +bool EPaperBase::rotate_coordinates_(int &x, int &y) { if (!this->get_clipping().inside(x, y)) return false; if (this->transform_ & SWAP_XY) @@ -297,6 +297,10 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) const { y = this->height_ - y - 1; if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); return true; } @@ -319,10 +323,6 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } else { this->buffer_[byte_position] = original | pixel_bit; } - this->x_low_ = clamp_at_most(this->x_low_, x); - this->x_high_ = clamp_at_least(this->x_high_, x + 1); - this->y_low_ = clamp_at_most(this->y_low_, y); - this->y_high_ = clamp_at_least(this->y_high_, y + 1); } void EPaperBase::dump_config() { diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 544ea3e9ba..6852416cac 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -106,7 +106,7 @@ class EPaperBase : public Display, void initialise_(); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); - bool rotate_coordinates_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/models/spectra_e6.py b/esphome/components/epaper_spi/models/spectra_e6.py index 42a5a7da72..58015f486e 100644 --- a/esphome/components/epaper_spi/models/spectra_e6.py +++ b/esphome/components/epaper_spi/models/spectra_e6.py @@ -4,8 +4,8 @@ from . import EpaperModel class SpectraE6(EpaperModel): - def __init__(self, name, class_name="EPaperSpectraE6", **kwargs): - super().__init__(name, class_name, **kwargs) + def __init__(self, name, class_name="EPaperSpectraE6", **defaults): + super().__init__(name, class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): @@ -30,7 +30,7 @@ class SpectraE6(EpaperModel): return self.defaults.get(key, fallback) -spectra_e6 = SpectraE6("spectra-e6") +spectra_e6 = SpectraE6("spectra-e6", minimum_update_interval="30s") spectra_e6_7p3 = spectra_e6.extend( "7.3in-Spectra-E6", diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 18226a9f57..50dba94a2b 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -498,12 +498,12 @@ void LvglComponent::setup() { buf_bytes /= MIN_BUFFER_FRAC; buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } + this->buffer_frac_ = frac; if (buffer == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } - this->buffer_frac_ = frac; lv_disp_draw_buf_init(&this->draw_buf_, buffer, nullptr, buffer_pixels); this->disp_drv_.hor_res = display->get_width(); this->disp_drv_.ver_res = display->get_height(); From ad0218fd4097b7212a16a1a8132e85413950a127 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:17:59 +1100 Subject: [PATCH 313/320] [mipi_rgb] Add Waveshare 3.16 (#12309) --- esphome/components/mipi_rgb/display.py | 15 ++-- esphome/components/mipi_rgb/mipi_rgb.cpp | 15 +--- esphome/components/mipi_rgb/models/lilygo.py | 2 - esphome/components/mipi_rgb/models/st7701s.py | 1 - .../components/mipi_rgb/models/waveshare.py | 76 ++++++++++++++++++- 5 files changed, 81 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 2d2e022045..61dbeb8ed4 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -24,7 +24,7 @@ from esphome.components.mipi import ( CONF_VSYNC_BACK_PORCH, CONF_VSYNC_FRONT_PORCH, CONF_VSYNC_PULSE_WIDTH, - MODE_BGR, + MODE_RGB, PIXEL_MODE_16BIT, PIXEL_MODE_18BIT, DriverChip, @@ -157,7 +157,7 @@ def model_schema(config): model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list( pins.gpio_output_pin_schema ), - model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True), + model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True), model.option(CONF_DRAW_ROUNDING, 2): power_of_two, model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of( *pixel_modes, lower=True @@ -280,14 +280,9 @@ async def to_code(config): red_pins = config[CONF_DATA_PINS][CONF_RED] green_pins = config[CONF_DATA_PINS][CONF_GREEN] blue_pins = config[CONF_DATA_PINS][CONF_BLUE] - if config[CONF_COLOR_ORDER] == "BGR": - dpins.extend(red_pins) - dpins.extend(green_pins) - dpins.extend(blue_pins) - else: - dpins.extend(blue_pins) - dpins.extend(green_pins) - dpins.extend(red_pins) + dpins.extend(blue_pins) + dpins.extend(green_pins) + dpins.extend(red_pins) # swap bytes to match big-endian format dpins = dpins[8:16] + dpins[0:8] else: diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 74eedae4f4..d5d1caf6d2 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -371,17 +371,10 @@ void MipiRgb::dump_config() { get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str()); - if (this->madctl_ & MADCTL_BGR) { - this->dump_pins_(8, 13, "Blue", 0); - this->dump_pins_(13, 16, "Green", 0); - this->dump_pins_(0, 3, "Green", 3); - this->dump_pins_(3, 8, "Red", 0); - } else { - this->dump_pins_(8, 13, "Red", 0); - this->dump_pins_(13, 16, "Green", 0); - this->dump_pins_(0, 3, "Green", 3); - this->dump_pins_(3, 8, "Blue", 0); - } + this->dump_pins_(8, 13, "Blue", 0); + this->dump_pins_(13, 16, "Green", 0); + this->dump_pins_(0, 3, "Green", 3); + this->dump_pins_(3, 8, "Red", 0); } } // namespace mipi_rgb diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index 109dc42af6..c0e91cd8ae 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -7,7 +7,6 @@ ST7701S( "T-PANEL-S3", width=480, height=480, - color_order="BGR", invert_colors=False, swap_xy=UNDEFINED, spi_mode="MODE3", @@ -56,7 +55,6 @@ t_rgb = ST7701S( "T-RGB-2.1", width=480, height=480, - color_order="BGR", pixel_mode="18bit", invert_colors=False, swap_xy=UNDEFINED, diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 0b0a9548ca..3c66380d04 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -82,7 +82,6 @@ st7701s.extend( "MAKERFABS-4", width=480, height=480, - color_order="RGB", invert_colors=True, pixel_mode="18bit", cs_pin=1, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index 0fc765fd52..cd1fc341ef 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,13 +1,13 @@ -from esphome.components.mipi import DriverChip +from esphome.components.mipi import DriverChip, delay from esphome.config_validation import UNDEFINED from .st7701s import st7701s +# fmt: off wave_4_3 = DriverChip( "ESP32-S3-TOUCH-LCD-4.3", swap_xy=UNDEFINED, initsequence=(), - color_order="RGB", width=800, height=480, pclk_frequency="16MHz", @@ -55,10 +55,9 @@ wave_4_3.extend( ) st7701s.extend( - "WAVESHARE-4-480x480", + "WAVESHARE-4-480X480", data_rate="2MHz", spi_mode="MODE3", - color_order="BGR", pixel_mode="18bit", width=480, height=480, @@ -76,3 +75,72 @@ st7701s.extend( "blue": [5, 45, 48, 47, 21], }, ) + +st7701s.extend( + "WAVESHARE-3.16-320X820", + width=320, + height=820, + de_pin=40, + hsync_pin=38, + vsync_pin=39, + pclk_pin=41, + cs_pin={ + "number": 0, + "ignore_strapping_warning": True, + }, + pclk_frequency="18MHz", + reset_pin=16, + hsync_back_porch=30, + hsync_front_porch=30, + hsync_pulse_width=6, + vsync_back_porch=20, + vsync_front_porch=20, + vsync_pulse_width=40, + data_pins={ + "red": [17, 46, 3, 8, 18], + "green": [14, 13, 12, 11, 10, 9], + "blue": [21, 5, 45, 48, 47], + }, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0xE5, 0x02), + (0xC1, 0x15, 0x0A), + (0xC2, 0x07, 0x02), + (0xCC, 0x10), + (0xB0, 0x00, 0x08, 0x51, 0x0D, 0xCE, 0x06, 0x00, 0x08, 0x08, 0x24, 0x05, 0xD0, 0x0F, 0x6F, 0x36, 0x1F), + (0xB1, 0x00, 0x10, 0x4F, 0x0C, 0x11, 0x05, 0x00, 0x07, 0x07, 0x18, 0x02, 0xD3, 0x11, 0x6E, 0x34, 0x1F), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x4D), + (0xB1, 0x37), + (0xB2, 0x87), + (0xB3, 0x80), + (0xB5, 0x4A), + (0xB7, 0x85), + (0xB8, 0x21), + (0xB9, 0x00, 0x13), + (0xC0, 0x09), + (0xC1, 0x78), + (0xC2, 0x78), + (0xD0, 0x88), + (0xE0, 0x80, 0x00, 0x02), + (0xE1, 0x0F, 0xA0, 0x00, 0x00, 0x10, 0xA0, 0x00, 0x00, 0x00, 0x60, 0x60), + (0xE2, 0x30, 0x30, 0x60, 0x60, 0x45, 0xA0, 0x00, 0x00, 0x46, 0xA0, 0x00, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x33, 0x33), + (0xE4, 0x44, 0x44), + (0xE5, 0x0F, 0x4A, 0xA0, 0xA0, 0x11, 0x4A, 0xA0, 0xA0, 0x13, 0x4A, 0xA0, 0xA0, 0x15, 0x4A, 0xA0, 0xA0), + (0xE6, 0x00, 0x00, 0x33, 0x33), + (0xE7, 0x44, 0x44), + (0xE8, 0x10, 0x4A, 0xA0, 0xA0, 0x12, 0x4A, 0xA0, 0xA0, 0x14, 0x4A, 0xA0, 0xA0, 0x16, 0x4A, 0xA0, 0xA0), + (0xEB, 0x02, 0x00, 0x4E, 0x4E, 0xEE, 0x44, 0x00), + (0xED, 0xFF, 0xFF, 0x04, 0x56, 0x72, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x27, 0x65, 0x40, 0xFF, 0xFF), + (0xEF, 0x08, 0x08, 0x08, 0x40, 0x3F, 0x64), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xE8, 0x00, 0x0E), + (0xE8, 0x00, 0x0C), + delay(10), + (0xE8, 0x00, 0x00), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00), + ) +) From 1e23b10eedc852e6380c1ae9a8736a3d990e6961 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:02:42 +0000 Subject: [PATCH 314/320] Bump aioesphomeapi from 43.1.0 to 43.2.1 (#12385) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5596f050af..71aaf47ddb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==43.1.0 +aioesphomeapi==43.2.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import From 5919355d182987030fcb15c27626ea1256ca64f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:26:24 +0100 Subject: [PATCH 315/320] [ci] Allow memory impact target branch build to fail without blocking CI (#12381) --- .github/workflows/ci.yml | 16 ++--- script/ci_memory_impact_comment.py | 72 +++++++++++++++++-- .../ci_memory_impact_target_unavailable.j2 | 19 +++++ 3 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 script/templates/ci_memory_impact_target_unavailable.j2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01689d3697..03eadb5f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -959,13 +959,13 @@ jobs: - memory-impact-comment if: always() steps: - - name: Success - if: ${{ !(contains(needs.*.result, 'failure')) }} - run: exit 0 - - name: Failure - if: ${{ contains(needs.*.result, 'failure') }} + - name: Check job results env: - JSON_DOC: ${{ toJSON(needs) }} + NEEDS_JSON: ${{ toJSON(needs) }} run: | - echo $JSON_DOC | jq - exit 1 + # memory-impact-target-branch is allowed to fail without blocking CI. + # This job builds the target branch (dev/beta/release) which may fail because: + # 1. The target branch has a build issue independent of this PR + # 2. This PR fixes a build issue on the target branch + # In either case, we only care that the PR branch builds successfully. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 1331a44d03..a296130645 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -215,6 +215,20 @@ def prepare_symbol_changes_data( } +def format_components_str(components: list[str]) -> str: + """Format a list of components for display. + + Args: + components: List of component names + + Returns: + Formatted string with backtick-quoted component names + """ + if len(components) == 1: + return f"`{components[0]}`" + return ", ".join(f"`{c}`" for c in sorted(components)) + + def prepare_component_breakdown_data( target_analysis: dict | None, pr_analysis: dict | None ) -> list[tuple[str, int, int, int]] | None: @@ -316,11 +330,10 @@ def create_comment_body( } # Format components list + context["components_str"] = format_components_str(components) if len(components) == 1: - context["components_str"] = f"`{components[0]}`" context["config_note"] = "a representative test configuration" else: - context["components_str"] = ", ".join(f"`{c}`" for c in sorted(components)) context["config_note"] = ( f"a merged configuration with {len(components)} components" ) @@ -502,6 +515,43 @@ def post_or_update_comment(pr_number: str, comment_body: str) -> None: print("Comment posted/updated successfully", file=sys.stderr) +def create_target_unavailable_comment( + pr_data: dict, +) -> str: + """Create a comment body when target branch data is unavailable. + + This happens when the target branch (dev/beta/release) fails to build. + This can occur because: + 1. The target branch has a build issue independent of this PR + 2. This PR fixes a build issue on the target branch + In either case, we only care that the PR branch builds successfully. + + Args: + pr_data: Dictionary with PR branch analysis results + + Returns: + Formatted comment body + """ + components = pr_data.get("components", []) + platform = pr_data.get("platform", "unknown") + pr_ram = pr_data.get("ram_bytes", 0) + pr_flash = pr_data.get("flash_bytes", 0) + + env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.get_template("ci_memory_impact_target_unavailable.j2") + return template.render( + comment_marker=COMMENT_MARKER, + components_str=format_components_str(components), + platform=platform, + pr_ram=format_bytes(pr_ram), + pr_flash=format_bytes(pr_flash), + ) + + def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( @@ -523,15 +573,25 @@ def main() -> int: # Load analysis JSON files (all data comes from JSON for security) target_data: dict | None = load_analysis_json(args.target_json) - if not target_data: - print("Error: Failed to load target analysis JSON", file=sys.stderr) - sys.exit(1) - pr_data: dict | None = load_analysis_json(args.pr_json) + + # PR data is required - if the PR branch can't build, that's a real error if not pr_data: print("Error: Failed to load PR analysis JSON", file=sys.stderr) sys.exit(1) + # Target data is optional - target branch (dev) may fail to build because: + # 1. The target branch has a build issue independent of this PR + # 2. This PR fixes a build issue on the target branch + if not target_data: + print( + "Warning: Target branch analysis unavailable, posting limited comment", + file=sys.stderr, + ) + comment_body = create_target_unavailable_comment(pr_data) + post_or_update_comment(args.pr_number, comment_body) + return 0 + # Extract detailed analysis if available target_analysis: dict | None = None pr_analysis: dict | None = None diff --git a/script/templates/ci_memory_impact_target_unavailable.j2 b/script/templates/ci_memory_impact_target_unavailable.j2 new file mode 100644 index 0000000000..542bd49d85 --- /dev/null +++ b/script/templates/ci_memory_impact_target_unavailable.j2 @@ -0,0 +1,19 @@ +{{ comment_marker }} +## Memory Impact Analysis + +**Components:** {{ components_str }} +**Platform:** `{{ platform }}` + +| Metric | This PR | +|--------|---------| +| **RAM** | {{ pr_ram }} | +| **Flash** | {{ pr_flash }} | + +> ⚠️ **Target branch comparison unavailable** - The target branch failed to build. +> This can happen when the target branch has a build issue, or when this PR fixes a build issue on the target branch. +> The PR branch compiled successfully with the memory usage shown above. + +--- +> **Note:** This analysis measures **static RAM and Flash usage** only (compile-time allocation). + +*This analysis runs automatically when components change.* From 608f834eaab1d22d0723e1229ec6ce3a4e419d47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:49:29 +0100 Subject: [PATCH 316/320] [ci] Isolate usb_cdc_acm in component tests due to tinyusb/usb_host conflict (#12392) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 27a36f889f..427602dff2 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -87,6 +87,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } From 3a6edbc2c74c7dbf1451d51cb162a155d6487917 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:49:44 +0100 Subject: [PATCH 317/320] [micronova] Fix test UART package key to match directory name (#12391) --- tests/components/micronova/test.esp32-idf.yaml | 2 +- tests/components/micronova/test.esp8266-ard.yaml | 2 +- tests/components/micronova/test.rp2040-ard.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/micronova/test.esp32-idf.yaml b/tests/components/micronova/test.esp32-idf.yaml index b3e4714bc3..6e5602818f 100644 --- a/tests/components/micronova/test.esp32-idf.yaml +++ b/tests/components/micronova/test.esp32-idf.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO13 packages: - uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.esp8266-ard.yaml b/tests/components/micronova/test.esp8266-ard.yaml index 04030801e3..80792813ad 100644 --- a/tests/components/micronova/test.esp8266-ard.yaml +++ b/tests/components/micronova/test.esp8266-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO15 packages: - uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.rp2040-ard.yaml b/tests/components/micronova/test.rp2040-ard.yaml index 67110f25b0..f069760378 100644 --- a/tests/components/micronova/test.rp2040-ard.yaml +++ b/tests/components/micronova/test.rp2040-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml <<: !include common.yaml From 36423994600afcbe276f55a4267d44a09c16a3b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Dec 2025 00:50:26 +0100 Subject: [PATCH 318/320] [tests] Fix clang-tidy warnings in custom_api_device_component fixture (#12390) --- .../custom_api_device_component/custom_api_device_component.cpp | 1 + .../custom_api_device_component/custom_api_device_component.h | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp index 01bc7dcd98..c86ab99242 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp @@ -52,6 +52,7 @@ void CustomAPIDeviceComponent::on_service_with_arrays(std::vector bool_arr } } +// NOLINTNEXTLINE(performance-unnecessary-value-param) void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::string state) { ESP_LOGI(TAG, "Home Assistant state changed for %s: %s", entity_id.c_str(), state.c_str()); ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility"); diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h index 0720b9e7de..4d519d3ed1 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h @@ -24,6 +24,7 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice { std::vector float_array, std::vector string_array); // Test Home Assistant state subscription with std::string API + // NOLINTNEXTLINE(performance-unnecessary-value-param) void on_ha_state_changed(std::string entity_id, std::string state); }; From 9f2693ead5405aa653fce7f8451428666f6b958c Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Wed, 10 Dec 2025 00:59:58 +0100 Subject: [PATCH 319/320] [core] Packages refactor and conditional package inclusion (package refactor part 1) (#11605) Co-authored-by: J. Nick Koston --- esphome/components/packages/__init__.py | 151 ++++++++--- esphome/config.py | 10 +- .../component_tests/packages/test_packages.py | 238 ++++++++++++++++-- .../06-package_merging.approved.yaml | 43 ++++ .../06-package_merging.input.yaml | 61 +++++ tests/unit_tests/test_substitutions.py | 6 +- 6 files changed, 446 insertions(+), 63 deletions(-) create mode 100644 tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml create mode 100644 tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 15ab11d6b0..6d353ccf11 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,9 @@ +from collections import UserDict +from collections.abc import Callable +from functools import reduce import logging from pathlib import Path +from typing import Any from esphome import git, yaml_util from esphome.components.substitutions.jinja import has_jinja @@ -15,6 +19,7 @@ from esphome.const import ( CONF_PATH, CONF_REF, CONF_REFRESH, + CONF_SUBSTITUTIONS, CONF_URL, CONF_USERNAME, CONF_VARS, @@ -27,32 +32,43 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = CONF_PACKAGES -def valid_package_contents(package_config: dict): - """Validates that a package_config that will be merged looks as much as possible to a valid config - to fail early on obvious mistakes.""" - if isinstance(package_config, dict): - if CONF_URL in package_config: - # If a URL key is found, then make sure the config conforms to a remote package schema: - return REMOTE_PACKAGE_SCHEMA(package_config) +def validate_has_jinja(value: Any): + if not isinstance(value, str) or not has_jinja(value): + raise cv.Invalid("string does not contain Jinja syntax") + return value - # Validate manually since Voluptuous would regenerate dicts and lose metadata - # such as ESPHomeDataBase - for k, v in package_config.items(): - if not isinstance(k, str): - raise cv.Invalid("Package content keys must be strings") - if isinstance(v, (dict, list, Remove)): - continue # e.g. script: [], psram: !remove, logger: {level: debug} - if v is None: - continue # e.g. web_server: - if isinstance(v, str) and has_jinja(v): - # e.g: remote package shorthand: - # package_name: github://esphome/repo/file.yaml@${ branch } - continue - raise cv.Invalid("Invalid component content in package definition") - return package_config +def valid_package_contents(allow_jinja: bool = True) -> Callable[[Any], dict]: + """Returns a validator that checks if a package_config that will be merged looks as + much as possible to a valid config to fail early on obvious mistakes.""" - raise cv.Invalid("Package contents must be a dict") + def validator(package_config: dict) -> dict: + if isinstance(package_config, dict): + if CONF_URL in package_config: + # If a URL key is found, then make sure the config conforms to a remote package schema: + return REMOTE_PACKAGE_SCHEMA(package_config) + + # Validate manually since Voluptuous would regenerate dicts and lose metadata + # such as ESPHomeDataBase + for k, v in package_config.items(): + if not isinstance(k, str): + raise cv.Invalid("Package content keys must be strings") + if isinstance(v, (dict, list, Remove)): + continue # e.g. script: [], psram: !remove, logger: {level: debug} + if v is None: + continue # e.g. web_server: + if allow_jinja and isinstance(v, str) and has_jinja(v): + # e.g: remote package shorthand: + # package_name: github://esphome/repo/file.yaml@${ branch }, or: + # switch: ${ expression that evals to a switch } + continue + + raise cv.Invalid("Invalid component content in package definition") + return package_config + + raise cv.Invalid("Package contents must be a dict") + + return validator def expand_file_to_files(config: dict): @@ -142,7 +158,10 @@ REMOTE_PACKAGE_SCHEMA = cv.All( PACKAGE_SCHEMA = cv.Any( # A package definition is either: validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or - valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} + validate_has_jinja, # a Jinja string that may resolve to a package, or + valid_package_contents( + allow_jinja=True + ), # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} # which will have to be fully validated later as per each component's schema. ) @@ -235,32 +254,84 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: return {"packages": packages} -def _process_package(package_config, config, skip_update: bool = False): - recursive_package = package_config - if CONF_URL in package_config: - package_config = _process_remote_package(package_config, skip_update) - if isinstance(package_config, dict): - recursive_package = do_packages_pass(package_config, skip_update) - return merge_config(recursive_package, config) - - -def do_packages_pass(config: dict, skip_update: bool = False): +def _walk_packages( + config: dict, callback: Callable[[dict], dict], validate_deprecated: bool = True +) -> dict: if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] - with cv.prepend_path(CONF_PACKAGES): + + # The following block and `validate_deprecated` parameter can be safely removed + # once single-package deprecation is effective + if validate_deprecated: packages = CONFIG_SCHEMA(packages) + + with cv.prepend_path(CONF_PACKAGES): if isinstance(packages, dict): for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): - config = _process_package(package_config, config, skip_update) + package_config = callback(package_config) + packages[package_name] = _walk_packages(package_config, callback) elif isinstance(packages, list): - for package_config in reversed(packages): - config = _process_package(package_config, config, skip_update) + for idx in reversed(range(len(packages))): + with cv.prepend_path(idx): + package_config = callback(packages[idx]) + packages[idx] = _walk_packages(package_config, callback) else: raise cv.Invalid( f"Packages must be a key to value mapping or list, got {type(packages)} instead" ) - - del config[CONF_PACKAGES] + config[CONF_PACKAGES] = packages + return config + + +def do_packages_pass(config: dict, skip_update: bool = False) -> dict: + """Processes, downloads and validates all packages in the config. + Also extracts and merges all substitutions found in packages into the main config substitutions. + """ + if CONF_PACKAGES not in config: + return config + + substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + + def process_package_callback(package_config: dict) -> dict: + """This will be called for each package found in the config.""" + package_config = PACKAGE_SCHEMA(package_config) + if isinstance(package_config, str): + return package_config # Jinja string, skip processing + if CONF_URL in package_config: + package_config = _process_remote_package(package_config, skip_update) + # Extract substitutions from the package and merge them into the main substitutions: + substitutions.data = merge_config( + package_config.pop(CONF_SUBSTITUTIONS, {}), substitutions.data + ) + return package_config + + _walk_packages(config, process_package_callback) + + if substitutions: + config[CONF_SUBSTITUTIONS] = substitutions.data + + return config + + +def merge_packages(config: dict) -> dict: + """Merges all packages into the main config and removes the `packages:` key.""" + if CONF_PACKAGES not in config: + return config + + # Build flat list of all package configs to merge in priority order: + merge_list: list[dict] = [] + + validate_package = valid_package_contents(allow_jinja=False) + + def process_package_callback(package_config: dict) -> dict: + """This will be called for each package found in the config.""" + merge_list.append(validate_package(package_config)) + return package_config + + _walk_packages(config, process_package_callback, validate_deprecated=False) + # Merge all packages into the main config: + config = reduce(lambda new, old: merge_config(old, new), merge_list, config) + del config[CONF_PACKAGES] return config diff --git a/esphome/config.py b/esphome/config.py index 1c4cdd93c6..694716be34 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1012,14 +1012,20 @@ def validate_config( CORE.raw_config = config - # 1.1. Resolve !extend and !remove and check for REPLACEME + # 1.1. Merge packages + if CONF_PACKAGES in config: + from esphome.components.packages import merge_packages + + config = merge_packages(config) + + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: resolve_extend_remove(config) except vol.Invalid as err: result.add_error(err) - # 1.2. Load external_components + # 1.3. Load external_components if CONF_EXTERNAL_COMPONENTS in config: from esphome.components.external_components import do_external_components_pass diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 34760587df..3829e540d7 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass +from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -27,6 +27,7 @@ from esphome.const import ( CONF_REFRESH, CONF_SENSOR, CONF_SSID, + CONF_SUBSTITUTIONS, CONF_UPDATE_INTERVAL, CONF_URL, CONF_VARS, @@ -68,11 +69,12 @@ def fixture_basic_esphome(): def packages_pass(config): """Wrapper around packages_pass that also resolves Extend and Remove.""" config = do_packages_pass(config) + config = merge_packages(config) resolve_extend_remove(config) return config -def test_package_unused(basic_esphome, basic_wifi): +def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. """ @@ -82,7 +84,7 @@ def test_package_unused(basic_esphome, basic_wifi): assert actual == config -def test_package_invalid_dict(basic_esphome, basic_wifi): +def test_package_invalid_dict(basic_esphome, basic_wifi) -> None: """ If a url: key is present, it's expected to be well-formed remote package spec. Ensure an error is raised if not. Any other simple dict passed as a package will be merged as usual but may fail later validation. @@ -107,7 +109,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): ], ], ) -def test_package_shorthand(packages): +def test_package_shorthand(packages) -> None: CONFIG_SCHEMA(packages) @@ -133,12 +135,12 @@ def test_package_shorthand(packages): [3], ], ) -def test_package_invalid(packages): +def test_package_invalid(packages) -> None: with pytest.raises(cv.Invalid): CONFIG_SCHEMA(packages) -def test_package_include(basic_wifi, basic_esphome): +def test_package_include(basic_wifi, basic_esphome) -> None: """ Tests the simple case where an independent config present in a package is added to the top-level config as is. @@ -159,7 +161,7 @@ def test_single_package( basic_esphome, basic_wifi, caplog: pytest.LogCaptureFixture, -): +) -> None: """ Tests the simple case where a single package is added to the top-level config as is. In this test, the CONF_WIFI config is expected to be simply added to the top-level config. @@ -179,7 +181,7 @@ def test_single_package( assert "This method for including packages will go away in 2026.7.0" in caplog.text -def test_package_append(basic_wifi, basic_esphome): +def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -204,7 +206,7 @@ def test_package_append(basic_wifi, basic_esphome): assert actual == expected -def test_package_override(basic_wifi, basic_esphome): +def test_package_override(basic_wifi, basic_esphome) -> None: """ Ensures that the top-level configuration takes precedence over duplicate keys defined in a package. @@ -228,7 +230,7 @@ def test_package_override(basic_wifi, basic_esphome): assert actual == expected -def test_multiple_package_order(): +def test_multiple_package_order() -> None: """ Ensures that mutiple packages are merged in order. """ @@ -257,7 +259,7 @@ def test_multiple_package_order(): assert actual == expected -def test_package_list_merge(): +def test_package_list_merge() -> None: """ Ensures lists defined in both a package and the top-level config are merged correctly """ @@ -313,7 +315,7 @@ def test_package_list_merge(): assert actual == expected -def test_package_list_merge_by_id(): +def test_package_list_merge_by_id() -> None: """ Ensures that components with matching IDs are merged correctly. @@ -391,7 +393,7 @@ def test_package_list_merge_by_id(): assert actual == expected -def test_package_merge_by_id_with_list(): +def test_package_merge_by_id_with_list() -> None: """ Ensures that components with matching IDs are merged correctly when their configuration contains lists. @@ -430,7 +432,7 @@ def test_package_merge_by_id_with_list(): assert actual == expected -def test_package_merge_by_missing_id(): +def test_package_merge_by_missing_id() -> None: """ Ensures that a validation error is thrown when trying to extend a missing ID. """ @@ -466,7 +468,7 @@ def test_package_merge_by_missing_id(): assert error_raised -def test_package_list_remove_by_id(): +def test_package_list_remove_by_id() -> None: """ Ensures that components with matching IDs are removed correctly. @@ -517,7 +519,7 @@ def test_package_list_remove_by_id(): assert actual == expected -def test_multiple_package_list_remove_by_id(): +def test_multiple_package_list_remove_by_id() -> None: """ Ensures that components with matching IDs are removed correctly. @@ -563,7 +565,7 @@ def test_multiple_package_list_remove_by_id(): assert actual == expected -def test_package_dict_remove_by_id(basic_wifi, basic_esphome): +def test_package_dict_remove_by_id(basic_wifi, basic_esphome) -> None: """ Ensures that components with missing IDs are removed from dict. Ensures that the top-level configuration takes precedence over duplicate keys defined in a package. @@ -584,7 +586,7 @@ def test_package_dict_remove_by_id(basic_wifi, basic_esphome): assert actual == expected -def test_package_remove_by_missing_id(): +def test_package_remove_by_missing_id() -> None: """ Ensures that components with missing IDs are not merged. """ @@ -632,7 +634,7 @@ def test_package_remove_by_missing_id(): @patch("esphome.git.clone_or_update") def test_remote_packages_with_files_list( mock_clone_or_update, mock_is_file, mock_load_yaml -): +) -> None: """ Ensures that packages are loaded as mixed list of dictionary and strings """ @@ -704,7 +706,7 @@ def test_remote_packages_with_files_list( @patch("esphome.git.clone_or_update") def test_remote_packages_with_files_and_vars( mock_clone_or_update, mock_is_file, mock_load_yaml -): +) -> None: """ Ensures that packages are loaded as mixed list of dictionary and strings with vars """ @@ -793,3 +795,199 @@ def test_remote_packages_with_files_and_vars( actual = packages_pass(config) assert actual == expected + + +def test_packages_merge_substitutions() -> None: + """ + Tests that substitutions from packages in a complex package hierarchy + are extracted and merged into the top-level config. + """ + config = { + CONF_SUBSTITUTIONS: { + "a": 1, + "b": 2, + "c": 3, + }, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + CONF_SUBSTITUTIONS: { + "a": 10, + "e": 5, + }, + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + CONF_SUBSTITUTIONS: { + "b": 20, + "d": 4, + }, + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + CONF_SUBSTITUTIONS: { + "b": 20, + "d": 6, + }, + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + + expected = { + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + + actual = do_packages_pass(config) + assert actual == expected + + +def test_package_merge() -> None: + """ + Tests that all packages are merged into the top-level config. + """ + config = { + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + expected = { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + {"platform": "template", "id": "sensor2"}, + {"platform": "template", "id": "sensor3"}, + {"platform": "template", "id": "sensor4"}, + ], + "logger": {"level": "VERBOSE"}, + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + } + actual = merge_packages(config) + + assert actual == expected + + +@pytest.mark.parametrize( + "invalid_package", + [ + 6, + "some string", + ["some string"], + None, + True, + {"some_component": 8}, + {3: 2}, + {"some_component": r"${unevaluated expression}"}, + ], +) +def test_package_merge_invalid(invalid_package) -> None: + """ + Tests that trying to merge an invalid package raises an error. + """ + config = { + CONF_PACKAGES: { + "some_package": invalid_package, + }, + } + + with pytest.raises(cv.Invalid): + merge_packages(config) diff --git a/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml new file mode 100644 index 0000000000..3fbf5660d5 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml @@ -0,0 +1,43 @@ +fancy_component: &id001 + - id: component9 + value: 9 +some_component: + - id: component1 + value: 1 + - id: component2 + value: 2 + - id: component3 + value: 3 + - id: component4 + value: 4 + - id: component5 + value: 79 + power: 200 + - id: component6 + value: 6 + - id: component7 + value: 7 +switch: &id002 + - platform: gpio + id: switch1 + pin: 12 + - platform: gpio + id: switch2 + pin: 13 +display: + - platform: ili9xxx + dimensions: + width: 100 + height: 480 +substitutions: + extended_component: component5 + package_options: + alternative_package: + alternative_component: + - id: component8 + value: 8 + fancy_package: + fancy_component: *id001 + pin: 12 + some_switches: *id002 + package_selection: fancy_package diff --git a/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml b/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml new file mode 100644 index 0000000000..d937a89306 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml @@ -0,0 +1,61 @@ +substitutions: + package_options: + alternative_package: + alternative_component: + - id: component8 + value: 8 + fancy_package: + fancy_component: + - id: component9 + value: 9 + + pin: 12 + some_switches: + - platform: gpio + id: switch1 + pin: ${pin} + - platform: gpio + id: switch2 + pin: ${pin+1} + + package_selection: fancy_package + +packages: + - ${ package_options[package_selection] } + - some_component: + - id: component1 + value: 1 + - some_component: + - id: component2 + value: 2 + - switch: ${ some_switches } + - packages: + package_with_defaults: !include + file: display.yaml + vars: + native_width: 100 + high_dpi: false + my_package: + packages: + - packages: + special_package: + substitutions: + extended_component: component5 + some_component: + - id: component3 + value: 3 + some_component: + - id: component4 + value: 4 + - id: !extend ${ extended_component } + power: 200 + value: 79 + some_component: + - id: component5 + value: 5 + +some_component: + - id: component6 + value: 6 + - id: component7 + value: 7 diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index cba1e398c3..1d8cb7631d 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -8,7 +8,7 @@ import pytest from esphome import config as config_module, yaml_util from esphome.components import substitutions -from esphome.components.packages import do_packages_pass +from esphome.components.packages import do_packages_pass, merge_packages from esphome.config import resolve_extend_remove from esphome.config_helpers import merge_config from esphome.const import CONF_SUBSTITUTIONS @@ -74,6 +74,8 @@ def verify_database(value: Any, path: str = "") -> str | None: return None if isinstance(value, dict): for k, v in value.items(): + if path == "" and k == CONF_SUBSTITUTIONS: + return None # ignore substitutions key at top level since it is merged. key_result = verify_database(k, f"{path}/{k}") if key_result is not None: return key_result @@ -144,6 +146,8 @@ def test_substitutions_fixtures( substitutions.do_substitution_pass(config, command_line_substitutions) + config = merge_packages(config) + resolve_extend_remove(config) verify_database_result = verify_database(config) if verify_database_result is not None: From 26770e09dcfda0df89f9e2448b087b019539db58 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:08:35 -0500 Subject: [PATCH 320/320] Bump version to 2025.12.0b1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a19120b9da..ecb156d1f3 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.12.0-dev +PROJECT_NUMBER = 2025.12.0b1 # 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 8fa2d8da16..93dd39b982 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0-dev" +__version__ = "2025.12.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (